/**
 * @import { TargetDescriptor5e, UnitConfiguration } from "./_types.mjs";
 */

/* -------------------------------------------- */
/*  Formatters                                  */
/* -------------------------------------------- */

/**
 * Format a Challenge Rating using the proper fractional symbols.
 * @param {number} value                   CR value to format.
 * @param {object} [options={}]
 * @param {boolean} [options.narrow=true]  Use narrow fractions (e.g. ⅛) rather than wide ones (e.g. 1/8).
 * @returns {string}
 */
function formatCR(value, { narrow=true }={}) {
  if ( value === null ) return "—";
  const fractions = narrow ? { 0.125: "⅛", 0.25: "¼", 0.5: "½" } : { 0.125: "1/8", 0.25: "1/4", 0.5: "1/2" };
  return fractions[value] ?? formatNumber(value);
}

/* -------------------------------------------- */

/**
 * Create a valid identifier from the provided string.
 * @param {string} input
 * @returns {string}
 */
function formatIdentifier(input) {
  input = input.replaceAll(/(\w+)([\\|/])(\w+)/g, "$1-$3");
  return input.slugify({ strict: true });
}

/* -------------------------------------------- */

/**
 * Form a number using the provided length unit.
 * @param {number} value         The length to format.
 * @param {string} unit          Length unit as defined in `CONFIG.DND5E.movementUnits`.
 * @param {object} [options={}]  Formatting options passed to `formatNumber`.
 * @returns {string}
 */
function formatLength(value, unit, options={}) {
  return _formatSystemUnits(value, unit, CONFIG.DND5E.movementUnits[unit], options);
}

/* -------------------------------------------- */

/**
 * Format a modifier for display with its sign separate.
 * @param {number} mod  The modifier.
 * @returns {Handlebars.SafeString}
 */
function formatModifier(mod) {
  if ( !Number.isFinite(mod) ) return new Handlebars.SafeString("—");
  return new Handlebars.SafeString(`<span class="sign">${mod < 0 ? "-" : "+"}</span>${Math.abs(mod)}`);
}

/* -------------------------------------------- */

/**
 * A helper for using Intl.NumberFormat within handlebars.
 * @param {number} value    The value to format.
 * @param {object} options  Options forwarded to {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat}
 * @param {string} [options.blank]      Format a zero or otherwise empty value as the given string.
 * @param {boolean} [options.numerals]  Format the number as roman numerals.
 * @param {boolean} [options.ordinal]   Use ordinal formatting.
 * @param {boolean} [options.words]     Write out number as full word, if possible.
 * @returns {string}
 */
function formatNumber(value, { blank, numerals, ordinal, words, ...options }={}) {
  if ( words && game.i18n.has(`DND5E.NUMBER.${value}`, false) ) return game.i18n.localize(`DND5E.NUMBER.${value}`);
  if ( !value && (typeof blank === "string") ) return blank;
  if ( numerals ) return _formatNumberAsNumerals(value);
  if ( ordinal ) return _formatNumberAsOrdinal(value, options);
  const formatter = new Intl.NumberFormat(game.i18n.lang, options);
  return formatter.format(value);
}

/**
 * Roman numerals.
 * @type {Record<string, number>}
 */
const _roman = {
  M: 1000, CM: 900, D: 500, CD: 400, C: 100, XC: 90, L: 50, XL: 40, X: 10, IX: 9, V: 5, IV: 4, I: 1
};

/**
 * Format a number as roman numerals.
 * @param {number} n  The number to format.
 * @returns {string}
 */
function _formatNumberAsNumerals(n) {
  let out = "";
  if ( (n < 1) || !Number.isInteger(n) ) return out;
  for ( const [numeral, decimal] of Object.entries(_roman) ) {
    const quotient = Math.floor(n / decimal);
    n -= quotient * decimal;
    out += numeral.repeat(quotient);
  }
  return out;
}

/* -------------------------------------------- */

/**
 * Format a number using an ordinal format.
 * @param {number} n        The number to format.
 * @param {object} options  Options forwarded to `formatNumber`.
 * @returns {string}
 */
function _formatNumberAsOrdinal(n, options={}) {
  const pr = getPluralRules({ type: "ordinal" }).select(n);
  const number = formatNumber(n, options);
  return game.i18n.has(`DND5E.ORDINAL.${pr}`) ? game.i18n.format(`DND5E.ORDINAL.${pr}`, { number }) : number;
}

/* -------------------------------------------- */

/**
 * Produce a number with the parts wrapped in their own spans.
 * @param {number} value      A number for format.
 * @param {object} [options]  Formatting options.
 * @returns {string}
 */
function formatNumberParts(value, options) {
  if ( options.numerals ) throw new Error("Cannot segment numbers when formatted as numerals.");
  return new Intl.NumberFormat(game.i18n.lang, options).formatToParts(value)
    .reduce((str, { type, value }) => `${str}<span class="${type}">${value}</span>`, "");
}

/* -------------------------------------------- */

/**
 * Form a number using the provided travel speed unit.
 * @param {number} value                    Travel speed to display.
 * @param {string} unit                     Unit as defined in `CONFIG.DND5E.travelUnits`.
 * @param {object} [options={}]             Formatting options passed to `formatNumber`.
 * @param {string} [options.period="hour"]  Time period formatting unit (e.g. hour or day).
 * @returns {string}
 */
function formatTravelSpeed(value, unit, { period="hour", ...options }={}) {
  const unitConfig = CONFIG.DND5E.travelUnits[unit];
  options.unit ??= `${unitConfig?.formattingUnit ?? unit}-per-${period}`;
  return _formatSystemUnits(value, unit, unitConfig, options);
}

/* -------------------------------------------- */

/**
 * A helper for using Intl.NumberFormat within handlebars for format a range.
 * @param {number} min      The lower end of the range.
 * @param {number} max      The upper end of the range.
 * @param {object} options  Options forwarded to {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat}
 * @returns {string}
 */
function formatRange(min, max, options) {
  const formatter = new Intl.NumberFormat(game.i18n.lang, options);
  return formatter.formatRange(min, max);
}

/* -------------------------------------------- */

/**
 * A helper function to format textarea text to HTML with linebreaks.
 * @param {string} value  The text to format.
 * @returns {Handlebars.SafeString}
 */
function formatText(value) {
  return new Handlebars.SafeString(value?.replaceAll("\n", "<br>") ?? "");
}

/* -------------------------------------------- */

/**
 * A helper function that formats a time in a human-readable format.
 * @param {number} value         Time to display.
 * @param {string} unit          Units as defined in `CONFIG.DND5E.timeUnits`.
 * @param {object} [options={}]  Formatting options passed to `formatNumber`.
 * @returns {string}
 */
function formatTime(value, unit, options={}) {
  options.maximumFractionDigits ??= 0;
  options.unitDisplay ??= "long";
  const config = CONFIG.DND5E.timeUnits[unit];
  if ( config?.counted ) {
    if ( (options.unitDisplay === "narrow") && game.i18n.has(`${config.counted}.narrow`) ) {
      return game.i18n.format(`${config.counted}.narrow`, { number: formatNumber(value, options) });
    } else {
      const pr = new Intl.PluralRules(game.i18n.lang);
      return game.i18n.format(`${config.counted}.${pr.select(value)}`, { number: formatNumber(value, options) });
    }
  }
  try {
    return formatNumber(value, { ...options, style: "unit", unit });
  } catch(err) {
    return formatNumber(value, options);
  }
}

/* -------------------------------------------- */

/**
 * Form a number using the provided volume unit.
 * @param {number} value         The volume to format.
 * @param {string} unit          Volume unit as defined in `CONFIG.DND5E.volumeUnits`.
 * @param {object} [options={}]  Formatting options passed to `formatNumber`.
 * @returns {string}
 */
function formatVolume(value, unit, options={}) {
  return _formatSystemUnits(value, unit, CONFIG.DND5E.volumeUnits[unit], options);
}

/* -------------------------------------------- */

/**
 * Form a number using the provided weight unit.
 * @param {number} value         The weight to format.
 * @param {string} unit          Weight unit as defined in `CONFIG.DND5E.weightUnits`.
 * @param {object} [options={}]  Formatting options passed to `formatNumber`.
 * @returns {string}
 */
function formatWeight(value, unit, options={}) {
  return _formatSystemUnits(value, unit, CONFIG.DND5E.weightUnits[unit], options);
}

/* -------------------------------------------- */

/**
 * Format a number using one of core's built-in unit types.
 * @param {number} value                   Value to display.
 * @param {string} unit                    Name of the unit to use.
 * @param {UnitConfiguration} config       Configuration data for the unit.
 * @param {object} [options={}]            Formatting options passed to `formatNumber`.
 * @param {boolean} [options.parts=false]  Format to parts.
 * @returns {string}
 */
function _formatSystemUnits(value, unit, config, { parts=false, ...options }={}) {
  options.unitDisplay ??= "short";
  if ( config?.counted ) {
    const localizationKey = `${config.counted}.${options.unitDisplay}.${getPluralRules().select(value)}`;
    return game.i18n.format(localizationKey, { number: formatNumber(value, options) });
  }
  unit = config?.formattingUnit ?? unit;
  if ( isValidUnit(unit) ) {
    options.style ??= "unit";
    options.unit ??= unit;
  }
  return (parts ? formatNumberParts : formatNumber)(value, options);
}

/* -------------------------------------------- */

/**
 * Cached store of Intl.PluralRules instances.
 * @type {Record<string, Intl.PluralRules>}
 */
const _pluralRules = {};

/**
 * Get a PluralRules object, fetching from cache if possible.
 * @param {object} [options={}]
 * @param {string} [options.type=cardinal]
 * @returns {Intl.PluralRules}
 */
function getPluralRules({ type="cardinal" }={}) {
  _pluralRules[type] ??= new Intl.PluralRules(game.i18n.lang, { type });
  return _pluralRules[type];
}

/* -------------------------------------------- */
/*  Formulas                                    */
/* -------------------------------------------- */

/**
 * Return whether a string is a valid reroll, explosion, min, or max dice modifier.
 * @param {string} mod      The modifier to test.
 * @returns {boolean}
 */
function isValidDieModifier(mod) {
  const regex = {
    reroll: /rr?([0-9]+)?([<>=]+)?([0-9]+)?/i,
    explode: /xo?([0-9]+)?([<>=]+)?([0-9]+)?/i,
    minimum: /(?:min)([0-9]+)/i,
    maximum: /(?:max)([0-9]+)/i,
    dropKeep: /[dk]([hl])?([0-9]+)?/i,
    count: /(?:c[sf])([<>=]+)?([0-9]+)?/i
  };
  return Object.values(regex).some(rgx => rgx.test(mod));
}

/* -------------------------------------------- */

/**
 * Convert a delta string into a number.
 * @param {string} raw     The raw string.
 * @param {number} target  A target number to apply the delta to.
 * @returns {number}
 */
function parseDelta(raw, target) {
  if ( !raw ) return target;
  let value = Number(raw);
  if ( (raw[0] === "+") || (raw[0] === "-") ) {
    const delta = parseFloat(raw);
    value = target + delta;
  }
  else if ( raw[0] === "=" ) value = Number(raw.slice(1));
  return Number.isNaN(value) ? target : value;
}

/* -------------------------------------------- */

/**
 * Handle a delta input for a number value from a form.
 * @param {HTMLInputElement} input  Input that contains the modified value.
 * @param {Document} target         Target document to be updated.
 * @returns {number|void}
 */
function parseInputDelta(input, target) {
  if ( !input?.value ) return input?.value;
  const prop = input.dataset.name ?? input.name;
  let current = foundry.utils.getProperty(target?._source ?? {}, prop) ?? foundry.utils.getProperty(target, prop);
  const value = parseDelta(input.value, Number(current));
  if ( Number.isNaN(value) ) return;
  input.value = value.toString();
  return value;
}

/* -------------------------------------------- */

/**
 * Prepare the final formula value for a model field.
 * @param {ItemDataModel|BaseActivityData} model  Model for which the value is being prepared.
 * @param {string} keyPath                        Path to the field within the model.
 * @param {string} label                          Label to use in preparation warnings.
 * @param {object} rollData                       Roll data to use when replacing formula values.
 */
function prepareFormulaValue(model, keyPath, label, rollData) {
  const value = foundry.utils.getProperty(model, keyPath);
  if ( !value ) return;
  const item = model.item ?? model.parent;
  const property = game.i18n.localize(label);
  try {
    const formula = replaceFormulaData(value, rollData, { item, property });
    const roll = new Roll(formula);
    foundry.utils.setProperty(model, keyPath, roll.evaluateSync().total);
  } catch(err) {
    if ( item.isEmbedded ) {
      const message = game.i18n.format("DND5E.FormulaMalformedError", { property, name: model.name ?? item.name });
      item.actor._preparationWarnings.push({ message, link: item.uuid, type: "error" });
      console.error(message, err);
    }
  }
}

/* -------------------------------------------- */

/**
 * 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 {Actor5e} [options.actor]            Actor for which the value is being prepared.
 * @param {Item5e} [options.item]              Item for which the value is being prepared.
 * @param {string|null} [options.missing="0"]  Value to use when replacing missing references, or `null` to not replace.
 * @param {string} [options.property]          Name of the property to which this formula belongs.
 * @returns {string}                 Formula with replaced data.
 */
function replaceFormulaData(formula, data, { actor, item, missing="0", property }={}) {
  const dataRgx = new RegExp(/@([a-z.0-9_-]+)/gi);
  const missingReferences = new Set();
  formula = String(formula).replace(dataRgx, (match, term) => {
    let value = foundry.utils.getProperty(data, term);
    if ( value == null ) {
      missingReferences.add(match);
      return missing ?? match[0];
    }
    return String(value).trim();
  });
  actor ??= item?.parent;
  if ( (missingReferences.size > 0) && actor && property ) {
    const listFormatter = new Intl.ListFormat(game.i18n.lang, { style: "long", type: "conjunction" });
    const message = game.i18n.format("DND5E.FormulaMissingReferenceWarn", {
      property, name: item?.name ?? actor.name, references: listFormatter.format(missingReferences)
    });
    actor._preparationWarnings.push({ message, link: item?.uuid ?? actor.uuid, type: "warning" });
  }
  return formula;
}

/* -------------------------------------------- */

/**
 * 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.evaluateSync().total : 0;
  } catch(error) {
    console.error(error);
    return 0;
  }
}

/* -------------------------------------------- */
/*  IDs                                         */
/* -------------------------------------------- */

/**
 * Create an ID from the input truncating or padding the value to make it reach 16 characters.
 * @param {string} id
 * @returns {string}
 */
function staticID(id) {
  if ( id.length >= 16 ) return id.substring(0, 16);
  return id.padEnd(16, "0");
}

/* -------------------------------------------- */
/*  Keybindings Helper                          */
/* -------------------------------------------- */

const { MODIFIER_CODES: CODES, MODIFIER_KEYS } = (foundry.helpers?.interaction?.KeyboardManager ?? KeyboardManager);

/**
 * Track which KeyboardEvent#code presses associate with each modifier.
 * Added support for treating Meta separate from Control.
 * @enum {string[]}
 */
const MODIFIER_CODES = {
  Alt: CODES.Alt,
  Control: CODES.Control.filter(k => k.startsWith("Control")),
  Meta: CODES.Control.filter(k => !k.startsWith("Control")),
  Shift: CODES.Shift
};

/**
 * Based on the provided event, determine if the keys are pressed to fulfill the specified keybinding.
 * @param {Event} event    Triggering event.
 * @param {string} action  Keybinding action within the `dnd5e` namespace.
 * @returns {boolean}      Is the keybinding triggered?
 */
function areKeysPressed(event, action) {
  if ( !event ) return false;
  const activeModifiers = {};
  const addModifiers = (key, pressed) => {
    activeModifiers[key] = pressed;
    MODIFIER_CODES[key].forEach(n => activeModifiers[n] = pressed);
  };
  addModifiers(MODIFIER_KEYS.ALT, event.altKey);
  addModifiers(MODIFIER_KEYS.CONTROL, event.ctrlKey);
  addModifiers("Meta", event.metaKey);
  addModifiers(MODIFIER_KEYS.SHIFT, event.shiftKey);
  return game.keybindings.get("dnd5e", action).some(b => {
    if ( game.keyboard.downKeys.has(b.key) && b.modifiers.every(m => activeModifiers[m]) ) return true;
    if ( b.modifiers.length ) return false;
    return activeModifiers[b.key];
  });
}

/* -------------------------------------------- */
/*  Logging                                     */
/* -------------------------------------------- */

/**
 * Log a console message with the "D&D 5e" prefix and styling.
 * @param {string} message                    Message to display.
 * @param {object} [options={}]
 * @param {string} [options.color="#6e0000"]  Color to use for the log.
 * @param {any[]} [options.extras=[]]         Extra options passed to the logging method.
 * @param {string} [options.level="log"]      Console logging method to call.
 */
function log(message, { color="#6e0000", extras=[], level="log" }={}) {
  console[level](
    `%cD&D 5e | %c${message}`, `color: ${color}; font-variant: small-caps`, "color: revert", ...extras
  );
}

/* -------------------------------------------- */
/*  Object Helpers                              */
/* -------------------------------------------- */

/**
 * Transform an object, returning only the keys which match the provided filter.
 * @param {object} obj         Object to transform.
 * @param {Function} [filter]  Filtering function. If none is provided, it will just check for truthiness.
 * @returns {string[]}         Array of filtered keys.
 */
function filteredKeys(obj, filter) {
  filter ??= e => e;
  return Object.entries(obj).filter(e => filter(e[1])).map(e => e[0]);
}

/* -------------------------------------------- */

/**
 * Check whether an object exists without transversing any getters, preventing any deprecation warnings from triggering.
 * @param {object} object
 * @param {string} keyPath
 * @returns {boolean}
 */
function safePropertyExists(object, keyPath) {
  const parts = keyPath.split(".");
  for ( const part of parts ) {
    const descriptor = Object.getOwnPropertyDescriptor(object, part);
    if ( !descriptor || !("value" in descriptor) ) return false;
    object = object[part];
  }
  return true;
}

/* -------------------------------------------- */

/**
 * Sort the provided object by its values or by an inner sortKey.
 * @param {object} obj                 The object to sort.
 * @param {string|Function} [sortKey]  An inner key upon which to sort or sorting function.
 * @returns {object}                   A copy of the original object that has been sorted.
 */
function sortObjectEntries(obj, sortKey) {
  let sorted = Object.entries(obj);
  const sort = (lhs, rhs) => foundry.utils.getType(lhs) === "string" ? lhs.localeCompare(rhs, game.i18n.lang) : lhs - rhs;
  if ( foundry.utils.getType(sortKey) === "function" ) sorted = sorted.sort((lhs, rhs) => sortKey(lhs[1], rhs[1]));
  else if ( sortKey ) sorted = sorted.sort((lhs, rhs) => sort(lhs[1][sortKey], rhs[1][sortKey]));
  else sorted = sorted.sort((lhs, rhs) => sort(lhs[1], rhs[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.
 * Try to build links to compendium content synchronously to avoid DB lookups.
 * @param {string} uuid                    UUID for which to produce the link.
 * @param {object} [options]
 * @param {string} [options.tooltip]       Tooltip to add to the link.
 * @param {string} [options.renderBroken]  If a UUID cannot found, render it as a broken link instead of returning the
 *                                         empty string.
 * @returns {string}                       Link to the item or empty string if item wasn't found.
 */
function linkForUuid(uuid, { tooltip, renderBroken }={}) {
  let doc = fromUuidSync(uuid);
  if ( !doc ) {
    if ( renderBroken ) return `
      <a class="content-link broken" data-uuid="${uuid}">
        <i class="fas fa-unlink"></i> ${game.i18n.localize("Unknown")}
      </a>
    `;
    return "";
  }
  if ( uuid.startsWith("Compendium.") && !(doc instanceof foundry.abstract.Document) ) {
    const {collection} = foundry.utils.parseUuid(uuid);
    const cls = collection.documentClass;
    // Minimal "shell" of a document using index data
    doc = new cls(foundry.utils.deepClone(doc), {pack: collection.metadata.id});
  }
  const a = doc.toAnchor();
  if ( tooltip ) a.dataset.tooltip = tooltip;
  return a.outerHTML;
}

/* -------------------------------------------- */
/*  Targeting                                   */
/* -------------------------------------------- */

/**
 * Grab the targeted tokens and return relevant information on them.
 * @returns {TargetDescriptor5e[]}
 */
function getTargetDescriptors() {
  const targets = new Map();
  for ( const token of game.user.targets ) {
    const { name } = token;
    const { img, system, uuid, statuses } = token.actor ?? {};
    if ( uuid ) {
      const ac = statuses.has("coverTotal") ? null : system.attributes?.ac?.value;
      targets.set(uuid, { name, img, uuid, ac: ac ?? null });
    }
  }
  return Array.from(targets.values());
}

/* -------------------------------------------- */

/**
 * Get currently selected tokens in the scene or user's character's tokens.
 * @param {Actor5e} [actor]  Only allow tokens associated with this specific actor.
 * @returns {Token5e[]}
 */
function getSceneTargets(actor) {
  let targets = canvas.tokens?.controlled.filter(t => t.actor && (!actor || t.actor === actor)) ?? [];
  if ( !targets.length && actor ) targets = actor.getActiveTokens();
  else if ( !targets.length && game.user.character ) targets = game.user.character.getActiveTokens();
  return targets;
}

/* -------------------------------------------- */
/*  Conversions                                 */
/* -------------------------------------------- */

/**
 * Convert the provided length to another unit.
 * @param {number} value                   The length being converted.
 * @param {string} from                    The initial units.
 * @param {string} to                      The final units.
 * @param {object} [options={}]
 * @param {boolean} [options.strict=true]  Throw an error if either unit isn't found.
 * @returns {number}
 */
function convertLength(value, from, to, { strict=true }={}) {
  const message = unit => `Length unit ${unit} not defined in CONFIG.DND5E.movementUnits`;
  return _convertSystemUnits(value, from, to, CONFIG.DND5E.movementUnits, { message, strict });
}

/* -------------------------------------------- */

/**
 * Convert the provided time value to another unit. If no final unit is provided, then will convert it to the largest
 * unit that can still represent the value as a whole number.
 * @param {number} value                    The time being converted.
 * @param {string} from                     The initial unit as defined in `CONFIG.DND5E.timeUnits`.
 * @param {object} [options={}]
 * @param {boolean} [options.combat=false]  Use combat units when auto-selecting units, rather than normal units.
 * @param {boolean} [options.strict=true]   Throw an error if from unit isn't found.
 * @param {string} [options.to]             The final units, if explicitly provided.
 * @returns {{ value: number, unit: string }}
 */
function convertTime(value, from, { combat=false, strict=true, to }={}) {
  const base = value * (CONFIG.DND5E.timeUnits[from]?.conversion ?? 1);
  if ( !to ) {
    // Find unit with largest conversion value that can still display the value
    const unitOptions = Object.entries(CONFIG.DND5E.timeUnits)
      .reduce((arr, [key, v]) => {
        if ( ((v.combat ?? false) === combat) && ((base % v.conversion === 0) || (base >= v.conversion * 2)) ) {
          arr.push({ key, conversion: v.conversion });
        }
        return arr;
      }, [])
      .sort((lhs, rhs) => rhs.conversion - lhs.conversion);
    to = unitOptions[0]?.key ?? from;
  }

  const message = unit => `Time unit ${unit} not defined in CONFIG.DND5E.timeUnits`;
  return { value: _convertSystemUnits(value, from, to, CONFIG.DND5E.timeUnits, { message, strict }), unit: to };
}

/* -------------------------------------------- */

/**
 * Convert the provided travel speed to another unit.
 * @param {number} value                    The travel speed being converted.
 * @param {string} from                     The initial unit.
 * @param {object} options
 * @param {boolean} [options.strict=false]  Throw an error if either unit isn't found.
 * @param {string} options.to               The final unit.
 * @returns {{ value: number, unit: string }}
 */
function convertTravelSpeed(value, from, { strict=false, to }) {
  const message = unit => `Travel speed unit ${unit} not defined in CONFIG.DND5E.travelUnits`;
  return { value: _convertSystemUnits(value, from, to, CONFIG.DND5E.travelUnits, { message, strict }), unit: to };
}

/* -------------------------------------------- */

/**
 * Convert the provided weight to another unit.
 * @param {number} value                   The weight being converted.
 * @param {string} from                    The initial unit as defined in `CONFIG.DND5E.weightUnits`.
 * @param {string} to                      The final units.
 * @param {object} [options={}]
 * @param {boolean} [options.strict=true]  Throw an error if either unit isn't found.
 * @returns {number}      Weight in the specified units.
 */
function convertWeight(value, from, to, { strict=true }={}) {
  const message = unit => `Weight unit ${unit} not defined in CONFIG.DND5E.weightUnits`;
  return _convertSystemUnits(value, from, to, CONFIG.DND5E.weightUnits, { message, strict });
}

/* -------------------------------------------- */

/**
 * Convert from one unit to another using one of core's built-in unit types.
 * @param {number} value                                Value to display.
 * @param {string} from                                 The initial unit.
 * @param {string} to                                   The final unit.
 * @param {UnitConfiguration} config                    Configuration data for the unit.
 * @param {object} options
 * @param {function(string): string} [options.message]  Method used to produce the error message if unit not found.
 * @param {boolean} [options.strict]                    Throw an error if either unit isn't found.
 * @returns {string}
 */
function _convertSystemUnits(value, from, to, config, { message, strict }) {
  if ( from === to ) return value;
  if ( strict && !config[from] ) throw new Error(message(from));
  if ( strict && !config[to] ) throw new Error(message(to));
  return value * (config[from]?.conversion ?? 1) / (config[to]?.conversion ?? 1);
}

/* -------------------------------------------- */

/**
 * Default units to use depending on system setting.
 * @param {"length"|"travel"|"volume"|"weight"} type  Type of units to select.
 * @returns {string}
 */
function defaultUnits(type) {
  const settingKey = type === "travel" ? "metricLengthUnits" : `metric${type.capitalize()}Units`;
  return CONFIG.DND5E.defaultUnits[type]?.[game.settings.get("dnd5e", settingKey) ? "metric" : "imperial"];
}

/* -------------------------------------------- */
/*  Validators                                  */
/* -------------------------------------------- */

/**
 * Ensure the provided string contains only the characters allowed in identifiers.
 * @param {string} identifier
 * @param {object} [options={}]
 * @param {boolean} [options.allowType]  Consider an identifier with a single ":" to be valid. Only the portion after
 *                                       the colon must follow the strict identifier validation.
 * @returns {boolean}
 */
function isValidIdentifier(identifier, { allowType=false }={}) {
  if ( allowType ) {
    const split = identifier.split(":");
    if ( split.length > 2 ) return false;
    identifier = split[1];
  }
  return /^([a-z0-9_-]+)$/i.test(identifier);
}

const validators = {
  isValidIdentifier: isValidIdentifier
};

/* -------------------------------------------- */

/**
 * Determine whether the provided unit is usable within `Intl.NumberFormat`.
 * @param {string} unit
 * @returns {boolean}
 */
function isValidUnit(unit) {
  if ( unit?.includes("-per-") ) return unit.split("-per-").every(u => isValidUnit(u));
  return Intl.supportedValuesOf("unit").includes(unit);
}

/* -------------------------------------------- */

/**
 * Test if a given string is serialized JSON, and parse it if so.
 * @param {string} raw  The raw value.
 * @returns {any}       The parsed value, or the original value if it was not serialized JSON.
 */
function parseOrString(raw) {
  try { return JSON.parse(raw); } catch(err) {}
  return raw;
}

/* -------------------------------------------- */
/*  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/shared/active-effects.hbs",
    "systems/dnd5e/templates/apps/parts/trait-list.hbs",
    "systems/dnd5e/templates/apps/parts/traits-list.hbs",

    // Actor Sheet Partials
    "systems/dnd5e/templates/actors/parts/actor-classes.hbs",
    "systems/dnd5e/templates/actors/parts/actor-trait-pills.hbs",
    "systems/dnd5e/templates/actors/parts/actor-traits.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",
    "systems/dnd5e/templates/actors/parts/actor-warnings-dialog.hbs",
    "systems/dnd5e/templates/actors/parts/biography-textbox.hbs",
    "systems/dnd5e/templates/actors/tabs/character-bastion.hbs",
    "systems/dnd5e/templates/actors/tabs/character-biography.hbs",
    "systems/dnd5e/templates/actors/tabs/character-details.hbs",
    "systems/dnd5e/templates/actors/tabs/creature-special-traits.hbs",
    "systems/dnd5e/templates/actors/tabs/npc-biography.hbs",

    // Chat Message Partials
    "systems/dnd5e/templates/chat/parts/card-activities.hbs",
    "systems/dnd5e/templates/chat/parts/card-deltas.hbs",

    // Item Sheet Partials
    "systems/dnd5e/templates/items/details/details-background.hbs",
    "systems/dnd5e/templates/items/details/details-class.hbs",
    "systems/dnd5e/templates/items/details/details-consumable.hbs",
    "systems/dnd5e/templates/items/details/details-container.hbs",
    "systems/dnd5e/templates/items/details/details-equipment.hbs",
    "systems/dnd5e/templates/items/details/details-facility.hbs",
    "systems/dnd5e/templates/items/details/details-feat.hbs",
    "systems/dnd5e/templates/items/details/details-loot.hbs",
    "systems/dnd5e/templates/items/details/details-mountable.hbs",
    "systems/dnd5e/templates/items/details/details-species.hbs",
    "systems/dnd5e/templates/items/details/details-spell.hbs",
    "systems/dnd5e/templates/items/details/details-spellcasting.hbs",
    "systems/dnd5e/templates/items/details/details-starting-equipment.hbs",
    "systems/dnd5e/templates/items/details/details-subclass.hbs",
    "systems/dnd5e/templates/items/details/details-tool.hbs",
    "systems/dnd5e/templates/items/details/details-weapon.hbs",
    "systems/dnd5e/templates/items/parts/item-summary.hbs",
    "systems/dnd5e/templates/items/parts/item-tooltip.hbs",
    "systems/dnd5e/templates/items/parts/spell-block.hbs",

    // Field Partials
    "systems/dnd5e/templates/shared/fields/field-activation.hbs",
    "systems/dnd5e/templates/shared/fields/field-damage.hbs",
    "systems/dnd5e/templates/shared/fields/field-duration.hbs",
    "systems/dnd5e/templates/shared/fields/field-range.hbs",
    "systems/dnd5e/templates/shared/fields/field-targets.hbs",
    "systems/dnd5e/templates/shared/fields/field-uses.hbs",
    "systems/dnd5e/templates/shared/fields/fieldlist.hbs",
    "systems/dnd5e/templates/shared/fields/formlist.hbs",

    // Journal Partials
    "systems/dnd5e/templates/journal/parts/journal-legacy-traits.hbs",
    "systems/dnd5e/templates/journal/parts/journal-modern-traits.hbs",
    "systems/dnd5e/templates/journal/parts/journal-table.hbs",

    // Activity Partials
    "systems/dnd5e/templates/activity/parts/activity-usage-notes.hbs",

    // Advancement Partials
    "systems/dnd5e/templates/advancement/parts/advancement-ability-score-control.hbs",
    "systems/dnd5e/templates/advancement/parts/advancement-controls.hbs",
    "systems/dnd5e/templates/advancement/parts/advancement-spell-config.hbs"
  ];

  const paths = {};
  for ( const path of partials ) {
    paths[path.replace(".hbs", ".html")] = path;
    paths[`dnd5e.${path.split("/").pop().replace(".hbs", "")}`] = path;
  }

  return foundry.applications.handlebars.loadTemplates(paths);
}

/* -------------------------------------------- */

/**
 * A helper that converts the provided object into a series of `data-` entries.
 * @param {object} object   Object to convert into dataset entries.
 * @param {object} options  Handlebars options.
 * @returns {string}
 */
function dataset(object, options) {
  const entries = [];
  for ( let [key, value] of Object.entries(object ?? {}) ) {
    if ( value === undefined ) continue;
    key = key.replace(/[A-Z]+(?![a-z])|[A-Z]/g, (a, b) => (b ? "-" : "") + a.toLowerCase());
    entries.push(`data-${key}="${Handlebars.escapeExpression(value)}"`);
  }
  return new Handlebars.SafeString(entries.join(" "));
}

/* -------------------------------------------- */

/**
 * Create an icon element dynamically based on the provided icon string, supporting FontAwesome class strings
 * or paths to SVG or other image types.
 * @param {string} icon           Icon class or path.
 * @param {object} [options={}]
 * @param {string} [options.alt]  Alt text for the icon.
 * @returns {HTMLElement|null}
 */
function generateIcon(icon, { alt }={}) {
  let element;
  if ( icon?.startsWith("fa") ) {
    element = document.createElement("i");
    element.className = icon;
  } else if ( icon ) {
    element = document.createElement(icon.endsWith(".svg") ? "dnd5e-icon" : "img");
    element.src = icon;
  } else {
    return null;
  }
  if ( alt ) element[element.tagName === "IMG" ? "alt" : "ariaLabel"] = alt;
  return element;
}

/* -------------------------------------------- */

/**
 * A helper to create a set of <option> elements in a <select> block grouped together
 * in <optgroup> based on the provided categories.
 *
 * @param {SelectChoices} choices          Choices to format.
 * @param {object} [options]
 * @param {boolean} [options.localize]     Should the label be localized?
 * @param {string} [options.blank]         Name for the empty option, if one should be added.
 * @param {string} [options.labelAttr]     Attribute pointing to label string.
 * @param {string} [options.chosenAttr]    Attribute pointing to chosen boolean.
 * @param {string} [options.childrenAttr]  Attribute pointing to array of children.
 * @returns {Handlebars.SafeString}        Formatted option list.
 */
function groupedSelectOptions(choices, options) {
  const localize = options.hash.localize ?? false;
  const blank = options.hash.blank ?? null;
  const labelAttr = options.hash.labelAttr ?? "label";
  const chosenAttr = options.hash.chosenAttr ?? "chosen";
  const childrenAttr = options.hash.childrenAttr ?? "children";

  // Create an option
  const option = (name, label, chosen) => {
    if ( localize ) label = game.i18n.localize(label);
    html += `<option value="${name}" ${chosen ? "selected" : ""}>${label}</option>`;
  };

  // Create a group
  const group = category => {
    let label = category[labelAttr];
    if ( localize ) game.i18n.localize(label);
    html += `<optgroup label="${label}">`;
    children(category[childrenAttr]);
    html += "</optgroup>";
  };

  // Add children
  const children = children => {
    for ( let [name, child] of Object.entries(children) ) {
      if ( child[childrenAttr] ) group(child);
      else option(name, child[labelAttr], child[chosenAttr] ?? false);
    }
  };

  // Create the options
  let html = "";
  if ( blank !== null ) option("", blank);
  children(choices);
  return new Handlebars.SafeString(html);
}

/* -------------------------------------------- */

/**
 * 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] });
}

/* -------------------------------------------- */

/**
 * Conceal a section and display a notice if unidentified.
 * @param {boolean} conceal  Should the section be concealed?
 * @param {object} options   Handlebars options.
 * @returns {string}
 */
function concealSection(conceal, options) {
  let content = options.fn(this);
  if ( !conceal ) return content;

  content = `<div inert>
    ${content}
  </div>
  <div class="unidentified-notice">
      <div>
          <strong>${game.i18n.localize("DND5E.Unidentified.Title")}</strong>
          <p>${game.i18n.localize("DND5E.Unidentified.Notice")}</p>
      </div>
  </div>`;
  return content;
}

/* -------------------------------------------- */

/**
 * Construct an object from the provided arguments.
 * @param {object} options       Handlebars options.
 * @param {object} options.hash
 * @returns {object}
 */
function makeObject({ hash }) {
  return hash;
}

/* -------------------------------------------- */

/**
 * Register custom Handlebars helpers used by 5e.
 */
function registerHandlebarsHelpers() {
  const curryUnitFormatter = method => (value, { hash }) => {
    const { unit, ...options } = hash;
    return method(value, unit, options);
  };
  Handlebars.registerHelper({
    getProperty: foundry.utils.getProperty,
    "dnd5e-concealSection": concealSection,
    "dnd5e-dataset": dataset,
    "dnd5e-icon": (icon, { hash: options }) => {
      let element = generateIcon(icon, options);
      if ( !element && options.fallback ) element = generateIcon(options.fallback, options);
      return element ? new Handlebars.SafeString(element.outerHTML) : "";
    },
    "dnd5e-formatCR": (value, options) => formatCR(value, options.hash),
    "dnd5e-formatLength": curryUnitFormatter(formatLength),
    "dnd5e-formatModifier": formatModifier,
    "dnd5e-formatTravelSpeed": curryUnitFormatter(formatTravelSpeed),
    "dnd5e-formatTime": curryUnitFormatter(formatTime),
    "dnd5e-formatVolume": curryUnitFormatter(formatVolume),
    "dnd5e-formatWeight": curryUnitFormatter(formatWeight),
    "dnd5e-groupedSelectOptions": groupedSelectOptions,
    "dnd5e-itemContext": itemContext,
    "dnd5e-linkForUuid": (uuid, options) => linkForUuid(uuid, options.hash),
    "dnd5e-numberFormat": (value, options) => formatNumber(value, options.hash),
    "dnd5e-numberParts": (value, options) => formatNumberParts(value, options.hash),
    "dnd5e-object": makeObject,
    "dnd5e-textFormat": formatText
  });
}

/* -------------------------------------------- */
/*  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);
    if ( !target ) continue;
    _localizeObject(target, settings.keys);
    if ( settings.sort ) foundry.utils.setProperty(config, keyPath, sortObjectEntries(target, settings.keys[0]));
  }

  // Localize & sort status effects
  CONFIG.statusEffects.forEach(s => s.name = game.i18n.localize(s.name));
  if ( game.release.generation < 14 ) {
    CONFIG.statusEffects.sort((lhs, rhs) =>
      lhs.order || rhs.order ? (lhs.order ?? Infinity) - (rhs.order ?? Infinity)
        : lhs.name.localeCompare(rhs.name, game.i18n.lang)
    );
  }
}

/* -------------------------------------------- */

/**
 * 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 ) {
      const value = foundry.utils.getProperty(v, key);
      if ( !value ) continue;
      foundry.utils.setProperty(v, key, game.i18n.localize(value));
    }
  }
}

/* -------------------------------------------- */
/*  Localization                                */
/* -------------------------------------------- */

/**
 * A cache of already-fetched labels for faster lookup.
 * @type {Record<string, Map<string, string>>}
 */
const _attributeLabelCache = {
  activity: new Map(),
  actor: new Map(),
  item: new Map()
};

/**
 * Convert an attribute path to a human-readable label. Assumes paths are on an actor unless an reference item
 * is provided.
 * @param {string} attr              The attribute path.
 * @param {object} [options]
 * @param {Actor5e} [options.actor]  An optional reference actor.
 * @param {Item5e} [options.item]    An optional reference item.
 * @returns {string|void}
 */
function getHumanReadableAttributeLabel(attr, { actor, item }={}) {
  if ( attr.startsWith("system.") ) attr = attr.slice(7);

  // Check any actor-specific names first.
  if ( attr.match(/^resources\.(?:primary|secondary|tertiary)/) && actor ) {
    const key = attr.replace(/\.value$/, "");
    const resource = foundry.utils.getProperty(actor, `system.${key}`);
    if ( resource?.label ) return resource.label;
  }

  if ( (attr === "details.xp.value") && (actor?.type === "npc") ) {
    return game.i18n.localize("DND5E.ExperiencePoints.Value");
  }

  if ( attr.startsWith(".") && actor ) {
    // TODO: Remove `strict: false` when https://github.com/foundryvtt/foundryvtt/issues/11214 is resolved
    // Only necessary when opening the token config for an actor in a compendium
    const item = fromUuidSync(attr, { relative: actor, strict: false });
    return item?.name ?? attr;
  }

  // Check if the attribute is already in cache.
  let label = item ? null : _attributeLabelCache.actor.get(attr);
  if ( label ) return label;
  let name;
  let type = "actor";

  const getSchemaLabel = (attr, type, doc) => {
    if ( doc ) return doc.system.schema.getField(attr)?.label;
    for ( const model of Object.values(CONFIG[type].dataModels) ) {
      const field = model.schema.getField(attr);
      if ( field ) return field.label;
    }
  };

  // Activity labels
  if ( item && attr.startsWith("activities.") ) {
    let [, activityId, ...keyPath] = attr.split(".");
    const activity = item.system.activities?.get(activityId);
    if ( !activity ) return attr;
    attr = keyPath.join(".");
    name = `${item.name}: ${activity.name}`;
    type = "activity";
    if ( _attributeLabelCache.activity.has(attr) ) label = _attributeLabelCache.activity.get(attr);
    else if ( attr === "uses.spent" ) label = "DND5E.Uses";
  }

  // Item labels
  else if ( item ) {
    name = item.name;
    type = "item";
    if ( _attributeLabelCache.item.has(attr) ) label = _attributeLabelCache.item.get(attr);
    else if ( attr === "hd.spent" ) label = "DND5E.HitDice";
    else if ( attr === "uses.spent" ) label = "DND5E.Uses";
    else label = getSchemaLabel(attr, "Item", item);
  }

  // Derived fields.
  else if ( attr === "attributes.init.total" ) label = "DND5E.InitiativeBonus";
  else if ( (attr === "attributes.ac.value") || (attr === "attributes.ac.flat") ) label = "DND5E.ArmorClass";
  else if ( attr === "attributes.spell.attack" ) label = "DND5E.SpellAttackBonus";
  else if ( attr === "attributes.spell.dc" ) label = "DND5E.SpellDC";

  // Abilities.
  else if ( attr.startsWith("abilities.") ) {
    const [, key] = attr.split(".");
    label = game.i18n.format("DND5E.AbilityScoreL", { ability: CONFIG.DND5E.abilities[key].label });
  }

  // Resources
  else if ( attr === "resources.legact.spent" ) label = "DND5E.LegendaryAction.LabelPl";
  else if ( attr === "resources.legact.value" ) label = "DND5E.LegendaryAction.Remaining";
  else if ( attr === "resources.legres.spent" ) label = "DND5E.LegendaryResistance.LabelPl";
  else if ( attr === "resources.legres.value" ) label = "DND5E.LegendaryResistance.Remaining";
  else if ( attr === "attributes.actions.value" ) label = "DND5E.VEHICLE.FIELDS.attributes.actions.label";

  // Skills.
  else if ( attr.startsWith("skills.") ) {
    const [, key] = attr.split(".");
    label = game.i18n.format("DND5E.SkillPassiveScore", { skill: CONFIG.DND5E.skills[key].label });
  }

  // Spell slots.
  else if ( attr.startsWith("spells.") ) {
    const [, key] = attr.split(".");
    if ( !/spell\d+/.test(key) ) label = `DND5E.SpellSlots${key.capitalize()}`;
    else {
      const plurals = new Intl.PluralRules(game.i18n.lang, { type: "ordinal" });
      const level = Number(key.slice(5));
      label = game.i18n.format(`DND5E.SpellSlotsN.${plurals.select(level)}`, { n: level });
    }
  }

  // Currency
  else if ( attr.startsWith("currency.") ) {
    const [, key] = attr.split(".");
    label = CONFIG.DND5E.currencies[key]?.label;
  }

  // Attempt to find the attribute in a data model.
  if ( !label ) label = getSchemaLabel(attr, "Actor", actor);

  if ( label ) {
    label = game.i18n.localize(label);
    _attributeLabelCache[type].set(attr, label);
    if ( name ) label = `${name} ${label}`;
  }

  return label;
}

/* -------------------------------------------- */

/**
 * Perform pre-localization on the contents of a SchemaField. Necessary because the `localizeSchema` method
 * on `Localization` is private.
 * @param {SchemaField} schema
 * @param {string[]} prefixes
 */
function localizeSchema(schema, prefixes) {
  foundry.helpers.Localization.localizeDataModel({ schema }, { prefixes });
}

/* -------------------------------------------- */

/**
 * Split a semi-colon-separated list and clean out any empty entries.
 * @param {string} input
 * @returns {string[]}
 */
function splitSemicolons(input="") {
  return input.split(";").map(t => t.trim()).filter(t => t);
}

/* -------------------------------------------- */
/*  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, "_stats.compendiumSource", 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,
  areKeysPressed: areKeysPressed,
  convertLength: convertLength,
  convertTime: convertTime,
  convertTravelSpeed: convertTravelSpeed,
  convertWeight: convertWeight,
  defaultUnits: defaultUnits,
  filteredKeys: filteredKeys,
  formatCR: formatCR,
  formatIdentifier: formatIdentifier,
  formatLength: formatLength,
  formatModifier: formatModifier,
  formatNumber: formatNumber,
  formatNumberParts: formatNumberParts,
  formatRange: formatRange,
  formatText: formatText,
  formatTime: formatTime,
  formatTravelSpeed: formatTravelSpeed,
  formatVolume: formatVolume,
  formatWeight: formatWeight,
  generateIcon: generateIcon,
  getHumanReadableAttributeLabel: getHumanReadableAttributeLabel,
  getPluralRules: getPluralRules,
  getSceneTargets: getSceneTargets,
  getTargetDescriptors: getTargetDescriptors,
  indexFromUuid: indexFromUuid,
  isValidDieModifier: isValidDieModifier,
  isValidUnit: isValidUnit,
  linkForUuid: linkForUuid,
  localizeSchema: localizeSchema,
  log: log,
  parseDelta: parseDelta,
  parseInputDelta: parseInputDelta,
  parseOrString: parseOrString,
  performPreLocalization: performPreLocalization,
  preLocalize: preLocalize,
  preloadHandlebarsTemplates: preloadHandlebarsTemplates,
  prepareFormulaValue: prepareFormulaValue,
  registerHandlebarsHelpers: registerHandlebarsHelpers,
  replaceFormulaData: replaceFormulaData,
  safePropertyExists: safePropertyExists,
  simplifyBonus: simplifyBonus,
  sortObjectEntries: sortObjectEntries,
  splitSemicolons: splitSemicolons,
  staticID: staticID,
  synchronizeActorSpells: synchronizeActorSpells,
  validators: validators
});

/**
 * @import { CalendarFormattingContext, CalendarTimeDeltas } from "./_types.mjs";
 */

/**
 * Extension of the core calendar with extra formatters.
 */
class CalendarData5e extends foundry.data.CalendarData {

  /* -------------------------------------------- */
  /*  Calendar Helper Methods                     */
  /* -------------------------------------------- */

  /**
   * Calculate the decimal hours since the start of the day.
   * @param {number|TimeComponents} [time]  The time to use, by default the current world time.
   * @param {CalendarData} [calendar]       Calendar to use, by default the current world calendar.
   * @returns {number}                      Number of hours since the start of the day as a decimal.
   */
  static hoursOfDay(time=game.time.components, calendar=game.time.calendar) {
    const components = typeof time === "number" ? this.timeToComponents(time) : time;
    const minutes = components.minute + (components.second / calendar.days.secondsPerMinute);
    return components.hour + (minutes / calendar.days.minutesPerHour);
  }

  /* -------------------------------------------- */

  /**
   * Get the number of hours in a given day.
   * @param {number|TimeComponents} [time]  The time to use, by default the current world time.
   * @returns {number}                      Number of hours between sunrise and sunset.
   */
  daylightHours(time=game.time.components) {
    return this.sunset(time) - this.sunrise(time);
  }

  /* -------------------------------------------- */

  /**
   * Progress between sunrise and sunset assuming it is daylight half the day duration.
   * @param {number|TimeComponents} [time]  The time to use, by default the current world time.
   * @returns {number}                      Progress through day period, with 0 representing sunrise and 1 sunset.
   */
  progressDay(time=game.time.components) {
    return (CalendarData5e.hoursOfDay(time) - this.sunrise(time)) / this.daylightHours(time);
  }

  /* -------------------------------------------- */

  /**
   * Progress between sunset and sunrise assuming it is night half the day duration.
   * @param {number|TimeComponents} [time]  The time to use, by default the current world time.
   * @returns {number}                      Progress through night period, with 0 representing sunset and 1 sunrise.
   */
  progressNight(time=game.time.components) {
    const daylightHours = this.daylightHours(time);
    let hour = CalendarData5e.hoursOfDay(time);
    if ( hour < daylightHours ) hour += this.days.hoursPerDay;
    return (hour - this.sunset(time)) / daylightHours;
  }

  /* -------------------------------------------- */

  /**
   * Get the sunrise time for a given day.
   * @param {number|TimeComponents} [time]  The time to use, by default the current world time.
   * @returns {number}                      Sunrise time in hours.
   */
  sunrise(time=game.time.components) {
    return this.days.hoursPerDay * .25;
  }

  /* -------------------------------------------- */

  /**
   * Get the sunset time for a given day.
   * @param {number|TimeComponents} [time]  The time to use, by default the current world time.
   * @returns {number}                      Sunset time in hours.
   */
  sunset(time=game.time.components) {
    return this.days.hoursPerDay * .75;
  }

  /* -------------------------------------------- */
  /*  Set Date Methods                            */
  /* -------------------------------------------- */

  /**
   * Set the date to a specific year, month, or day. Any values not provided will remain the same.
   * @param {object} components
   * @param {number} [components.year]   Visible year (with `yearZero` added in).
   * @param {number} [components.month]  Index of month.
   * @param {number} [components.day]    Day within the month.
   */
  async jumpToDate({ year, month, day }) {
    const components = { ...game.time.components };
    year ??= components.year + this.years.yearZero;
    month ??= components.month;
    day ??= components.dayOfMonth;

    // Subtract out year zero
    components.year = year - this.years.yearZero;
    const { leapYear } = this._decomposeTimeYears(this.componentsToTime(components));

    // Convert days within month to day of year
    let dayOfYear = day - 1;
    for ( let idx=0; idx<month; idx++ ) {
      const m = this.months.values[idx];
      dayOfYear += leapYear ? (m.leapDays ?? m.days) : m.days;
    }
    components.day = dayOfYear;
    components.month = month;

    await game.time.set(components);
  }

  /* -------------------------------------------- */
  /*  Formatter Functions                         */
  /* -------------------------------------------- */

  /**
   * Prepared date parts passed to the localization.
   * @param {CalendarData} calendar      The configured calendar.
   * @param {TimeComponents} components  Time components to format.
   * @returns {CalendarFormattingContext}
   */
  static dateFormattingParts(calendar, components) {
    const month = calendar.months.values[components.month];
    return {
      // Year
      y: components.year + calendar.years.yearZero,
      yyyy: (components.year + calendar.years.yearZero).paddedString(4),

      // Month
      b: month.abbreviation,
      B: game.i18n.localize(month.name),
      m: month.ordinal,
      mm: month.ordinal.paddedString(2),

      // Week
      // a: Day of week abbreviation (e.g. Mon, Tue)
      // A: Day of week full name (e.g. Monday, Tuesday)
      // W: week number of the year

      // Day
      d: components.dayOfMonth + 1,
      dd: (components.dayOfMonth + 1).paddedString(2),
      D: formatNumber(components.dayOfMonth + 1, { ordinal: true }),
      j: (components.day + 1).paddedString(3),
      w: String(components.dayOfWeek + 1),

      // Hour
      H: components.hour.paddedString(2),

      // Minute
      M: components.minute.paddedString(2),

      // Second
      S: components.second.paddedString(2)
    };
  }

  /* -------------------------------------------- */

  /**
   * Format the date to approximate value based on season (e.g. "Early Spring", "Mid-Winter").
   * @param {CalendarData} calendar      The configured calendar.
   * @param {TimeComponents} components  Time components to format.
   * @param {object} options             Additional formatting options.
   * @returns {string}                   The returned string format.
   */
  static formatApproximateDate(calendar, components, options) {
    const season = calendar.seasons.values[components.season];
    let day = components.day;
    let seasonStart = season.dayStart;
    let seasonEnd = season.dayEnd;
    let days = 0;
    for ( const month of calendar.months.values ) {
      if ( (seasonStart !== null) && (seasonEnd !== null) ) break;
      if ( season.monthStart === month.ordinal ) seasonStart = days;
      if ( season.monthEnd === month.ordinal ) seasonEnd = days;
      days += components.leapYear ? month.leapDays ?? month.days : month.days;
    }
    if ( seasonEnd < seasonStart ) seasonEnd += calendar.days.daysPerYear;
    if ( day < seasonStart ) day += calendar.days.daysPerYear;
    const seasonPercent = (day - seasonStart) / (seasonEnd - seasonStart);
    let formatter = "Mid";
    if ( seasonPercent <= 0.33 ) formatter = "Early";
    else if ( seasonPercent >= 0.66 ) formatter = "Late";
    return game.i18n.format(`DND5E.CALENDAR.Formatters.ApproximateDate.${formatter}Season`, {
      season: game.i18n.localize(season.name)
    });
  }

  /* -------------------------------------------- */

  /**
   * Format the time to approximate value (e.g. "Dawn", "Noon", "Night").
   * @param {CalendarData} calendar      The configured calendar.
   * @param {TimeComponents} components  Time components to format.
   * @param {object} options             Additional formatting options.
   * @returns {string}                   The returned string format.
   */
  static formatApproximateTime(calendar, components, options) {
    const day = calendar.progressDay(components);
    const night = calendar.progressNight(components);
    let formatter;
    if ( (night > 0.96) && (day < 0.04) ) formatter = "Sunrise";
    else if ( (day > 0.96) && (night < 0.04) ) formatter = "Sunset";
    else if ( (day > 0.45) && (day < 0.55) ) formatter = "Noon";
    else if ( (night > 0.45) && (night < 0.55) ) formatter = "Midnight";
    else if ( (night > 0.84) && (day < 0) ) formatter = "Dawn";
    else if ( (day > 1) && (night < 0.16) ) formatter = "Dusk";
    else if ( (day > 0) && (day < 0.5) ) formatter = "Morning";
    else if ( (day > 0.5) && (day <= 0.85) ) formatter = "Afternoon";
    else if ( (day > 0.85) && (night < 0) ) formatter = "Evening";
    else formatter = "Night";
    return game.i18n.localize(`DND5E.CALENDAR.Formatters.ApproximateTime.${formatter}`);
  }

  /* -------------------------------------------- */

  /**
   * Format the time to a value including hours and minutes.
   * @param {CalendarData} calendar      The configured calendar.
   * @param {TimeComponents} components  Time components to format.
   * @param {object} options             Additional formatting options.
   * @returns {string}                   The returned string format.
   */
  static formatHoursMinutes(calendar, components, options) {
    return CalendarData5e.formatLocalized(
      "DND5E.CALENDAR.Formatters.HoursMinutes.Format", calendar, components, options
    );
  }

  /* -------------------------------------------- */

  /**
   * Format the time to a value including hours, minutes, and seconds.
   * @param {CalendarData} calendar      The configured calendar.
   * @param {TimeComponents} components  Time components to format.
   * @param {object} options             Additional formatting options.
   * @returns {string}                   The returned string format.
   */
  static formatHoursMinutesSeconds(calendar, components, options) {
    return CalendarData5e.formatLocalized(
      "DND5E.CALENDAR.Formatters.HoursMinutesSeconds.Format", calendar, components, options
    );
  }

  /* -------------------------------------------- */

  /**
   * Format the date using the provided localization key and the default formatting parts.
   * @param {string} localizationKey         Key to use for localization.
   * @param {CalendarData} calendar          The configured calendar.
   * @param {TimeComponents} components   Time components to format.
   * @param {object} options                 Additional formatting options.
   * @returns {string}                       The returned string format.
   */
  static formatLocalized(localizationKey, calendar, components, options) {
    return game.i18n.format(localizationKey, CalendarData5e.dateFormattingParts(calendar, components));
  }

  /* -------------------------------------------- */

  /**
   * Format the date to a value including month and day.
   * @param {CalendarData} calendar      The configured calendar.
   * @param {TimeComponents} components  Time components to format.
   * @param {object} options                 Additional formatting options.
   * @returns {string}                       The returned string format.
   */
  static formatMonthDay(calendar, components, options) {
    return CalendarData5e.formatLocalized(
      "DND5E.CALENDAR.Formatters.MonthDay.Format", calendar, components, options
    );
  }

  /* -------------------------------------------- */

  /**
   * Format the date to a value including month, day, and year.
   * @param {CalendarData} calendar      The configured calendar.
   * @param {TimeComponents} components  Time components to format.
   * @param {object} options             Additional formatting options.
   * @returns {string}                   The returned string format.
   */
  static formatMonthDayYear(calendar, components, options) {
    return CalendarData5e.formatLocalized(
      "DND5E.CALENDAR.Formatters.MonthDayYear.Format", calendar, components, options
    );
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Inject additional information into time update.
   * @param {number} worldTime
   * @param {number} deltaTime
   * @param {object} options
   * @param {string} userId
   */
  static onUpdateWorldTime(worldTime, deltaTime, options, userId) {
    const previousTime = game.time.calendar.timeToComponents(game.time.worldTime - deltaTime);
    const previousHour = CalendarData5e.hoursOfDay(previousTime);
    const nowTime = game.time.components;
    const nowHour = CalendarData5e.hoursOfDay(nowTime);
    const passedHour = hour => {
      if ( (previousHour < hour) && (nowHour > hour) ) return 1;
      if ( (previousHour > hour) && (nowHour < hour) ) return -1;
      return 0;
    };

    const days = CalendarData5e.#dayDifference(previousTime, nowTime);
    foundry.utils.setProperty(options, "dnd5e.deltas", {
      midnights: days,
      middays: days + passedHour(game.time.calendar.days.hoursPerDay / 2),
      sunrises: ("sunrise" in game.time.calendar) ? days + passedHour(game.time.calendar.sunrise(nowTime)) : null,
      sunsets: ("sunset" in game.time.calendar) ? days + passedHour(game.time.calendar.sunset(nowTime)) : null
    });
  }

  /* -------------------------------------------- */

  /**
   * Count the total number of full days that have passed between two times.
   * @param {Components} previousTime
   * @param {Components} currentTime
   * @returns {number}
   */
  static #dayDifference(previousTime, currentTime) {
    // If years are the same, simple subtraction should work
    if ( previousTime.year === currentTime.year ) return currentTime.day - previousTime.day;

    // Calculate the number of days just in current & previous years
    const daysForYear = year => game.time.calendar.isLeapYear(previousTime.year)
      ? game.time.calendar.days.daysPerLeapYear : game.time.calendar.days.daysPerYear;
    let days = 0;
    if ( previousTime.year < currentTime.year ) {
      days += currentTime.day + (daysForYear(previousTime.year) - previousTime.day);
    } else {
      days -= (daysForYear(currentTime.year) - currentTime.day) + previousTime.day;
    }

    // If more than one year has passed, count the number of days for each year
    const largerYear = Math.max(currentTime.year, previousTime.year);
    const smallerYear = Math.min(currentTime.year, previousTime.year);
    for ( let year = smallerYear + 1; year < largerYear; year++ ) {
      if ( previousTime.year < currentTime.year ) days += daysForYear();
      else days -= daysForYear();
    }

    return days;
  }
}

/**
 * A specialized subclass of ContextMenu that places the menu in a fixed position.
 * @extends {ContextMenu}
 */
class ContextMenu5e extends foundry.applications.ux.ContextMenu {
  /** @override */
  _setPosition(html, target, options={}) {
    html.classList.add("dnd5e2");
    return this._setFixedPosition(html, target, options);
  }

  /* -------------------------------------------- */

  /**
   * Trigger a context menu event in response to a normal click on a additional options button.
   * @param {PointerEvent} event
   */
  static triggerEvent(event) {
    event.preventDefault();
    event.stopPropagation();
    const { clientX, clientY } = event;
    const selector = "[data-id],[data-effect-id],[data-item-id],[data-message-id],[data-activity-id]";
    const target = event.target.closest(selector) ?? event.currentTarget.closest(selector);
    target?.dispatchEvent(new PointerEvent("contextmenu", {
      view: window, bubbles: true, cancelable: true, clientX, clientY
    }));
  }
}

const { HandlebarsApplicationMixin } = foundry.applications.api;

/**
 * @import { ApplicationContainerParts } from "./_types.mjs";
 */

/**
 * Mixin method for ApplicationV2-based 5e applications.
 * @template {ApplicationV2} T
 * @param {typeof T} Base   Application class being extended.
 * @returns {typeof BaseApplication5e}
 * @mixin
 */
function ApplicationV2Mixin(Base) {
  class BaseApplication5e extends HandlebarsApplicationMixin(Base) {
    /** @override */
    static DEFAULT_OPTIONS = {
      actions: {
        toggleCollapsed: BaseApplication5e.#toggleCollapsed
      },
      classes: ["dnd5e2"],
      window: {
        subtitle: ""
      }
    };

    /* -------------------------------------------- */

    /**
     * @type {Record<string, HandlebarsTemplatePart & ApplicationContainerParts>}
     */
    static PARTS = {};

    /* -------------------------------------------- */
    /*  Properties                                  */
    /* -------------------------------------------- */

    /**
     * Expanded states for collapsible sections to persist between renders.
     * @type {Map<string, boolean>}
     */
    #expandedSections = new Map();

    get expandedSections() {
      return this.#expandedSections;
    }

    /* -------------------------------------------- */

    /**
     * A reference to the window subtitle.
     * @type {string}
     */
    get subtitle() {
      return game.i18n.localize(this.options.window.subtitle ?? "");
    }

    /* -------------------------------------------- */
    /*  Rendering                                   */
    /* -------------------------------------------- */

    /** @inheritDoc */
    _configureRenderOptions(options) {
      super._configureRenderOptions(options);
      if ( options.isFirstRender && this.hasFrame ) {
        options.window ||= {};
        options.window.subtitle ||= this.subtitle;
      }
    }

    /* -------------------------------------------- */

    /**
     * Translate header controls to context menu entries.
     * @returns {Generator<ContextMenuEntry>}
     * @yields {ContextMenuEntry}
     * @protected
     */
    *_getHeaderControlContextEntries() {
      for ( const { icon, label, action, onClick } of this._headerControlButtons() ) {
        let handler = this.options.actions[action];
        if ( typeof handler === "object" ) {
          if ( handler.buttons && !handler.buttons.includes(0) ) continue;
          handler = handler.handler;
        }
        yield {
          name: label,
          icon: `<i class="${icon}" inert></i>`,
          callback: li => {
            if ( onClick ) onClick(window.event);
            else if ( handler ) handler.call(this, window.event, li);
            else this._onClickAction(window.event, li);
          }
        };
      }
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    _onFirstRender(context, options) {
      super._onFirstRender(context, options);
      this._renderContainers(context, options);
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    async _prepareContext(options) {
      const context = await super._prepareContext(options);
      context.CONFIG = CONFIG.DND5E;
      context.inputs = { ...foundry.applications.fields, ...dnd5e.applications.fields };
      return context;
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    async _preparePartContext(partId, context, options) {
      return { ...await super._preparePartContext(partId, context, options) };
    }

    /* -------------------------------------------- */

    /**
     * Lazily create containers and place parts appropriately.
     * @param {object} context  Render context.
     * @param {object} options  Render options.
     * @protected
     */
    _renderContainers(context, options) {
      const containerElements = Array.from(this.element.querySelectorAll("[data-container-id]"));
      const containers = Object.fromEntries(containerElements.map(el => [el.dataset.containerId, el]));
      for ( const [part, config] of Object.entries(this.constructor.PARTS) ) {
        if ( !config.container?.id ) continue;
        const element = this.element.querySelector(`[data-application-part="${part}"]`);
        if ( !element ) continue;
        let container = containers[config.container.id];
        if ( !container ) {
          const div = document.createElement("div");
          div.dataset.containerId = config.container.id;
          div.classList.add(...config.container.classes ?? []);
          container = containers[config.container.id] = div;
          element.replaceWith(div);
        }
        if ( element.parentElement !== container ) container.append(element);
      }
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    async _renderFrame(options) {
      const frame = await super._renderFrame(options);

      // Subtitles
      const subtitle = document.createElement("h2");
      subtitle.classList.add("window-subtitle");
      frame?.querySelector(".window-title")?.insertAdjacentElement("afterend", subtitle);

      // Icon
      if ( (options.window?.icon ?? "").includes(".") ) {
        const icon = frame.querySelector(".window-icon");
        const newIcon = document.createElement(options.window.icon?.endsWith(".svg") ? "dnd5e-icon" : "img");
        newIcon.classList.add("window-icon");
        newIcon.src = options.window.icon;
        icon.replaceWith(newIcon);
      }

      return frame;
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    _replaceHTML(result, content, options) {
      for ( const part of Object.values(result) ) {
        for ( const element of part.querySelectorAll("[data-expand-id]") ) {
          element.querySelector(".collapsible")?.classList
            .toggle("collapsed", !this.#expandedSections.get(element.dataset.expandId));
        }
      }
      super._replaceHTML(result, content, options);
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    _updateFrame(options) {
      super._updateFrame(options);
      if ( options.window && ("subtitle" in options.window) ) {
        this.element.querySelector(".window-header > .window-subtitle").innerText = options.window.subtitle;
      }
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    _onRender(context, options) {
      super._onRender(context, options);

      this.element.querySelectorAll("[data-context-menu]").forEach(control =>
        control.addEventListener("click", dnd5e.applications.ContextMenu5e.triggerEvent)
      );

      // Allow multi-select tags to be removed when the whole tag is clicked.
      this.element.querySelectorAll("multi-select").forEach(select => {
        if ( select.disabled ) return;
        select.querySelectorAll(".tag").forEach(tag => {
          tag.classList.add("remove");
          tag.querySelector(":scope > span")?.classList.add("remove");
        });
      });

      // Add special styling for label-top hints.
      this.element.querySelectorAll(".label-top > p.hint").forEach(hint => {
        const label = hint.parentElement.querySelector(":scope > label");
        if ( !label ) return;
        hint.ariaLabel = hint.innerText;
        hint.dataset.tooltip = hint.innerHTML;
        hint.innerHTML = "";
        label.insertAdjacentElement("beforeend", hint);
      });
    }

    /* -------------------------------------------- */

    /**
     * Disable form fields that aren't marked with the `always-interactive` class.
     */
    _disableFields() {
      const selector = `.window-content :is(${[
        "INPUT", "SELECT", "TEXTAREA", "BUTTON", "DND5E-CHECKBOX", "COLOR-PICKER", "DOCUMENT-TAGS",
        "FILE-PICKER", "HUE-SLIDER", "MULTI-SELECT", "PROSE-MIRROR", "RANGE-PICKER", "STRING-TAGS"
      ].join(", ")}):not(.always-interactive)`;
      for ( const element of this.element.querySelectorAll(selector) ) {
        if ( element.closest("prose-mirror[open]") ) continue; // Skip active ProseMirror editors
        if ( element.tagName === "TEXTAREA" ) element.readOnly = true;
        else element.disabled = true;
      }
    }

    /* -------------------------------------------- */
    /*  Event Listeners and Handlers                */
    /* -------------------------------------------- */

    /** @inheritDoc */
    _attachFrameListeners() {
      new ContextMenu5e(this.element, '.header-control[data-action="toggleControls"]', [], {
        eventName: "click", fixed: true, jQuery: false,
        onOpen: () => ui.context.menuItems = Array.from(this._getHeaderControlContextEntries())
      });
      super._attachFrameListeners();
      this.element.addEventListener("plugins", this._onConfigurePlugins.bind(this));
    }

    /* -------------------------------------------- */

    /**
     * Configure plugins for the ProseMirror instance.
     * @param {ProseMirrorPluginsEvent} event
     * @protected
     */
    _onConfigurePlugins(event) {
      event.plugins.highlightDocumentMatches =
        ProseMirror.ProseMirrorHighlightMatchesPlugin.build(ProseMirror.defaultSchema);
    }

    /* -------------------------------------------- */

    /**
     * Handle toggling the collapsed state of collapsible sections.
     * @this {BaseApplication5e}
     * @param {Event} event         Triggering click event.
     * @param {HTMLElement} target  Button that was clicked.
     */
    static #toggleCollapsed(event, target) {
      const collapsible = target.closest(".collapsible");
      if ( !collapsible || event.target.closest(".collapsible-content") ) return;
      collapsible.classList.toggle("collapsed");
      this.#expandedSections.set(
        target.closest("[data-expand-id]")?.dataset.expandId,
        !collapsible.classList.contains("collapsed")
      );
    }
  }
  return BaseApplication5e;
}

const { ApplicationV2 } = foundry.applications.api;

/**
 * Base application from which all system applications should be based.
 */
class Application5e extends ApplicationV2Mixin(ApplicationV2) {}

/**
 * Base application that calendar HUDs should inherit from.
 */
class BaseCalendarHUD extends Application5e {
  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    actions: {
      advance: BaseCalendarHUD.#advanceTime,
      reverse: BaseCalendarHUD.#advanceTime
    },
    id: "calendar-hud",
    window: {
      frame: false,
      positioned: false
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Should the calendar HUD be displayed for the current user?
   * @type {boolean}
   */
  static get shouldDisplay() {
    return (game.settings.get("dnd5e", "calendarConfig")?.enabled || false)
      && (game.settings.get("dnd5e", "calendarPreferences")?.visible || false);
  }

  /* -------------------------------------------- */

  /**
   * Should the calendar HUD be displayed for the current user?
   * @type {boolean}
   */
  get shouldDisplay() {
    return this.constructor.shouldDisplay;
  }

  /* -------------------------------------------- */
  /*  Life-Cycle Handlers                         */
  /* -------------------------------------------- */

  /** @override */
  _canRender(options) {
    return this.shouldDisplay;
  }

  /* -------------------------------------------- */

  /** @override */
  _insertElement(element) {
    const existing = document.getElementById(element.id);
    if ( existing ) existing.replaceWith(element);
    else {
      const location = document.querySelector("#ui-middle #ui-top");
      location.append(this.element);
    }
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle moving between set amounts of time.
   * @this {BaseCalendarHUD}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static #advanceTime(event, target) {
    const { value, unit } = target.dataset;
    game.time.advance({
      [unit]: target.dataset.action.startsWith("reverse") ? -Number(value) : Number(value)
    });
  }

  /* -------------------------------------------- */

  /**
   * Respond to changes in the calendar settings.
   */
  onUpdateSettings() {
    if ( this.shouldDisplay ) this.render({ force: true });
    else if ( this.rendered ) this.close({ animate: false });
  }

  /* -------------------------------------------- */

  /**
   * Respond to changes in the world time.
   * @param {number} worldTime
   * @param {number} deltaTime
   * @param {object} options
   * @param {string} userId
   */
  static onUpdateWorldTime(worldTime, deltaTime, options, userId) {
    if ( this.shouldDisplay ) dnd5e.ui.calendar?.render();
  }
}

/**
 * Application for creating dnd5e dialogs.
 */
class Dialog5e extends Application5e {
  /** @override */
  static DEFAULT_OPTIONS = {
    tag: "dialog",
    templates: [],
    window: {
      contentTag: "form",
      contentClasses: ["standard-form"],
      minimizable: false
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    content: {
      template: "systems/dnd5e/templates/shared/dialog-content.hbs"
    },
    footer: {
      template: "templates/generic/form-footer.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _configureRenderParts(options) {
    const parts = super._configureRenderParts(options);
    if ( parts.content && this.options.templates?.length ) {
      parts.content.templates = [...(parts.content.templates ?? []), ...this.options.templates];
    }
    return parts;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = { ...(await super._preparePartContext(partId, context, options)) };
    if ( partId === "content" ) return this._prepareContentContext(context, options);
    if ( partId === "footer" ) return this._prepareFooterContext(context, options);
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare rendering context for the content section.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {Promise<ApplicationRenderContext>}
   * @protected
   */
  async _prepareContentContext(context, options) {
    context.content = this.options.content ?? "";
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare rendering context for the footer.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {Promise<ApplicationRenderContext>}
   * @protected
   */
  async _prepareFooterContext(context, options) {
    context.buttons = this.options.buttons?.map(button => ({
      ...button, cssClass: button.class
    }));
    return context;
  }
}

const { NumberField: NumberField$T, StringField: StringField$1o } = foundry.data.fields;

/**
 * Dialog that allows setting the current date.
 */
class SetDateDialog extends Dialog5e {
  /** @override */
  static DEFAULT_OPTIONS = {
    buttons: [{
      default: true,
      icon: "fa-regular fa-calendar-check",
      label: "Confirm",
      type: "submit"
    }],
    form: {
      handler: SetDateDialog.#onSubmitForm,
      closeOnSubmit: true
    },
    position: {
      width: 300
    },
    window: {
      title: "DND5E.CALENDAR.Action.SetDate"
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    ...super.PARTS,
    content: {
      template: "systems/dnd5e/templates/apps/set-date-dialog.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @override */
  async _prepareContentContext(context, options) {
    const { year, month, dayOfMonth } = game.time.components;
    context.fields = [
      {
        classes: "label-top",
        field: new NumberField$T({ integer: true }),
        label: game.i18n.localize("DND5E.CALENDAR.Component.Year"),
        name: "year",
        value: year + game.time.calendar.years.yearZero
      },
      {
        classes: "label-top",
        field: new NumberField$T({ required: true, blank: false }),
        label: game.i18n.localize("DND5E.CALENDAR.Component.Month"),
        name: "month",
        options: game.time.calendar.months.values
          .map(({ name }, value) => ({ value, label: game.i18n.localize(name) })),
        value: month
      },
      {
        classes: "label-top",
        field: new NumberField$T(),
        label: game.i18n.localize("DND5E.CALENDAR.Component.Day"),
        name: "day",
        value: dayOfMonth + 1
      }
    ];
    return context;
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle form submission.
   * @this {SetDateDialog}
   * @param {SubmitEvent} event          Triggering submit event.
   * @param {HTMLFormElement} form       The form that was submitted.
   * @param {FormDataExtended} formData  Data from the submitted form.
   */
  static async #onSubmitForm(event, form, formData) {
    game.time.calendar.jumpToDate(formData.object);
  }
}

/**
 * @import { CalendarTimeDeltas } from "../../data/calendar/_types.mjs";
 * @import { CalendarHUDButton } from "./_types.mjs";
 */

/**
 * Application for showing a date and time interface on the screen.
 */
class CalendarHUD extends BaseCalendarHUD {
  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    actions: {
      openCharacterSheet: CalendarHUD.#openCharacterSheet,
      openPartySheet: CalendarHUD.#openPartySheet,
      setDate: CalendarHUD.#setDate
    },
    classes: ["faded-ui", "ui-control"]
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    startButtons: {
      classes: ["calendar-buttons"],
      template: "systems/dnd5e/templates/apps/calendar-buttons.hbs"
    },
    core: {
      classes: ["calendar-core"],
      template: "systems/dnd5e/templates/apps/calendar-core.hbs"
    },
    endButtons: {
      classes: ["calendar-buttons"],
      template: "systems/dnd5e/templates/apps/calendar-buttons.hbs"
    }
  };

  /* -------------------------------------------- */

  /**
   * Default time periods to display for controlling time.
   * @type {{ value: number, unit: string, [default]: boolean }}
   */
  static TIME_CONTROL_VALUES = [
    { value: 7, unit: "day" },
    { value: 1, unit: "day" },
    { value: 8, unit: "hour" },
    { value: 1, unit: "hour", default: true },
    { value: 10, unit: "minute" },
    { value: 1, unit: "minute" }
  ];

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Prepared calendar buttons to display.
   * @type {CalendarHUDButton[]}
   */
  #buttons = [];

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _configureRenderOptions(options) {
    super._configureRenderOptions(options);
    if ( this.rendered ) options.parts = options.parts.filter(p => p !== "core");
  }

  /* -------------------------------------------- */

  /**
   * Build the list of default calendar buttons.
   * @returns {CalendarHUDButton[]}
   * @protected
   */
  _getCalendarButtons() {
    const defaultTime = CalendarHUD.TIME_CONTROL_VALUES.find(v => v.default) ?? { value: 1, unit: "hour" };
    const defaultAmount = formatTime(defaultTime.value, defaultTime.unit).titleCase();
    return [
      {
        action: "reverse",
        dataset: defaultTime,
        icon: "fa-solid fa-angles-left",
        position: "start",
        tooltip: game.i18n.format("DND5E.CALENDAR.Action.ReverseTime", { amount: defaultAmount }),
        visible: game.user.isGM,
        additional: CalendarHUD.TIME_CONTROL_VALUES.map(({ value, unit }) => ({
          action: "reverse",
          dataset: { value, unit },
          label: `-${formatTime(value, unit, { unitDisplay: "narrow" })}`,
          tooltip: game.i18n.format("DND5E.CALENDAR.Action.ReverseTime", {
            amount: formatTime(value, unit).titleCase()
          })
        }))
      },
      {
        action: "setDate",
        icon: "fa-solid fa-calendar-days",
        position: "start",
        tooltip: game.i18n.localize("DND5E.CALENDAR.Action.SetDate"),
        visible: game.user.isGM
      },
      {
        action: "openCharacterSheet",
        icon: "fa-solid fa-user",
        position: "start",
        tooltip: game.i18n.localize("DND5E.CALENDAR.Action.OpenCharacterSheet"),
        visible: !!game.user.character
      },
      {
        action: "advance",
        dataset: defaultTime,
        icon: "fa-solid fa-angles-right",
        position: "end",
        tooltip: game.i18n.format("DND5E.CALENDAR.Action.AdvanceTime", { amount: defaultAmount }),
        visible: game.user.isGM,
        additional: CalendarHUD.TIME_CONTROL_VALUES.map(({ value, unit }) => ({
          action: "advance",
          dataset: { value, unit },
          label: `+${formatTime(value, unit, { unitDisplay: "narrow" })}`,
          tooltip: game.i18n.format("DND5E.CALENDAR.Action.AdvanceTime", {
            amount: formatTime(value, unit).titleCase()
          })
        }))
      },
      {
        action: "openPartySheet",
        icon: "fa-solid fa-users",
        position: "end",
        tooltip: game.i18n.localize("DND5E.CALENDAR.Action.OpenPartySheet"),
        visible: game.actors.party?.testUserPermission(game.user, CONST.DOCUMENT_OWNERSHIP_LEVELS.LIMITED)
      }
    ];
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    this._prepareButtonsContext(context, options);
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare the buttons that can be displayed around the calendar UI.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   */
  async _prepareButtonsContext(context, options) {
    /**
     * A hook event that fires when preparing the buttons displayed around the calendar HUD. Buttons in each list
     * are sorted with those closest to the center first.
     * @function dnd5e.prepareCalendarButtons
     * @memberof hookEvents
     * @param {CalendarHUD} app              The Calendar HUD application being rendered.
     * @param {CalendarHUDButton[]} buttons  Buttons displayed around the calendar UI.
     */
    const controls = this._doEvent(this._getCalendarButtons, {
      async: false,
      debugText: "Calendar Control Buttons",
      hookName: "dnd5e.prepareCalendarButtons",
      hookResponse: true,
      parentClassHooks: false
    });

    const prepareCalendarButton = (data, index, parent) => ({
      ...data, index,
      additional: data.additional ? data.additional.map((a, i) => prepareCalendarButton(a, i, data)) : undefined,
      tooltipDirection: (parent?.position ?? data.position) === "start" ? "LEFT" : "RIGHT"
    });

    this.#buttons = context.buttons = controls
      .filter(b => typeof b.visible === "function" ? b.visible.call(this) : b.visible ?? true)
      .map(prepareCalendarButton);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    switch ( partId ) {
      case "endButtons":
        context.buttons = context.buttons.filter(b => b.position === "end");
        break;
      case "startButtons":
        context.buttons = context.buttons.filter(b => b.position === "start").reverse();
        break;
    }
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Adjust the date, time, and progress in the core part without a full re-render to allow animation.
   * @param {CalendarTimeDeltas} [deltas={}]  Information on the time change deltas.
   */
  async renderCore(deltas={}) {
    const prefs = game.settings.get("dnd5e", "calendarPreferences");
    const dateFormatter = CONFIG.DND5E.calendar.formatters.find(f => f.value === prefs.formatters.date);
    this.element.querySelector(".calendar-date").innerText = dateFormatter ? game.time.calendar.format(
      game.time.components, dateFormatter.formatter
    ) : "";
    const timeFormatter = CONFIG.DND5E.calendar.formatters.find(f => f.value === prefs.formatters.time);
    this.element.querySelector(".calendar-time").innerText = timeFormatter ? game.time.calendar.format(
      game.time.components, timeFormatter.formatter
    ) : "";

    const widget = this.element.querySelector(".calendar-widget");
    const adjustProgress = (variable, type, delta=0) => {
      const currentProgress = Number(widget.style.getPropertyValue(variable));
      const modulo = (n, d) => ((n % d) + d) % d;
      const partialProgress = modulo((currentProgress + 0.5), 2) - 0.5;
      const targetProgress = type in game.time.calendar ? game.time.calendar[type]()
        : CalendarData5e.prototype[type].call(game.time.calendar);
      const difference = targetProgress - partialProgress;
      const newProgress = currentProgress + difference + (delta * 2);
      widget.style.setProperty(variable, newProgress);
    };
    adjustProgress("--calendar-day-progress", "progressDay", deltas.midnights);
    adjustProgress("--calendar-night-progress", "progressNight", deltas.middays);
  }

  /* -------------------------------------------- */
  /*  Life-Cycle Handlers                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    await this.renderCore();
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle opening the player's character sheet.
   * @this {CalendarHUD}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static #openCharacterSheet(event, target) {
    game.user.character?.sheet.render({ force: true });
  }

  /* -------------------------------------------- */

  /**
   * Handle opening the primary party's sheet.
   * @this {CalendarHUD}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static #openPartySheet(event, target) {
    game.actors.party?.sheet.render({ force: true });
  }

  /* -------------------------------------------- */

  /**
   * Handle opening the set date dialog.
   * @this {CalendarHUD}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static #setDate(event, target) {
    if ( !game.user.isGM ) return;
    const dialog = new SetDateDialog();
    dialog.render({ force: true });
  }

  /* -------------------------------------------- */

  /** @override */
  _onClickAction(event, target) {
    if ( !target.parentElement.classList.contains("calendar-button") ) return;
    const topLevelButton = target.closest(".calendar-buttons > .calendar-button").querySelector(":scope > button");
    let config = this.#buttons[topLevelButton.dataset.index];
    if ( topLevelButton !== target ) config = config?.additional?.[target.dataset.index];
    if ( typeof config?.onClick === "function" ) config.onClick(event);
  }

  /* -------------------------------------------- */

  // TODO: Respond to updates to primary party
  // TODO: Respond to updates to player's character

  /** @override */
  static onUpdateWorldTime(worldTime, deltaTime, options, userId) {
    if ( this.shouldDisplay ) dnd5e.ui.calendar?.renderCore(options.dnd5e?.deltas);
  }
}

/**
 * Custom control icon used to display Map Location journal pages when pinned to the map.
 */
class MapLocationControlIcon extends PIXI.Container {
  constructor({code, size=40, ...style}={}, ...args) {
    super(...args);

    this.code = code;
    this.size = size;
    this.style = style;

    this.renderMarker();
    this.refresh();
  }

  /* -------------------------------------------- */

  /**
   * Perform the actual rendering of the marker.
   */
  renderMarker() {
    this.radius = this.size / 2;
    this.circle = [this.radius, this.radius, this.radius + 8];
    this.backgroundColor = this.style.backgroundColor;
    this.borderColor = this.style.borderHoverColor;

    // Define hit area
    this.eventMode = "static";
    this.interactiveChildren = false;
    this.hitArea = new PIXI.Circle(...this.circle);
    this.cursor = "pointer";

    // Drop Shadow
    this.shadow = this.addChild(new PIXI.Graphics());
    this.shadow.clear()
      .beginFill(this.style.shadowColor, 0.65)
      .drawCircle(this.radius + 8, this.radius + 8, this.radius + 10)
      .endFill();
    this.shadow.filters = [new PIXI.filters.BlurFilter(16)];

    // 3D Effect
    this.extrude = this.addChild(new PIXI.Graphics());
    this.extrude.clear()
      .beginFill(this.style.borderColor, 1.0)
      .drawCircle(this.radius + 2, this.radius + 2, this.radius + 9)
      .endFill();

    // Background
    this.bg = this.addChild(new PIXI.Graphics());
    this.bg.clear()
      .beginFill(this.backgroundColor, 1.0)
      .lineStyle(2, this.style.borderColor, 1.0)
      .drawCircle(...this.circle)
      .endFill();

    // Text
    this.text = new foundry.canvas.containers.PreciseText(this.code, this._getTextStyle(this.code.length, this.size));
    this.text.anchor.set(0.5, 0.5);
    this.text.position.set(this.radius, this.radius);
    this.addChild(this.text);

    // Border
    this.border = this.addChild(new PIXI.Graphics());
    this.border.visible = false;
  }

  /* -------------------------------------------- */

  /**
   * Code text to be rendered.
   * @type {string}
   */
  code;

  /* -------------------------------------------- */

  /** @inheritDoc */
  refresh({ visible, iconColor, borderColor, borderVisible }={}) {
    if ( borderColor ) this.borderColor = borderColor;
    this.border.clear().lineStyle(2, this.borderColor, 1.0).drawCircle(...this.circle).endFill();
    if ( borderVisible !== undefined ) this.border.visible = borderVisible;
    if ( visible !== undefined ) this.visible = visible;
    return this;
  }

  /* -------------------------------------------- */

  /**
   * Define PIXI TestStyle object for rendering the map location code.
   * @param {number} characterCount  Number of characters in the code.
   * @param {number} size            Size of the icon in the Scene.
   * @returns {PIXI.TextStyle}
   * @protected
   */
  _getTextStyle(characterCount, size) {
    const style = CONFIG.canvasTextStyle.clone();
    style.dropShadow = false;
    style.fill = Color.from(this.style.textColor);
    style.strokeThickness = 0;
    style.fontFamily = ["Signika"];
    if ( this.style.fontFamily ) style.fontFamily.unshift(this.style.fontFamily);
    style.fontSize = characterCount > 2 ? size * .5 : size * .6;
    return style;
  }
}

const {
  Coin, DiceTerm: DiceTerm$3, Die: Die$1, FunctionTerm: FunctionTerm$1, NumericTerm: NumericTerm$2, OperatorTerm: OperatorTerm$2, ParentheticalTerm: ParentheticalTerm$1, RollTerm: RollTerm$1
} = foundry.dice.terms;

/**
 * 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.
 * @param {boolean} [options.deterministic]         Strip any non-deterministic terms from the result.
 *
 * @returns {string}  The resulting simplified formula.
 */
function simplifyRollFormula(formula, { preserveFlavor=false, deterministic=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$1.FLAVOR_REGEXP, ""));

  if ( deterministic ) {
    // Perform arithmetic simplification to simplify parsing through the terms.
    roll.terms = _simplifyOperatorTerms(roll.terms);

    // Remove non-deterministic terms, their preceding operators, and dependent operators/terms.
    const terms = [];
    let temp = [];
    let multiplicative = false;
    let determ;

    for ( let i = roll.terms.length - 1; i >= 0; ) {
      let paren;
      let term = roll.terms[i];
      if ( term instanceof ParentheticalTerm$1 ) {
        paren = simplifyRollFormula(term.term, { preserveFlavor, deterministic });
      }
      if ( Number.isNumeric(paren) ) {
        const termData = { number: paren };
        if ( preserveFlavor ) termData.options = { flavor: term.flavor };
        term = new NumericTerm$2(termData);
      }
      determ = term.isDeterministic && (!multiplicative || determ);
      if ( determ ) temp.unshift(term);
      else temp = [];
      term = roll.terms[--i];
      while ( term instanceof OperatorTerm$2 ) {
        if ( determ ) temp.unshift(term);
        if ( (term.operator === "*") || (term.operator === "/") || (term.operator === "%") ) multiplicative = true;
        else {
          multiplicative = false;
          while ( temp.length ) terms.unshift(temp.pop());
        }
        term = roll.terms[--i];
      }
    }
    if ( determ ) {
      while ( temp.length ) terms.unshift(temp.pop());
    }
    roll.terms = terms;
  }

  // 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 String(new Roll(roll.formula).evaluateSync().total);
    }
    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$2({ operator: "-" }));

    // Replace double "-" operators with a "+" operator.
    else if ( (ops.has("-")) && (ops.size === 1) ) acc.splice(-1, 1, new OperatorTerm$2({ 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.
    simplified.push(new OperatorTerm$2({ operator: staticBonus < 0 ? "-" : "+" }));
    simplified.push(new NumericTerm$2({ number: Math.abs(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$2 ) return obj;
    const isCoin = curr.constructor?.name === "Coin";
    const face = isCoin ? "c" : curr.faces;
    const modifiers = isCoin ? "" : curr.modifiers.filterJoin("");
    const key = `${unannotated[i - 1].operator}${face}${modifiers}`;
    obj[key] ??= {};
    if ( (curr._number instanceof Roll) && (curr._number.isDeterministic) ) curr._number.evaluateSync();
    obj[key].number = (obj[key].number ?? 0) + curr.number;
    if ( !isCoin ) obj[key].modifiers = (obj[key].modifiers ?? []).concat(curr.modifiers);
    return obj;
  }, {});

  // Add new die and operator terms to simplified for each die size and sign
  const simplified = Object.entries(diceQuantities).flatMap(([key, {number, modifiers}]) => ([
    new OperatorTerm$2({ operator: key.charAt(0) }),
    key.slice(1) === "c"
      ? new Coin({ number: number })
      : new Die$1({ number, faces: parseInt(key.slice(1)), modifiers: [...new Set(modifiers)] })
  ]));
  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$1 ) {
      if ( term.isDeterministic ) {
        const roll = new Roll(term.term);
        term = new NumericTerm$2({ number: roll.evaluateSync().total });
      } 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, FunctionTerms, and NumericTerms.
 * FunctionTerms 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$2) ) terms.unshift(new OperatorTerm$2({ operator: "+" }));

  return terms.reduce((obj, term, i) => {
    let type;
    if ( term instanceof DiceTerm$3 ) type = DiceTerm$3;
    else if ( (term instanceof FunctionTerm$1) && (term.isDeterministic) ) type = NumericTerm$2;
    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$2 ) return obj;
    obj[curr.flavor ? "annotated" : "unannotated"].push(terms[i - 1], curr);
    return obj;
  }, { annotated: [], unannotated: [] });
}

/**
 * Version of embedded data field that properly initializes data models added via active effects.
 * TODO: Remove when we can fully rely on https://github.com/foundryvtt/foundryvtt/issues/12528
 */
class EmbeddedDataField5e extends foundry.data.fields.EmbeddedDataField {
  /** @override */
  _castChangeDelta(delta) {
    if ( delta instanceof this.model ) return delta;
    return this.initialize(this._cast(delta));
  }
}

/**
 * @import { FormulaFieldOptions } from "./_types.mjs";
 */

/**
 * 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) {
    const roll = new Roll(value.replace(/@([a-z.0-9_-]+)/gi, "1"));
    roll.evaluateSync({ strict: false });
    if ( this.options.deterministic && !roll.isDeterministic ) throw new Error(`must not contain dice terms: ${value}`);
    super._validateType(value);
  }

  /* -------------------------------------------- */
  /*  Active Effect Integration                   */
  /* -------------------------------------------- */

  /** @override */
  _castChangeDelta(delta) {
    return this._cast(delta).trim();
  }

  /* -------------------------------------------- */

  /** @override */
  _applyChangeAdd(value, delta, model, change) {
    if ( !value ) return delta;
    const operator = delta.startsWith("-") ? "-" : "+";
    delta = delta.replace(/^[+-]/, "").trim();
    return `${value} ${operator} ${delta}`;
  }

  /* -------------------------------------------- */

  /** @override */
  _applyChangeMultiply(value, delta, model, change) {
    if ( !value ) return value;
    const terms = new Roll(value).terms;
    if ( terms.length > 1 ) return `(${value}) * ${delta}`;
    return `${value} * ${delta}`;
  }

  /* -------------------------------------------- */

  /** @override */
  _applyChangeUpgrade(value, delta, model, change) {
    if ( !value ) return delta;
    const terms = new Roll(value).terms;
    if ( (terms.length === 1) && (terms[0].fn === "max") ) return value.replace(/\)$/, `, ${delta})`);
    return `max(${value}, ${delta})`;
  }

  /* -------------------------------------------- */

  /** @override */
  _applyChangeDowngrade(value, delta, model, change) {
    if ( !value ) return delta;
    const terms = new Roll(value).terms;
    if ( (terms.length === 1) && (terms[0].fn === "min") ) return value.replace(/\)$/, `, ${delta})`);
    return `min(${value}, ${delta})`;
  }
}

const { ArrayField: ArrayField$r, SchemaField: SchemaField$13, StringField: StringField$1n } = foundry.data.fields;

/**
 * @import { ConsumptionLabels } from "../../../_types.mjs";
 * @import { ActivityUsageUpdates, ActivityUseConfiguration } from "../../../documents/activity/_types.mjs";
 * @import { UsesData } from "../../shared/_types.mjs";
 */

/**
 * Field for holding one or more consumption targets.
 */
class ConsumptionTargetsField extends ArrayField$r {
  constructor(options={}) {
    super(new EmbeddedDataField5e(ConsumptionTargetData), options);
  }
}

/**
 * Embedded data model for storing consumption target data and handling consumption.
 */
class ConsumptionTargetData extends foundry.abstract.DataModel {
  /** @override */
  static defineSchema() {
    return {
      type: new StringField$1n({ required: true, blank: false, initial: "activityUses" }),
      target: new StringField$1n(),
      value: new FormulaField({ initial: "1" }),
      scaling: new SchemaField$13({
        mode: new StringField$1n(),
        formula: new FormulaField()
      })
    };
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Activity to which this consumption target belongs.
   * @type {Activity}
   */
  get activity() {
    return this.parent;
  }

  /* -------------------------------------------- */

  /**
   * Actor containing this consumption target, if embedded.
   * @type {Actor5e}
   */
  get actor() {
    return this.activity.actor;
  }

  /* -------------------------------------------- */

  /**
   * Should this consumption only be performed during initiative? This will return `true` if consuming activity or item
   * uses and those uses only recover on "combat" periods.
   * @type {boolean}
   */
  get combatOnly() {
    let recovery;
    switch ( this.type ) {
      case "activityUses":
        recovery = this.activity.uses.recovery;
        break;
      case "itemUses":
        recovery = (this.target ? this.actor?.items.get(this.target) : this.item)?.system.uses.recovery;
        break;
      default: return false;
    }
    if ( !recovery?.length ) return false;
    return recovery.every(r => CONFIG.DND5E.limitedUsePeriods[r.period]?.type === "combat");
  }

  /* -------------------------------------------- */

  /**
   * Item to which this consumption target's activity belongs.
   * @type {Item5e}
   */
  get item() {
    return this.activity.item;
  }

  /* -------------------------------------------- */

  /**
   * List of valid targets within the current context.
   * @type {FormSelectOption[]|null}
   */
  get validTargets() {
    const config = CONFIG.DND5E.activityConsumptionTypes[this.type];
    if ( !config?.validTargets || (!this.item.isEmbedded && (config.targetRequiresEmbedded === true)) ) return null;
    return config.validTargets.call(this);
  }

  /* -------------------------------------------- */
  /*  Consumption                                 */
  /* -------------------------------------------- */

  /**
   * Perform consumption according to the target type.
   * @param {ActivityUseConfiguration} config  Configuration data for the activity usage.
   * @param {ActivityUsageUpdates} updates     Updates to be performed.
   * @throws ConsumptionError
   */
  async consume(config, updates) {
    const typeConfig = CONFIG.DND5E.activityConsumptionTypes[this.type];
    if ( !typeConfig?.consume ) throw new Error("Consumption types must define consumption method.");
    await typeConfig.consume.call(this, config, updates);
  }

  /* -------------------------------------------- */

  /**
   * Prepare consumption updates for "Activity Uses" consumption type.
   * @this {ConsumptionTargetData}
   * @param {ActivityUseConfiguration} config  Configuration data for the activity usage.
   * @param {ActivityUsageUpdates} updates     Updates to be performed.
   * @throws ConsumptionError
   */
  static async consumeActivityUses(config, updates) {
    const result = await this._usesConsumption(config, {
      uses: this.activity.uses,
      type: game.i18n.format("DND5E.CONSUMPTION.Type.ActivityUses.Warning", {
        activity: this.activity.name, item: this.item.name
      }),
      rolls: updates.rolls,
      delta: { item: this.item.id, keyPath: `system.activities.${this.activity.id}.uses.spent` }
    });
    if ( result ) foundry.utils.mergeObject(updates.activity, { "uses.spent": result.spent });
  }

  /* -------------------------------------------- */

  /**
   * Prepare consumption updates for "Attribute" consumption type.
   * @this {ConsumptionTargetData}
   * @param {ActivityUseConfiguration} config  Configuration data for the activity usage.
   * @param {ActivityUsageUpdates} updates     Updates to be performed.
   * @throws ConsumptionError
   */
  static async consumeAttribute(config, updates) {
    const keyPath = `system.${this.target}`;
    const cost = (await this.resolveCost({ config, delta: { keyPath }, rolls: updates.rolls })).total;

    if ( !foundry.utils.hasProperty(this.actor, keyPath) ) throw new ConsumptionError(
      game.i18n.format("DND5E.CONSUMPTION.Warning.MissingAttribute", {
        activity: this.activity.name, attribute: this.target, item: this.item.name
      })
    );
    let current = foundry.utils.getProperty(this.actor, keyPath);

    let warningMessage;
    if ( (cost > 0) && !current ) warningMessage = "DND5E.CONSUMPTION.Warning.None";
    else if ( current < cost ) warningMessage = "DND5E.CONSUMPTION.Warning.NotEnough";
    if ( warningMessage ) throw new ConsumptionError(game.i18n.format(warningMessage, {
      available: formatNumber(current), cost: formatNumber(cost),
      type: game.i18n.format("DND5E.CONSUMPTION.Type.Attribute.Warning", { attribute: this.target })
    }));

    const adjustedKeyPath = keyPath.replace(/\.value$/, ".spent");
    const isSpent = (keyPath !== adjustedKeyPath) && !foundry.utils.hasProperty(this.actor._source, keyPath)
      && foundry.utils.hasProperty(this.actor._source, adjustedKeyPath);
    if ( isSpent ) {
      current = foundry.utils.getProperty(this.actor, adjustedKeyPath);
      updates.actor[adjustedKeyPath] = current + cost;
    } else updates.actor[keyPath] = current - cost;
  }

  /* -------------------------------------------- */

  /**
   * Prepare consumption updates for "Hit Dice" consumption type.
   * @this {ConsumptionTargetData}
   * @param {ActivityUseConfiguration} config  Configuration data for the activity usage.
   * @param {ActivityUsageUpdates} updates     Updates to be performed.
   * @throws ConsumptionError
   */
  static async consumeHitDice(config, updates) {
    const cost = (await this.resolveCost({ config, rolls: updates.rolls })).total;

    const denom = !["smallest", "largest"].includes(this.target) ? this.target : false;
    const validClasses = Object.values(this.actor.classes).filter(cls => {
      return !denom || (cls.system.hd.denomination === denom);
    });
    const total = validClasses.reduce((count, cls) => count + cls.system.hd.value, 0);

    if ( !denom ) validClasses.sort((lhs, rhs) => {
      const sort = lhs.system.hd.denomination.localeCompare(rhs.system.hd.denomination, "en", { numeric: true });
      return (this.target === "smallest") ? sort : sort * -1;
    });

    let warningMessage;
    if ( !validClasses.length ) warningMessage = "DND5E.CONSUMPTION.Warning.MissingHitDice";
    else if ( (cost > 0) && !total ) warningMessage = "DND5E.CONSUMPTION.Warning.None";
    else if ( total < cost ) warningMessage = "DND5E.CONSUMPTION.Warning.NotEnough";
    if ( warningMessage ) {
      const denomination = !["smallest", "largest"].includes(this.target) ? this.target : "";
      throw new ConsumptionError(game.i18n.format(warningMessage, {
        available: formatNumber(total), cost: formatNumber(cost), denomination,
        type: game.i18n.format("DND5E.CONSUMPTION.Type.HitDice.Warning", { denomination })
      }));
    }

    let toConsume = cost;
    for ( const cls of validClasses ) {
      const available = toConsume > 0 ? cls.system.hd.value : toConsume < 0 ? -cls.system.hd.spent : 0;
      const delta = toConsume > 0 ? Math.min(toConsume, available) : Math.max(toConsume, available);
      const itemUpdate = { "system.hd.spent": cls.system.hd.spent + delta };
      if ( delta !== 0 ) {
        const itemIndex = updates.item.findIndex(i => i._id === cls.id);
        if ( itemIndex === -1 ) updates.item.push({ _id: cls.id, ...itemUpdate });
        else foundry.utils.mergeObject(updates.item[itemIndex], itemUpdate);
        toConsume -= delta;
        if ( toConsume === 0 ) break;
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Prepare consumption updates for "Item Uses" consumption type.
   * @this {ConsumptionTargetData}
   * @param {ActivityUseConfiguration} config  Configuration data for the activity usage.
   * @param {ActivityUsageUpdates} updates     Updates to be performed.
   * @throws ConsumptionError
   */
  static async consumeItemUses(config, updates) {
    const item = this.target ? this.actor.items.get(this.target) : this.item;
    if ( !item ) throw new ConsumptionError(game.i18n.format("DND5E.CONSUMPTION.Warning.MissingItem", {
      activity: this.activity.name, item: this.item.name
    }));

    const result = await this._usesConsumption(config, {
      uses: item.system.uses,
      type: game.i18n.format("DND5E.CONSUMPTION.Type.ItemUses.Warning", { name: this.item.name }),
      rolls: updates.rolls,
      delta: { item: item.id, keyPath: "system.uses.spent" }
    });
    if ( !result ) return;

    const itemUpdate = {};
    if ( item.system.uses.autoDestroy && (result.spent === item.system.uses.max) ) {
      const newQuantity = item.system.quantity - 1;
      if ( newQuantity === 0 ) {
        updates.delete.push(item.id);
        return;
      } else {
        itemUpdate["system.uses.spent"] = 0;
        itemUpdate["system.quantity"] = newQuantity;
      }
    } else {
      itemUpdate["system.uses.spent"] = result.spent;
    }

    const itemIndex = updates.item.findIndex(i => i._id === item.id);
    if ( itemIndex === -1 ) updates.item.push({ _id: item.id, ...itemUpdate });
    else foundry.utils.mergeObject(updates.item[itemIndex], itemUpdate);
  }

  /* -------------------------------------------- */

  /**
   * Prepare consumption updates for "Material" consumption type.
   * @this {ConsumptionTargetData}
   * @param {ActivityUseConfiguration} config  Configuration data for the activity usage.
   * @param {ActivityUsageUpdates} updates     Updates to be performed.
   * @throws ConsumptionError
   */
  static async consumeMaterial(config, updates) {
    const item = this.actor.items.get(this.target);
    if ( !item ) throw new ConsumptionError(game.i18n.format("DND5E.CONSUMPTION.Warning.MissingItem", {
      activity: this.activity.name, item: this.item.name
    }));

    const delta = { item: item.id, keyPath: "system.quantity" };
    const cost = (await this.resolveCost({ config, delta, rolls: updates.rolls })).total;

    let warningMessage;
    if ( cost > 0 && !item.system.quantity ) warningMessage = "DND5E.CONSUMPTION.Warning.None";
    else if ( cost > item.system.quantity ) warningMessage = "DND5E.CONSUMPTION.Warning.NotEnough";
    if ( warningMessage ) throw new ConsumptionError(game.i18n.format(warningMessage, {
      available: formatNumber(item.system.quantity), cost: formatNumber(cost),
      type: game.i18n.format("DND5E.CONSUMPTION.Type.Material.Warning", { name: item.name })
    }));

    const newQuantity = item.system.quantity - cost;
    if ( (newQuantity === 0) && item.system.uses?.autoDestroy ) {
      updates.delete.push(item.id);
    } else {
      const itemUpdate = { "system.quantity": newQuantity };
      const itemIndex = updates.item.findIndex(i => i._id === item.id);
      if ( itemIndex === -1 ) updates.item.push({ _id: item.id, ...itemUpdate });
      else foundry.utils.mergeObject(updates.item[itemIndex], itemUpdate);
    }
  }

  /* -------------------------------------------- */

  /**
   * Prepare consumption updates for "Spell Slots" consumption type.
   * @this {ConsumptionTargetData}
   * @param {ActivityUseConfiguration} config  Configuration data for the activity usage.
   * @param {ActivityUsageUpdates} updates     Updates to be performed.
   * @throws ConsumptionError
   */
  static async consumeSpellSlots(config, updates) {
    const levelNumber = Math.clamp(
      this.resolveLevel({ config, rolls: updates.rolls }), 1, Object.keys(CONFIG.DND5E.spellLevels).length - 1
    );
    const keyPath = `system.spells.spell${levelNumber}.value`;
    const cost = (await this.resolveCost({ config, delta: { keyPath }, rolls: updates.rolls })).total;

    // Check to see if enough slots are available at the specified level
    const levelData = this.actor.system.spells?.[`spell${levelNumber}`];
    const newValue = (levelData?.value ?? 0) - cost;
    let warningMessage;
    if ( !levelData?.max ) warningMessage = "DND5E.CONSUMPTION.Warning.MissingSpellSlot";
    else if ( (cost > 0) && !levelData.value ) warningMessage = "DND5E.CONSUMPTION.Warning.None";
    else if ( newValue < 0 ) warningMessage = "DND5E.CONSUMPTION.Warning.NotEnough";
    if ( warningMessage ) {
      const level = CONFIG.DND5E.spellLevels[levelNumber];
      const type = game.i18n.format("DND5E.CONSUMPTION.Type.SpellSlots.Warning", { level });
      throw new ConsumptionError(game.i18n.format(warningMessage, {
        type, level, cost: formatNumber(cost), available: formatNumber(levelData.value)
      }));
    }

    updates.actor[keyPath] = Math.max(0, newValue);
  }

  /* -------------------------------------------- */

  /**
   * Calculate updates to activity or item uses.
   * @param {ActivityUseConfiguration} config  Configuration data for the activity usage.
   * @param {object} options
   * @param {UsesData} options.uses            Uses data to consume.
   * @param {string} options.type              Type label to be used in warning messages.
   * @param {BasicRoll[]} options.rolls        Rolls performed as part of the usages.
   * @param {object} [options.delta]           Delta information stored in roll options.
   * @returns {{ spent: number, quantity: number }|null}
   * @internal
   */
  async _usesConsumption(config, { uses, type, rolls, delta }) {
    const cost = (await this.resolveCost({ config, delta, rolls })).total;

    let warningMessage;
    if ( cost > 0 && !uses.value ) warningMessage = "DND5E.CONSUMPTION.Warning.None";
    else if ( cost > uses.value ) warningMessage = "DND5E.CONSUMPTION.Warning.NotEnough";
    if ( warningMessage ) throw new ConsumptionError(
      game.i18n.format(warningMessage, { type, cost: formatNumber(cost), available: formatNumber(uses.value) })
    );

    return { spent: uses.spent + cost };
  }

  /* -------------------------------------------- */
  /*  Consumption Hints                           */
  /* -------------------------------------------- */

  /**
   * Create label and hint text indicating how much of this resource will be consumed/recovered.
   * @param {ActivityUseConfiguration} config  Configuration data for the activity usage.
   * @param {object} [options={}]
   * @param {boolean} [options.consumed]       Is this consumption currently set to be consumed?
   * @returns {ConsumptionLabels}
   */
  getConsumptionLabels(config, options={}) {
    const typeConfig = CONFIG.DND5E.activityConsumptionTypes[this.type];
    if ( !typeConfig?.consumptionLabels ) return "";
    return typeConfig.consumptionLabels.call(this, config, options);
  }

  /* -------------------------------------------- */

  /**
   * Create hint text indicating how much of this resource will be consumed/recovered.
   * @this {ConsumptionTargetData}
   * @param {ActivityUseConfiguration} config  Configuration data for the activity usage.
   * @param {object} [options={}]
   * @param {boolean} [options.consumed]       Is this consumption currently set to be consumed?
   * @returns {ConsumptionLabels}
   */
  static consumptionLabelsActivityUses(config, { consumed }={}) {
    const { cost, simplifiedCost, increaseKey, pluralRule } = this._resolveHintCost(config);
    const uses = this.activity.uses;
    const usesPluralRule = new Intl.PluralRules(game.i18n.lang).select(uses.value);
    return {
      label: game.i18n.localize(`DND5E.CONSUMPTION.Type.ActivityUses.Prompt${increaseKey}`),
      hint: game.i18n.format(
        `DND5E.CONSUMPTION.Type.ActivityUses.PromptHint${increaseKey}`,
        {
          cost,
          use: game.i18n.localize(`DND5E.CONSUMPTION.Type.Use.${pluralRule}`),
          available: formatNumber(uses.value),
          availableUse: game.i18n.localize(`DND5E.CONSUMPTION.Type.Use.${usesPluralRule}`)
        }
      ),
      warn: simplifiedCost > uses.value
    };
  }

  /* -------------------------------------------- */

  /**
   * Create hint text indicating how much of this resource will be consumed/recovered.
   * @this {ConsumptionTargetData}
   * @param {ActivityUseConfiguration} config  Configuration data for the activity usage.
   * @param {object} [options={}]
   * @param {boolean} [options.consumed]       Is this consumption currently set to be consumed?
   * @returns {ConsumptionLabels}
   */
  static consumptionLabelsAttribute(config, { consumed }={}) {
    const { cost, simplifiedCost, increaseKey } = this._resolveHintCost(config);
    const current = foundry.utils.getProperty(this.actor.system, this.target);
    return {
      label: game.i18n.localize(`DND5E.CONSUMPTION.Type.Attribute.Prompt${increaseKey}`),
      hint: game.i18n.format(
        `DND5E.CONSUMPTION.Type.Attribute.PromptHint${increaseKey}`,
        {
          cost,
          attribute: getHumanReadableAttributeLabel(this.target, { actor: this.actor }),
          current: formatNumber(current)
        }
      ),
      warn: simplifiedCost > current
    };
  }

  /* -------------------------------------------- */

  /**
   * Create hint text indicating how much of this resource will be consumed/recovered.
   * @this {ConsumptionTargetData}
   * @param {ActivityUseConfiguration} config  Configuration data for the activity usage.
   * @param {object} [options={}]
   * @param {boolean} [options.consumed]       Is this consumption currently set to be consumed?
   * @returns {ConsumptionLabels}
   */
  static consumptionLabelsHitDice(config, { consumed }={}) {
    const { cost, simplifiedCost, increaseKey, pluralRule } = this._resolveHintCost(config);
    let denomination;
    if ( this.target === "smallest" ) denomination = game.i18n.localize("DND5E.ConsumeHitDiceSmallest");
    else if ( this.target === "largest" ) denomination = game.i18n.localize("DND5E.ConsumeHitDiceLargest");
    else denomination = this.target;
    const available = (["smallest", "largest"].includes(this.target)
      ? this.actor.system.attributes?.hd?.value : this.actor.system.attributes?.hd?.bySize?.[this.target]) ?? 0;
    return {
      label: game.i18n.localize(`DND5E.CONSUMPTION.Type.HitDice.Prompt${increaseKey}`),
      hint: game.i18n.format(
        `DND5E.CONSUMPTION.Type.HitDice.PromptHint${increaseKey}`,
        {
          cost, denomination: denomination.toLowerCase(),
          die: game.i18n.localize(`DND5E.CONSUMPTION.Type.HitDie.${pluralRule}`),
          available: formatNumber(available)
        }
      ),
      warn: simplifiedCost > available
    };
  }

  /* -------------------------------------------- */

  /**
   * Create hint text indicating how much of this resource will be consumed/recovered.
   * @this {ConsumptionTargetData}
   * @param {ActivityUseConfiguration} config  Configuration data for the activity usage.
   * @param {object} [options={}]
   * @param {boolean} [options.consumed]       Is this consumption currently set to be consumed?
   * @returns {ConsumptionLabels}
   */
  static consumptionLabelsItemUses(config, { consumed }={}) {
    const { cost, simplifiedCost, increaseKey, pluralRule } = this._resolveHintCost(config);
    const item = this.actor.items.get(this.target);
    const itemName = item ? item.name : game.i18n.localize("DND5E.CONSUMPTION.Target.ThisItem").toLowerCase();
    const uses = (item ?? this.item).system.uses;
    const usesPluralRule = new Intl.PluralRules(game.i18n.lang).select(uses.value);

    const notes = [];
    let warn = false;
    if ( simplifiedCost > uses.value ) warn = true;
    else if ( (simplifiedCost > 0) && (uses.value - simplifiedCost === 0) && uses.autoDestroy ) notes.push({
      type: "warn",
      message: game.i18n.format("DND5E.CONSUMPTION.Warning.WillDestroy", { item: itemName })
    });

    return {
      label: game.i18n.localize(`DND5E.CONSUMPTION.Type.ItemUses.Prompt${increaseKey}`),
      hint: game.i18n.format(
        `DND5E.CONSUMPTION.Type.ItemUses.PromptHint${increaseKey}`,
        {
          cost,
          use: game.i18n.localize(`DND5E.CONSUMPTION.Type.Use.${pluralRule}`),
          available: formatNumber(uses.value),
          availableUse: game.i18n.localize(`DND5E.CONSUMPTION.Type.Use.${usesPluralRule}`),
          item: item ? `<em>${itemName}</em>` : itemName
        }
      ),
      notes: consumed ? notes : null,
      warn
    };
  }

  /* -------------------------------------------- */

  /**
   * Create hint text indicating how much of this resource will be consumed/recovered.
   * @this {ConsumptionTargetData}
   * @param {ActivityUseConfiguration} config  Configuration data for the activity usage.
   * @param {object} [options={}]
   * @param {boolean} [options.consumed]       Is this consumption currently set to be consumed?
   * @returns {ConsumptionLabels}
   */
  static consumptionLabelsMaterial(config, { consumed }={}) {
    const { cost, simplifiedCost, increaseKey } = this._resolveHintCost(config);
    const item = this.actor.items.get(this.target);
    const quantity = item?.system.quantity ?? 0;
    return {
      label: game.i18n.localize(`DND5E.CONSUMPTION.Type.Material.Prompt${increaseKey}`),
      hint: game.i18n.format(
        `DND5E.CONSUMPTION.Type.Material.PromptHint${increaseKey}`,
        {
          cost,
          item: item ? `<em>${item.name}</em>`
            : game.i18n.localize("DND5E.CONSUMPTION.Target.UnknownItem").toLowerCase(),
          quantity: formatNumber(quantity)
        }
      ),
      warn: !item || (simplifiedCost > quantity)
    };
  }

  /* -------------------------------------------- */

  /**
   * Create hint text indicating how much of this resource will be consumed/recovered.
   * @this {ConsumptionTargetData}
   * @param {ActivityUseConfiguration} config  Configuration data for the activity usage.
   * @param {object} [options={}]
   * @param {boolean} [options.consumed]       Is this consumption currently set to be consumed?
   * @returns {ConsumptionLabels}
   */
  static consumptionLabelsSpellSlots(config, { consumed }={}) {
    const { cost, simplifiedCost, increaseKey, pluralRule } = this._resolveHintCost(config);
    const levelNumber = Math.clamp(this.resolveLevel({ config }), 1, Object.keys(CONFIG.DND5E.spellLevels).length - 1);
    const level = CONFIG.DND5E.spellLevels[levelNumber].toLowerCase();
    const available = this.actor.system.spells?.[`spell${levelNumber}`]?.value ?? 0;
    return {
      label: game.i18n.localize(`DND5E.CONSUMPTION.Type.SpellSlots.Prompt${increaseKey}`),
      hint: game.i18n.format(
        `DND5E.CONSUMPTION.Type.SpellSlots.PromptHint${increaseKey}`,
        {
          cost,
          slot: game.i18n.format(`DND5E.CONSUMPTION.Type.SpellSlot.${pluralRule}`, { level }),
          available: formatNumber(available)
        }
      ),
      warn: simplifiedCost > available
    };
  }

  /* -------------------------------------------- */

  /**
   * Resolve the cost for the consumption hint.
   * @param {ActivityUseConfiguration} config  Configuration data for the activity usage.
   * @returns {{ cost: string, simplifiedCost: number, increaseKey: string, pluralRule: string }}
   * @internal
   */
  _resolveHintCost(config) {
    const costRoll = this.resolveCost({ config, evaluate: false });
    let cost = costRoll.isDeterministic
      ? String(costRoll.evaluateSync().total)
      : simplifyRollFormula(costRoll.formula).trim();
    const simplifiedCost = simplifyBonus(cost);
    const isNegative = cost.startsWith("-");
    if ( isNegative ) {
      if ( costRoll.isDeterministic ) cost = cost.replace("-", "");
      else cost = simplifyRollFormula(costRoll.invert().formula);
    }
    let pluralRule;
    if ( costRoll.isDeterministic ) pluralRule = new Intl.PluralRules(game.i18n.lang).select(Number(cost));
    else pluralRule = "other";
    return { cost, simplifiedCost, increaseKey: isNegative ? "Increase" : "Decrease", pluralRule };
  }

  /* -------------------------------------------- */
  /*  Valid Targets                               */
  /* -------------------------------------------- */

  /**
   * Generate a list of targets for the "Attribute" consumption type.
   * @this {ConsumptionTargetData}
   * @returns {FormSelectOption[]}
   */
  static validAttributeTargets() {
    if ( !this.actor ) return [];
    return TokenDocument.implementation.getConsumedAttributes(this.actor.type).map(attr => {
      let group;
      if ( attr.startsWith("abilities.") ) group = game.i18n.localize("DND5E.AbilityScorePl");
      else if ( attr.startsWith("currency.") ) group = game.i18n.localize("DND5E.Currency");
      else if ( attr.startsWith("spells.") ) group = game.i18n.localize("DND5E.CONSUMPTION.Type.SpellSlots.Label");
      else if ( attr.startsWith("attributes.movement.") ) group = game.i18n.localize("DND5E.Speed");
      else if ( attr.startsWith("attributes.senses.") ) group = game.i18n.localize("DND5E.Senses");
      else if ( attr.startsWith("attributes.actions.") ) group = game.i18n.localize("DND5E.Vehicle");
      else if ( attr.startsWith("resources.") ) group = game.i18n.localize("DND5E.Resources");
      return { group, value: attr, label: getHumanReadableAttributeLabel(attr, { actor: this.actor }) || attr };
    });
  }

  /* -------------------------------------------- */

  /**
   * Generate a list of targets for the "Hit Dice" consumption type.
   * @this {ConsumptionTargetData}
   * @returns {FormSelectOption[]}
   */
  static validHitDiceTargets() {
    return [
      { value: "smallest", label: game.i18n.localize("DND5E.ConsumeHitDiceSmallest") },
      ...CONFIG.DND5E.hitDieTypes.map(d => ({ value: d, label: d })),
      { value: "largest", label: game.i18n.localize("DND5E.ConsumeHitDiceLargest") }
    ];
  }

  /* -------------------------------------------- */

  /**
   * Generate a list of targets for the "Item Uses" consumption type.
   * @this {ConsumptionTargetData}
   * @returns {FormSelectOption[]}
   */
  static validItemUsesTargets() {
    const makeLabel = (name, item) => {
      let label;
      const uses = item.system.uses;
      if ( uses.max && (uses.recovery?.length === 1) && (uses.recovery[0].type === "recoverAll")
        && (uses.recovery[0].period !== "recharge") ) {
        const per = CONFIG.DND5E.limitedUsePeriods[uses.recovery[0].period]?.abbreviation;
        label = game.i18n.format("DND5E.AbilityUseConsumableLabel", { max: uses.max, per });
      }
      else label = game.i18n.format("DND5E.AbilityUseChargesLabel", { value: uses.value });
      return `${name} (${label})`;
    };
    return [
      { value: "", label: makeLabel(game.i18n.localize("DND5E.CONSUMPTION.Target.ThisItem"), this.item) },
      { rule: true },
      ...(this.actor?.items ?? [])
        .filter(i => i.system.uses?.max && (i !== this.item))
        .map(i => ({ value: i.id, label: makeLabel(i.name, i) }))
    ];
  }

  /* -------------------------------------------- */

  /**
   * Generate a list of targets for the "Material" consumption type.
   * @this {ConsumptionTargetData}
   * @returns {FormSelectOption[]}
   */
  static validMaterialTargets() {
    return (this.actor?.items ?? [])
      .filter(i => ["consumable", "loot"].includes(i.type))
      .map(i => ({ value: i.id, label: `${i.name} (${formatNumber(i.system.quantity)})` }));
  }

  /* -------------------------------------------- */

  /**
   * Generate a list of targets for the "Spell Slots" consumption type.
   * @this {ConsumptionTargetData}
   * @returns {FormSelectOption[]}
   */
  static validSpellSlotsTargets() {
    return Object.entries(CONFIG.DND5E.spellLevels).reduce((arr, [value, label]) => {
      if ( value !== "0" ) arr.push({ value, label });
      return arr;
    }, []);
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Resolve the amount to consume, taking scaling into account.
   * @param {object} [options={}]
   * @param {ActivityUseConfiguration} [options.config]  Usage configuration.
   * @param {boolean} [options.evaluate=true]            Should the cost roll be evaluated?
   * @param {BasicRoll[]} [options.rolls]                Rolls performed as part of the usages.
   * @returns {Promise<BasicRoll>|BasicRoll}             Returns Promise if evaluate is `true`.
   */
  resolveCost({ config={}, ...options }={}) {
    return this._resolveScaledRoll(this.value, this.scaling.mode === "amount" ? config.scaling ?? 0 : 0, options);
  }

  /* -------------------------------------------- */

  /**
   * Resolve the spell level to consume, taking scaling into account.
   * @param {object} [options={}]
   * @param {ActivityUseConfiguration} [options.config]  Usage configuration.
   * @param {BasicRoll[]} [options.rolls]                Rolls performed as part of the usages.
   * @returns {number}
   */
  resolveLevel({ config={}, ...options }={}) {
    const roll = this._resolveScaledRoll(
      this.target, this.scaling.mode === "level" ? config.scaling ?? 0 : 0, { ...options, evaluate: false }
    );
    roll.evaluateSync();
    return roll.total;
  }

  /* -------------------------------------------- */

  /**
   * Resolve a scaling consumption value formula.
   * @param {string} formula                   Formula for the initial value.
   * @param {number} scaling                   Amount to scale the formula.
   * @param {object} [options={}]
   * @param {object} [options.delta]           Delta information stored in roll options.
   * @param {boolean} [options.evaluate=true]  Should the slot roll be evaluated?
   * @param {BasicRoll[]} [options.rolls]      Rolls performed as part of the usages.
   * @returns {Promise<BasicRoll>|BasicRoll}
   * @internal
   */
  _resolveScaledRoll(formula, scaling, { delta, evaluate=true, rolls }={}) {
    const rollData = this.activity.getRollData();
    const roll = new CONFIG.Dice.BasicRoll(formula ? `0 + ${formula}` : "0", rollData, { delta });

    if ( scaling ) {
      // If a scaling formula is provided, multiply it and add to the end of the initial formula
      if ( this.scaling.formula ) {
        const scalingRoll = new Roll(this.scaling.formula, rollData);
        scalingRoll.alter(scaling, undefined, { multiplyNumeric: true });
        roll.terms.push(new foundry.dice.terms.OperatorTerm({ operator: "+" }), ...scalingRoll.terms);
      }

      // Otherwise increase the number of dice and the numeric term for each scaling step
      else roll.terms = roll.terms.map(term => {
        if ( term instanceof foundry.dice.terms.DiceTerm ) return term.alter(undefined, scaling);
        else if ( term instanceof foundry.dice.terms.NumericTerm ) {
          term.number += term.number > 0 ? scaling : term.number < 0 ? -scaling : 0;
        }
        return term;
      });

      roll.resetFormula();
    }

    if ( evaluate ) return roll.evaluate().then(roll => {
      if ( rolls && !roll.isDeterministic ) rolls.push(roll);
      return roll;
    });
    if ( rolls && !roll.isDeterministic ) rolls.push(roll);
    return roll;
  }
}

/**
 * Error to throw when consumption cannot be achieved.
 */
class ConsumptionError extends Error {
  constructor(...args) {
    super(...args);
    this.name = "ConsumptionError";
  }
}

/**
 * Extension of the core calendar with support for extra formatters.
 */
class CalendarGreyhawk extends CalendarData5e {

  /* -------------------------------------------- */
  /*  Formatter Functions                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static formatMonthDay(calendar, components, options) {
    return CalendarGreyhawk.formatLocalized(
      "DND5E.CALENDAR.Greyhawk.Formatters.MonthDay", calendar, components, options
    );
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static formatMonthDayYear(calendar, components, options) {
    return CalendarGreyhawk.formatLocalized(
      "DND5E.CALENDAR.Greyhawk.Formatters.MonthDayYear", calendar, components, options
    );
  }
}

/* -------------------------------------------- */

const CALENDAR_OF_GREYHAWK = {
  name: "Calendar of Greyhawk",
  years: {
    yearZero: 576,
    firstWeekday: 0
  },
  months: {
    values: [
      {
        name: "DND5E.CALENDAR.Greyhawk.Festival.Needfest",
        ordinal: 1, days: 6 // Days 0–5
      },
      {
        name: "DND5E.CALENDAR.Greyhawk.Month.Fireseek",
        ordinal: 1, days: 28 // Days 5–33
      },
      {
        name: "DND5E.CALENDAR.Greyhawk.Month.Readying",
        ordinal: 2, days: 28 // Days 33–61
      },
      {
        name: "DND5E.CALENDAR.Greyhawk.Month.Coldeven",
        ordinal: 3, days: 28 // Days 61–89
      },
      {
        name: "DND5E.CALENDAR.Greyhawk.Festival.Growfest",
        ordinal: 4, days: 6 // Days 89–95
      },
      {
        name: "DND5E.CALENDAR.Greyhawk.Month.Planting",
        ordinal: 4, days: 28 // Days 95–123
      },
      {
        name: "DND5E.CALENDAR.Greyhawk.Month.Flocktime",
        ordinal: 5, days: 28 // Days 123–151
      },
      {
        name: "DND5E.CALENDAR.Greyhawk.Month.Wealsun",
        ordinal: 6, days: 28 // Days 151–179
      },
      {
        name: "DND5E.CALENDAR.Greyhawk.Festival.Richfest",
        ordinal: 7, days: 6 // Days 179–185
      },
      {
        name: "DND5E.CALENDAR.Greyhawk.Month.Reaping",
        ordinal: 7, days: 28 // Days 185–213
      },
      {
        name: "DND5E.CALENDAR.Greyhawk.Month.Godmonth",
        ordinal: 8, days: 28 // Days 213–241
      },
      {
        name: "DND5E.CALENDAR.Greyhawk.Month.Harvester",
        ordinal: 9, days: 28 // Days 241–269
      },
      {
        name: "DND5E.CALENDAR.Greyhawk.Festival.Brewfest",
        ordinal: 10, days: 6 // Days 269–275
      },
      {
        name: "DND5E.CALENDAR.Greyhawk.Month.Patchwall",
        ordinal: 10, days: 28 // Days 275-303
      },
      {
        name: "DND5E.CALENDAR.Greyhawk.Month.Readyreat",
        ordinal: 11, days: 28 // Days 303–331
      },
      {
        name: "DND5E.CALENDAR.Greyhawk.Month.Sunsebb",
        ordinal: 12, days: 28 // Days 331–359
      }
    ]
  },
  days: {
    values: [
      { name: "DND5E.CALENDAR.Greyhawk.Day.Starday", ordinal: 1 },
      { name: "DND5E.CALENDAR.Greyhawk.Day.Sunday", ordinal: 2 },
      { name: "DND5E.CALENDAR.Greyhawk.Day.Moonday", ordinal: 3 },
      { name: "DND5E.CALENDAR.Greyhawk.Day.Godsday", ordinal: 4 },
      { name: "DND5E.CALENDAR.Greyhawk.Day.Waterday", ordinal: 5 },
      { name: "DND5E.CALENDAR.Greyhawk.Day.Earthday", ordinal: 6 },
      { name: "DND5E.CALENDAR.Greyhawk.Day.Freeday", ordinal: 7 }
    ],
    daysPerYear: 360,
    hoursPerDay: 24,
    minutesPerHour: 60,
    secondsPerMinute: 60
  },
  seasons: {
    values: [
      { name: "DND5E.CALENDAR.Greyhawk.Season.Spring", dayStart: 48, dayEnd: 137 }, // Readying 15–Flocktime 14
      { name: "DND5E.CALENDAR.Greyhawk.Season.Summer", dayStart: 138, dayEnd: 227 }, // Flocktime 15–Godmonth 14
      { name: "DND5E.CALENDAR.Greyhawk.Season.Fall", dayStart: 228, dayEnd: 317 }, // Godmonth 15–Readyrest 14
      { name: "DND5E.CALENDAR.Greyhawk.Season.Winter", dayStart: 318, dayEnd: 47 } // Readrest 15–Readying 14
    ]
  }
};

const { ArrayField: ArrayField$q, NumberField: NumberField$S, SchemaField: SchemaField$12, StringField: StringField$1m } = foundry.data.fields;

/**
 * @import { CalendarConfigHarptosFestival } from "./_types.mjs";
 */

/**
 * Extension of the core calendar with support for festivals days and extra formatters.
 */
class CalendarHarptos extends CalendarData5e {
  /** @inheritDoc */
  static defineSchema() {
    const schema = super.defineSchema();
    return {
      ...schema,
      festivals: new ArrayField$q(new SchemaField$12({
        name: new StringField$1m({ required: true }),
        month: new NumberField$S({ required: true, nullable: false, min: 1, integer: true }),
        day: new NumberField$S({ required: true, nullable: false, min: 1, integer: true })
      }))
    };
  }

  /* -------------------------------------------- */
  /*  Calendar Helper Methods                     */
  /* -------------------------------------------- */

  /**
   * Find festival day for current day.
   * @param {number|Components} [time]      Time to use when finding festival day, by default the current world time.
   * @returns {CalendarConfigHarptosFestival|null}
   */
  findFestivalDay(time=game.time.worldTime) {
    const components = typeof time === "number" ? this.timeToComponents(time) : time;
    return this.festivals
      .find(f => f.month === (components.month + 1) && f.day === (components.dayOfMonth + 1)) ?? null;
  }

  /* -------------------------------------------- */
  /*  Formatter Functions                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static formatMonthDay(calendar, components, options) {
    const festivalDay = calendar.findFestivalDay(components);
    return festivalDay ? game.i18n.localize(festivalDay.name) : CalendarHarptos.formatLocalized(
      "DND5E.CALENDAR.Harptos.Formatters.DayMonth", calendar, components, options
    );
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static formatMonthDayYear(calendar, components, options) {
    const festivalDay = calendar.findFestivalDay(components);
    if ( festivalDay ) {
      const context = CalendarData5e.dateFormattingParts(calendar, components);
      context.day = game.i18n.localize(festivalDay.name);
      return game.i18n.format("DND5E.CALENDAR.Harptos.Formatters.FestivalDayYear", context);
    }
    return CalendarHarptos.formatLocalized(
      "DND5E.CALENDAR.Harptos.Formatters.DayMonthYear", calendar, components, options
    );
  }
}

/* -------------------------------------------- */

const CALENDAR_OF_HARPTOS = {
  name: "Calendar of Harptos",
  years: {
    yearZero: 1501,
    firstWeekday: 0,
    leapYear: {
      leapStart: 0,
      leapInterval: 4
    }
  },
  months: {
    values: [
      {
        name: "DND5E.CALENDAR.Harptos.Month.Hammer", abbreviation: "DND5E.CALENDAR.Harptos.Month.HammerCommon",
        ordinal: 1, days: 31 // Days: 0–30
      },
      {
        name: "DND5E.CALENDAR.Harptos.Month.Alturiak", abbreviation: "DND5E.CALENDAR.Harptos.Month.AlturiakCommon",
        ordinal: 2, days: 30 // Days: 30–60
      },
      {
        name: "DND5E.CALENDAR.Harptos.Month.Ches", abbreviation: "DND5E.CALENDAR.Harptos.Month.ChesCommon",
        ordinal: 3, days: 30 // Days: 60–90
      },
      {
        name: "DND5E.CALENDAR.Harptos.Month.Tarsakh", abbreviation: "DND5E.CALENDAR.Harptos.Month.TarsakhCommon",
        ordinal: 4, days: 31 // Days: 91–122
      },
      {
        name: "DND5E.CALENDAR.Harptos.Month.Mirtul", abbreviation: "DND5E.CALENDAR.Harptos.Month.MirtulCommon",
        ordinal: 5, days: 30 // Days: 122–152
      },
      {
        name: "DND5E.CALENDAR.Harptos.Month.Kythorn", abbreviation: "DND5E.CALENDAR.Harptos.Month.KythornCommon",
        ordinal: 6, days: 30 // Days: 152–182
      },
      {
        name: "DND5E.CALENDAR.Harptos.Month.Flamerule", abbreviation: "DND5E.CALENDAR.Harptos.Month.FlameruleCommon",
        ordinal: 7, days: 31, leapDays: 32 // Days: 182–213
      },
      {
        name: "DND5E.CALENDAR.Harptos.Month.Eleasis", abbreviation: "DND5E.CALENDAR.Harptos.Month.EleasisCommon",
        ordinal: 8, days: 30 // Days: 213–243
      },
      {
        name: "DND5E.CALENDAR.Harptos.Month.Eleint", abbreviation: "DND5E.CALENDAR.Harptos.Month.EleintCommon",
        ordinal: 9, days: 31 // Days: 243–273
      },
      {
        name: "DND5E.CALENDAR.Harptos.Month.Marpenoth", abbreviation: "DND5E.CALENDAR.Harptos.Month.MarpenothCommon",
        ordinal: 10, days: 30 // Days: 273–303
      },
      {
        name: "DND5E.CALENDAR.Harptos.Month.Uktar", abbreviation: "DND5E.CALENDAR.Harptos.Month.UktarCommon",
        ordinal: 11, days: 31 // Days: 303–334
      },
      {
        name: "DND5E.CALENDAR.Harptos.Month.Nightal", abbreviation: "DND5E.CALENDAR.Harptos.Month.NightalCommon",
        ordinal: 12, days: 30 // Days: 334–364
      }
    ]
  },
  days: {
    values: [
      { name: "DND5E.CALENDAR.Harptos.Day.One", ordinal: 1 },
      { name: "DND5E.CALENDAR.Harptos.Day.Two", ordinal: 2 },
      { name: "DND5E.CALENDAR.Harptos.Day.Three", ordinal: 3 },
      { name: "DND5E.CALENDAR.Harptos.Day.Four", ordinal: 4 },
      { name: "DND5E.CALENDAR.Harptos.Day.Five", ordinal: 5 },
      { name: "DND5E.CALENDAR.Harptos.Day.Six", ordinal: 6 },
      { name: "DND5E.CALENDAR.Harptos.Day.Seven", ordinal: 7 },
      { name: "DND5E.CALENDAR.Harptos.Day.Eight", ordinal: 8 },
      { name: "DND5E.CALENDAR.Harptos.Day.Nine", ordinal: 9 },
      { name: "DND5E.CALENDAR.Harptos.Day.Ten", ordinal: 10 }
    ],
    daysPerYear: 365,
    hoursPerDay: 24,
    minutesPerHour: 60,
    secondsPerMinute: 60
  },
  festivals: [
    { name: "DND5E.CALENDAR.Harptos.Festival.Midwinter", month: 1, day: 31 },
    { name: "DND5E.CALENDAR.Harptos.Festival.Greengrass", month: 4, day: 31 },
    { name: "DND5E.CALENDAR.Harptos.Festival.Midsummer", month: 7, day: 31 },
    { name: "DND5E.CALENDAR.Harptos.Festival.Shieldmeet", month: 7, day: 32 },
    { name: "DND5E.CALENDAR.Harptos.Festival.Highharvestide", month: 9, day: 31 },
    { name: "DND5E.CALENDAR.Harptos.Festival.FeastOfTheMoon", month: 11, day: 31 }
  ],
  seasons: {
    values: [
      { name: "DND5E.CALENDAR.Harptos.Season.Spring", dayStart: 79, dayEnd: 171 }, // 19 Ches–19 Kythorn
      { name: "DND5E.CALENDAR.Harptos.Season.Summer", dayStart: 172, dayEnd: 263 }, // 20 Kythorn–20 Eleint
      { name: "DND5E.CALENDAR.Harptos.Season.Fall", dayStart: 264, dayEnd: 353 }, // 21 Eleint–19 Uktar
      { name: "DND5E.CALENDAR.Harptos.Season.Winter", dayStart: 354, dayEnd: 78 } // 20 Uktar-18 Ches
    ]
  }
};

/**
 * Extension of the core calendar with support for extra formatters.
 */
class CalendarKhorvaire extends CalendarData5e {

  /* -------------------------------------------- */
  /*  Formatter Functions                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static formatMonthDay(calendar, components, options) {
    return CalendarKhorvaire.formatLocalized(
      "DND5E.CALENDAR.Khorvaire.Formatters.DayMonth", calendar, components, options
    );
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static formatMonthDayYear(calendar, components, options) {
    return CalendarKhorvaire.formatLocalized(
      "DND5E.CALENDAR.Khorvaire.Formatters.DayMonthYear", calendar, components, options
    );
  }
}

/* -------------------------------------------- */

const CALENDAR_OF_KHORVAIRE = {
  name: "Common Calendar of Khorvaire",
  years: {
    yearZero: 998,
    firstWeekday: 0
  },
  months: {
    values: [
      {
        name: "DND5E.CALENDAR.Khorvaire.Month.Zarantyr",
        ordinal: 1, days: 28
      },
      {
        name: "DND5E.CALENDAR.Khorvaire.Month.Olarune",
        ordinal: 2, days: 28
      },
      {
        name: "DND5E.CALENDAR.Khorvaire.Month.Therendor",
        ordinal: 3, days: 28
      },
      {
        name: "DND5E.CALENDAR.Khorvaire.Month.Eyre",
        ordinal: 4, days: 28
      },
      {
        name: "DND5E.CALENDAR.Khorvaire.Month.Dravago",
        ordinal: 5, days: 28
      },
      {
        name: "DND5E.CALENDAR.Khorvaire.Month.Nymm",
        ordinal: 6, days: 28
      },
      {
        name: "DND5E.CALENDAR.Khorvaire.Month.Lharvion",
        ordinal: 7, days: 28
      },
      {
        name: "DND5E.CALENDAR.Khorvaire.Month.Barrakas",
        ordinal: 8, days: 28
      },
      {
        name: "DND5E.CALENDAR.Khorvaire.Month.Rhaan",
        ordinal: 9, days: 28
      },
      {
        name: "DND5E.CALENDAR.Khorvaire.Month.Sypheros",
        ordinal: 10, days: 28
      },
      {
        name: "DND5E.CALENDAR.Khorvaire.Month.Aryth",
        ordinal: 11, days: 28
      },
      {
        name: "DND5E.CALENDAR.Khorvaire.Month.Vult",
        ordinal: 12, days: 28
      }
    ]
  },
  days: {
    values: [
      { name: "DND5E.CALENDAR.Khorvaire.Day.Sul", ordinal: 1 },
      { name: "DND5E.CALENDAR.Khorvaire.Day.Mol", ordinal: 2 },
      { name: "DND5E.CALENDAR.Khorvaire.Day.Zol", ordinal: 3 },
      { name: "DND5E.CALENDAR.Khorvaire.Day.Wir", ordinal: 4 },
      { name: "DND5E.CALENDAR.Khorvaire.Day.Zor", ordinal: 5 },
      { name: "DND5E.CALENDAR.Khorvaire.Day.Far", ordinal: 6 },
      { name: "DND5E.CALENDAR.Khorvaire.Day.Sar", ordinal: 7 }
    ],
    daysPerYear: 336,
    hoursPerDay: 24,
    minutesPerHour: 60,
    secondsPerMinute: 60
  },
  seasons: {
    values: [
      { name: "DND5E.CALENDAR.Khorvaire.Season.Spring", monthStart: 3,  monthEnd: 5 }, // Therendor–Dravago
      { name: "DND5E.CALENDAR.Khorvaire.Season.Summer", monthStart: 6, monthEnd: 8 }, // Nymm–Barrakas
      { name: "DND5E.CALENDAR.Khorvaire.Season.Autumn", monthStart: 9, monthEnd: 11 }, // Rhaan–Aryth
      { name: "DND5E.CALENDAR.Khorvaire.Season.Winter", monthStart: 12, monthEnd: 2 } // Vult–Olarune
    ]
  }
};

const { ArrayField: ArrayField$p, NumberField: NumberField$R, SchemaField: SchemaField$11, StringField: StringField$1l } = foundry.data.fields;

/**
 * @import {
 *   BasicRollDialogConfiguration, BasicRollMessageConfiguration, RechargeRollProcessConfiguration
 * } from "../../dice/_types.mjs";
 */

/**
 * Field for storing uses data.
 */
class UsesField extends SchemaField$11 {
  constructor(fields={}, options={}) {
    fields = {
      spent: new NumberField$R({ initial: 0, min: 0, integer: true }),
      max: new FormulaField({ deterministic: true }),
      recovery: new ArrayField$p(
        new SchemaField$11({
          period: new StringField$1l({ required: true, initial: "lr", blank: false }),
          type: new StringField$1l({ required: true, initial: "recoverAll", blank: false }),
          formula: new FormulaField()
        })
      ),
      ...fields
    };
    super(fields, options);
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /**
   * Prepare data for this field. Should be called during the `prepareFinalData` stage.
   * @this {ItemDataModel|BaseActivityData}
   * @param {object} rollData  Roll data used for formula replacements.
   * @param {object} [labels]  Object in which to insert generated labels.
   */
  static prepareData(rollData, labels) {
    prepareFormulaValue(this, "uses.max", "DND5E.USES.FIELDS.uses.max.label", rollData);
    this.uses.value = this.uses.max ? Math.clamp(this.uses.max - this.uses.spent, 0, this.uses.max) : 0;

    const periods = [];
    for ( const recovery of this.uses.recovery ) {
      if ( recovery.period === "recharge" ) {
        recovery.formula ??= "6";
        recovery.type = "recoverAll";
        recovery.recharge = { options: UsesField.rechargeOptions };
        if ( labels ) labels.recharge ??= `${game.i18n.localize("DND5E.Recharge")} [${
          recovery.formula}${parseInt(recovery.formula) < 6 ? "+" : ""}]`;
      } else if ( recovery.period in CONFIG.DND5E.limitedUsePeriods ) {
        const config = CONFIG.DND5E.limitedUsePeriods[recovery.period];
        periods.push(config.abbreviation ?? config.label);
      }
    }
    if ( labels ) labels.recovery = game.i18n.getListFormatter({ style: "narrow" }).format(periods);

    this.uses.label ??= UsesField.getStatblockLabel.call(this);

    Object.defineProperty(this.uses, "rollRecharge", {
      value: UsesField.rollRecharge.bind(this.parent?.system ? this.parent : this),
      configurable: true
    });
  }

  /* -------------------------------------------- */

  /**
   * Recharge range options.
   * @returns {FormSelectOption[]}
   */
  static get rechargeOptions() {
    return Array.fromRange(5, 2).reverse().map(min => ({
      value: min,
      label: game.i18n.format("DND5E.USES.Recovery.Recharge.Range", {
        range: min === 6 ? formatNumber(6) : formatRange(min, 6)
      })
    }));
  }

  /* -------------------------------------------- */

  /**
   * Create a label for uses data that matches the style seen on NPC stat blocks. Complex recovery data might result
   * in no label being generated if it doesn't represent recovery that can be normally found on a NPC.
   * @this {ItemDataModel|BaseActivityData}
   * @returns {string}
   */
  static getStatblockLabel() {
    // Legendary/Mythic Actions
    if ( (this.activation?.type === "legendary") || (this.activation?.type === "mythic") ) {
      if ( this.activation.value < 2 ) return "";
      const pr = getPluralRules();
      return game.i18n.format(`DND5E.NPC.ActionCostCounted.${pr.select(this.activation.value)}`, {
        number: formatNumber(this.activation.value)
      });
    }

    if ( !this.uses.max || (this.uses.recovery.length !== 1) ) return "";
    const recovery = this.uses.recovery[0];

    // Ignore combat & special recovery periods
    const type = CONFIG.DND5E.limitedUsePeriods[recovery.period]?.type;
    if ( (type === "combat") || (type === "special") ) return "";

    // Recharge X–Y
    if ( recovery.period === "recharge" ) {
      const value = parseInt(recovery.formula);
      return `${game.i18n.localize("DND5E.Recharge")} ${value === 6 ? "6" : `${value}–6`}`;
    }

    // Recharge after a Short or Long Rest
    if ( ["lr", "sr"].includes(recovery.period) && (this.uses.max === 1) ) {
      return game.i18n.localize(`DND5E.Recharge${recovery.period === "sr" ? "Short" : "Long"}`);
    }

    // X/Day
    const period = CONFIG.DND5E.limitedUsePeriods[recovery.period === "sr" ? "sr" : "day"]?.label ?? "";
    if ( !period ) return "";
    return `${this.uses.max}/${period}`;
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Determine uses recovery.
   * @this {ItemDataModel|BaseActivityData}
   * @param {string[]} periods  Recovery periods to check.
   * @param {object} rollData   Roll data to use when evaluating recover formulas.
   * @returns {Promise<{ updates: object, rolls: BasicRoll[] }|false>}
   */
  static async recoverUses(periods, rollData) {
    if ( !this.uses?.recovery.length ) return false;

    // Search the recovery profiles in order to find the first matching period,
    // and then find the first profile that uses that recovery period
    let profile;
    findPeriod: {
      for ( const period of periods ) {
        for ( const recovery of this.uses.recovery ) {
          if ( recovery.period === period ) {
            profile = recovery;
            break findPeriod;
          }
        }
      }
    }
    if ( !profile ) return false;

    const updates = {};
    const rolls = [];
    const item = this.item ?? this.parent;

    if ( profile.type === "recoverAll" ) updates.spent = 0;
    else if ( profile.type === "loseAll" ) updates.spent = this.uses.max;
    else if ( profile.formula ) {
      let roll;
      let total;
      try {
        const delta = this.parent instanceof Item ? { item: this.parent.id, keyPath: "system.uses.spent" }
          : { item: this.item.id, keyPath: `system.activities.${this.id}.uses.spent` };
        roll = new CONFIG.Dice.BasicRoll(profile.formula, rollData, { delta });
        if ( ["day", "dawn", "dusk"].includes(profile.period)
          && (game.settings.get("dnd5e", "restVariant") === "gritty") ) {
          roll.alter(7, 0, { multiplyNumeric: true });
        }
        total = (await roll.evaluate()).total;
      } catch(err) {
        Hooks.onError("UsesField#recoverUses", err, {
          msg: game.i18n.format("DND5E.ItemRecoveryFormulaWarning", {
            name: item.name, formula: profile.formula, uuid: this.uuid ?? item.uuid
          }),
          log: "error",
          notify: "error"
        });
        return false;
      }

      const newSpent = Math.clamp(this.uses.spent - total, 0, this.uses.max);
      if ( newSpent !== this.uses.spent ) {
        updates.spent = newSpent;
        if ( !roll.isDeterministic ) rolls.push(roll);
      }
    }

    return { updates, rolls };
  }

  /* -------------------------------------------- */

  /**
   * Rolls a recharge test for an Item or Activity that uses the d6 recharge mechanic.
   * @this {Item5e|Activity}
   * @param {RechargeRollProcessConfiguration} config  Configuration information for the roll.
   * @param {BasicRollDialogConfiguration} dialog      Configuration for the roll dialog.
   * @param {BasicRollMessageConfiguration} message    Configuration for the roll message.
   * @returns {Promise<BasicRoll[]|{ rolls: BasicRoll[], updates: object }|void>}  The created Roll instances, update
   *                                                                               data, or nothing if not rolled.
   */
  static async rollRecharge(config={}, dialog={}, message={}) {
    const uses = this.system ? this.system.uses : this.uses;
    const recharge = uses?.recovery.find(({ period }) => period === "recharge");
    if ( !recharge || !uses?.spent ) return;

    const rollConfig = foundry.utils.mergeObject({
      rolls: [{
        parts: ["1d6"],
        data: this.getRollData(),
        options: {
          delta: this instanceof Item ? { item: this.id, keyPath: "system.uses.spent" }
            : { item: this.item.id, keyPath: `system.activities.${this.id}.uses.spent` },
          target: parseInt(recharge.formula)
        }
      }]
    }, config);
    rollConfig.hookNames = [...(config.hookNames ?? []), "recharge"];
    rollConfig.subject = this;

    const dialogConfig = foundry.utils.mergeObject({ configure: false }, dialog);

    const messageConfig = foundry.utils.mergeObject({
      create: true,
      data: {
        speaker: ChatMessage.getSpeaker({ actor: this.actor, token: this.actor.token })
      },
      rollMode: game.settings.get("core", "rollMode")
    }, message);

    const rolls = await CONFIG.Dice.BasicRoll.buildConfigure(rollConfig, dialogConfig, messageConfig);
    await CONFIG.Dice.BasicRoll.buildEvaluate(rolls, rollConfig, messageConfig);
    if ( !rolls.length ) return;
    messageConfig.data.flavor = game.i18n.format("DND5E.ItemRechargeCheck", {
      name: this.name,
      result: game.i18n.localize(`DND5E.ItemRecharge${rolls[0].isSuccess ? "Success" : "Failure"}`)
    });
    await CONFIG.Dice.BasicRoll.buildPost(rolls, rollConfig, messageConfig);

    const updates = {};
    if ( rolls[0].isSuccess ) {
      if ( this instanceof Item ) updates["system.uses.spent"] = 0;
      else updates["uses.spent"] = 0;
    }

    /**
     * A hook event that fires after an Item or Activity has rolled to recharge, but before any usage changes have
     * been made.
     * @function dnd5e.rollRecharge
     * @memberof hookEvents
     * @param {BasicRoll[]} rolls             The resulting rolls.
     * @param {object} data
     * @param {Item5e|Activity} data.subject  Item or Activity for which the roll was performed.
     * @param {object} data.updates           Updates to be applied to the subject.
     * @returns {boolean}                     Explicitly return `false` to prevent updates from being performed.
     */
    if ( Hooks.call("dnd5e.rollRecharge", rolls, { subject: this, updates }) === false ) return rolls;
    if ( Hooks.call("dnd5e.rollRechargeV2", rolls, { subject: this, updates }) === false ) return rolls;

    if ( (rollConfig.apply !== false) && !foundry.utils.isEmpty(updates) ) await this.update(updates);

    /**
     * A hook event that fires after an Item or Activity has rolled recharge and usage updates have been performed.
     * @function dnd5e.postRollRecharge
     * @memberof hookEvents
     * @param {BasicRoll[]} rolls     The resulting rolls.
     * @param {object} data
     * @param {Actor5e} data.subject  Item or Activity for which the roll was performed.
     */
    Hooks.callAll("dnd5e.postRollRecharge", rolls, { subject: this });

    return { rolls, updates };
  }
}

/**
 * Default sheet for activities.
 */
class PseudoDocumentSheet extends Application5e {
  constructor(options={}) {
    super(options);
    this.#documentId = options.document.id;
    this.#documentType = options.document.metadata.name;
    this.#item = options.document.item;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["pseudo-document", "sheet", "standard-form"],
    tag: "form",
    document: null,
    viewPermission: CONST.DOCUMENT_OWNERSHIP_LEVELS.LIMITED,
    editPermission: CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER,
    actions: {
      copyUuid: { handler: PseudoDocumentSheet.#onCopyUuid, buttons: [0, 2] }
    },
    form: {
      handler: PseudoDocumentSheet.#onSubmitForm,
      submitOnChange: true
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The PseudoDocument associated with this application.
   * @type {PseudoDocument}
   */
  get document() {
    return this.item.getEmbeddedDocument(this.#documentType, this.#documentId);
  }

  /**
   * ID of this PseudoDocument on the parent item.
   * @type {string}
   */
  #documentId;

  /**
   * Collection representing this PseudoDocument.
   * @type {string}
   */
  #documentType;

  /* -------------------------------------------- */

  /**
   * Is this PseudoDocument sheet visible to the current user?
   * @type {boolean}
   */
  get isVisible() {
    return this.item.testUserPermission(game.user, this.options.viewPermission);
  }

  /* -------------------------------------------- */

  /**
   * Is this PseudoDocument sheet editable by the current User?
   * This is governed by the editPermission threshold configured for the class.
   * @type {boolean}
   */
  get isEditable() {
    if ( game.packs.get(this.item.pack)?.locked ) return false;
    return this.item.testUserPermission(game.user, this.options.editPermission);
  }

  /* -------------------------------------------- */

  /**
   * Parent item to which this PseudoDocument belongs.
   * @type {Item5e}
   */
  #item;

  get item() {
    return this.#item;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    return {
      ...await super._prepareContext(options),
      document: this.document,
      editable: this.isEditable,
      options: this.options
    };
  }

  /* -------------------------------------------- */
  /*  Life-Cycle Handlers                         */
  /* -------------------------------------------- */

  /** @override */
  _canRender(options) {
    if ( !this.isVisible ) throw new Error(game.i18n.format("SHEETS.DocumentSheetPrivate", {
      type: game.i18n.localize(this.document.metadata.label)
    }));
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onFirstRender(context, options) {
    super._onFirstRender(context, options);
    this.document.constructor._registerApp(this.document, this);
    this.item.apps[this.id] = this;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onRender(context, options) {
    super._onRender(context, options);
    if ( !this.isEditable ) this._disableFields();
  }

  /* -------------------------------------------- */

  /** @override */
  _onClose(_options) {
    this.document?.constructor._unregisterApp(this.document, this);
    delete this.item.apps[this.id];
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _renderFrame(options) {
    const frame = await super._renderFrame(options);
    frame.autocomplete = "off";

    // Add document ID copy
    const copyLabel = game.i18n.localize("SHEETS.CopyUuid");
    const copyId = `<button type="button" class="header-control fa-solid fa-passport icon" data-action="copyUuid"
                            data-tooltip aria-label="${copyLabel}"></button>`;
    this.window.close.insertAdjacentHTML("beforebegin", copyId);

    return frame;
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle click events to copy the UUID of this document to clipboard.
   * @this {PseudoDocumentSheet}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   * @this {PseudoDocumentSheet}
   */
  static #onCopyUuid(event, target) {
    event.preventDefault();
    event.stopPropagation();
    if ( event.detail > 1 ) return;
    const id = event.button === 2 ? this.document.id : this.document.uuid;
    const type = event.button === 2 ? "id" : "uuid";
    const label = game.i18n.localize(this.document.metadata.label);
    game.clipboard.copyPlainText(id);
    ui.notifications.info(game.i18n.format("DOCUMENT.IdCopiedClipboard", { label, type, id }));
  }

  /* -------------------------------------------- */
  /*  Form Handling                               */
  /* -------------------------------------------- */

  /**
   * Handle form submission.
   * @param {SubmitEvent} event          Triggering submit event.
   * @param {HTMLFormElement} form       The form that was submitted.
   * @param {FormDataExtended} formData  Data from the submitted form.
   */
  static async #onSubmitForm(event, form, formData) {
    const submitData = this._prepareSubmitData(event, formData);
    await this._processSubmitData(event, submitData);
  }

  /* -------------------------------------------- */

  /**
   * Perform any pre-processing of the form data to prepare it for updating.
   * @param {SubmitEvent} event          Triggering submit event.
   * @param {FormDataExtended} formData  Data from the submitted form.
   * @returns {object}
   */
  _prepareSubmitData(event, formData) {
    const submitData = foundry.utils.expandObject(formData.object);
    // Workaround for https://github.com/foundryvtt/foundryvtt/issues/11610
    this.element.querySelectorAll("fieldset legend :is(input, select, dnd5e-checkbox)").forEach(input => {
      foundry.utils.setProperty(submitData, input.name, input.value);
    });
    return submitData;
  }

  /* -------------------------------------------- */

  /**
   * Handle updating the PseudoDocument based on processed submit data.
   * @param {SubmitEvent} event  Triggering submit event.
   * @param {object} submitData  Prepared object for updating.
   */
  async _processSubmitData(event, submitData) {
    await this.document.update(submitData);
  }

  /* -------------------------------------------- */

  /**
   * Programmatically submit a PseudoDocumentSheet instance, providing additional data to be merged with form data.
   * @param {object} options
   * @param {object} [options.updateData]  Additional data merged with processed form data.
   */
  async submit({ updateData={} }={}) {
    if ( !this.options.form?.handler ) throw new Error(
      `The ${this.constructor.name} PseudoDocumentSheet does not support a single top-level form element.`
    );
    const event = new Event("submit", { cancelable: true });
    const formData = new foundry.applications.ux.FormDataExtended(this.element);
    const submitData = await this._prepareSubmitData(event, formData);
    foundry.utils.mergeObject(submitData, updateData, { inplace: true });
    await this._processSubmitData(event, submitData);
  }
}

/**
 * Default sheet for activities.
 */
class ActivitySheet extends PseudoDocumentSheet {
  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["activity"],
    window: {
      icon: "fa-solid fa-gauge"
    },
    actions: {
      addConsumption: ActivitySheet.#addConsumption,
      addDamagePart: ActivitySheet.#addDamagePart,
      addEffect: ActivitySheet.#addEffect,
      addRecovery: ActivitySheet.#addRecovery,
      deleteConsumption: ActivitySheet.#deleteConsumption,
      deleteDamagePart: ActivitySheet.#deleteDamagePart,
      deleteEffect: ActivitySheet.#deleteEffect,
      deleteRecovery: ActivitySheet.#deleteRecovery,
      dissociateEffect: ActivitySheet.#dissociateEffect
    },
    position: {
      width: 500,
      height: "auto"
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    tabs: {
      template: "templates/generic/tab-navigation.hbs"
    },
    identity: {
      template: "systems/dnd5e/templates/activity/identity.hbs",
      templates: [
        "systems/dnd5e/templates/activity/parts/activity-identity.hbs",
        "systems/dnd5e/templates/activity/parts/activity-visibility.hbs"
      ]
    },
    activation: {
      template: "systems/dnd5e/templates/activity/activation.hbs",
      templates: [
        "systems/dnd5e/templates/activity/parts/activity-time.hbs",
        "systems/dnd5e/templates/activity/parts/activity-targeting.hbs",
        "systems/dnd5e/templates/activity/parts/activity-consumption.hbs"
      ]
    },
    effect: {
      template: "systems/dnd5e/templates/activity/effect.hbs",
      templates: [
        "systems/dnd5e/templates/activity/parts/activity-effects.hbs",
        "systems/dnd5e/templates/activity/parts/activity-effect-level-limit.hbs",
        "systems/dnd5e/templates/activity/parts/activity-effect-settings.hbs"
      ]
    }
  };

  /* -------------------------------------------- */

  /**
   * Key paths to the parts of the submit data stored in arrays that will need special handling on submission.
   * @type {string[]}
   */
  static CLEAN_ARRAYS = ["consumption.targets", "damage.parts", "effects", "uses.recovery"];

  /* -------------------------------------------- */

  /** @override */
  tabGroups = {
    sheet: "identity",
    activation: "time"
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The Activity associated with this application.
   * @type {Activity}
   */
  get activity() {
    return this.document;
  }

  /* -------------------------------------------- */

  /** @override */
  get title() {
    return this.activity.name;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    return {
      ...await super._prepareContext(options),
      activity: this.activity,
      fields: this.activity.schema.fields,
      inferred: this.activity._inferredSource,
      source: this.activity.toObject(),
      tabs: this._getTabs()
    };
  }

  /* -------------------------------------------- */

  /** @override */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    switch ( partId ) {
      case "activation": return this._prepareActivationContext(context, options);
      case "effect": return this._prepareEffectContext(context, options);
      case "identity": return this._prepareIdentityContext(context, options);
    }
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare rendering context for the activation tab.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {ApplicationRenderContext}
   * @protected
   */
  async _prepareActivationContext(context, options) {
    context.tab = context.tabs.activation;

    context.data = {};
    context.disabled = {};
    for ( const field of ["activation", "duration", "range", "target", "uses"] ) {
      if ( !this.activity[field] ) continue;
      context.data[field] = this.activity[field].override ? context.source[field] : context.inferred[field];
      context.disabled[field] = this.activity[field].canOverride && !this.activity[field].override
        && !this.activity.isRider;
    }

    context.activationTypes = [
      ...Object.entries(CONFIG.DND5E.activityActivationTypes).map(([value, config]) => ({
        value,
        label: game.i18n.localize(config.label),
        group: game.i18n.localize(config.group)
      })),
      { value: "", label: game.i18n.localize("DND5E.NoneActionLabel") }
    ];
    context.affectsPlaceholder = game.i18n.localize(
      `DND5E.TARGET.Count.${context.data.target?.template?.type ? "Every" : "Any"}`
    );
    context.durationUnits = [
      { value: "inst", label: game.i18n.localize("DND5E.TimeInst") },
      ...Object.entries(CONFIG.DND5E.scalarTimePeriods).map(([value, label]) => ({
        value, label, group: game.i18n.localize("DND5E.DurationTime")
      })),
      ...Object.entries(CONFIG.DND5E.permanentTimePeriods).map(([value, label]) => ({
        value, label, group: game.i18n.localize("DND5E.DurationPermanent")
      })),
      { value: "spec", label: game.i18n.localize("DND5E.Special") }
    ];
    context.rangeUnits = [
      ...Object.entries(CONFIG.DND5E.rangeTypes).map(([value, label]) => ({ value, label })),
      ...Object.entries(CONFIG.DND5E.movementUnits).map(([value, { label }]) => ({
        value, label, group: game.i18n.localize("DND5E.RangeDistance")
      }))
    ];

    // Consumption targets
    const canScale = this.activity.canConfigureScaling;
    const consumptionTypeOptions = Array.from(this.activity.validConsumptionTypes).map(value => ({
      value,
      label: CONFIG.DND5E.activityConsumptionTypes[value].label
    }));
    context.consumptionTargets = context.source.consumption.targets.map((data, index) => {
      const typeConfig = CONFIG.DND5E.activityConsumptionTypes[data.type] ?? {};
      const showTextTarget = typeConfig.targetRequiresEmbedded && !this.item.isEmbedded;
      const target = new ConsumptionTargetData(data, { parent: this.activity });
      return {
        data,
        fields: this.activity.schema.fields.consumption.fields.targets.element.fields,
        prefix: `consumption.targets.${index}.`,
        source: context.source.consumption.targets[index] ?? data,
        targetHint: this.item.isEmbedded ? undefined : typeConfig.nonEmbeddedHint,
        typeOptions: consumptionTypeOptions,
        scalingModes: canScale ? [
          { value: "", label: game.i18n.localize("DND5E.CONSUMPTION.Scaling.None") },
          { value: "amount", label: game.i18n.localize("DND5E.CONSUMPTION.Scaling.Amount") },
          ...(typeConfig.scalingModes ?? []).map(({ value, label }) => ({ value, label: game.i18n.localize(label) }))
        ] : null,
        showTargets: "validTargets" in typeConfig,
        selectedTarget: ("validTargets" in typeConfig) && ["itemUses", "material"].includes(data.type)
          ? this.activity._remapConsumptionTarget(data.target)
          : data.target,
        targetPlaceholder: data.type === "itemUses" ? game.i18n.localize("DND5E.CONSUMPTION.Target.ThisItem") : "",
        validTargets: showTextTarget ? null : target.validTargets
      };
    });
    context.showConsumeSpellSlot = (this.activity.isSpell || this.activity.isRider) && (this.item.system.level !== 0);
    context.showScaling = !this.activity.isSpell || this.activity.isRider;

    // Uses recovery
    context.recoveryPeriods = CONFIG.DND5E.limitedUsePeriods.recoveryOptions;
    context.recoveryTypes = [
      { value: "recoverAll", label: game.i18n.localize("DND5E.USES.Recovery.Type.RecoverAll") },
      { value: "loseAll", label: game.i18n.localize("DND5E.USES.Recovery.Type.LoseAll") },
      { value: "formula", label: game.i18n.localize("DND5E.USES.Recovery.Type.Formula") }
    ];
    context.usesRecovery = context.source.uses.recovery.map((data, index) => ({
      data,
      fields: this.activity.schema.fields.uses.fields.recovery.element.fields,
      prefix: `uses.recovery.${index}.`,
      source: context.source.uses.recovery[index] ?? data,
      formulaOptions: data.period === "recharge" ? UsesField.rechargeOptions : null
    }));

    // Template dimensions
    context.dimensions = context.activity.target?.template?.dimensions;

    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare a specific applied effect if present in the activity data.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {object} effect                     Applied effect context being prepared.
   * @returns {object}
   * @protected
   */
  _prepareAppliedEffectContext(context, effect) {
    return effect;
  }

  /* -------------------------------------------- */

  /**
   * Prepare a specific damage part if present in the activity data.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {object} part                       Damage part context being prepared.
   * @returns {object}
   * @protected
   */
  _prepareDamagePartContext(context, part) {
    return part;
  }

  /* -------------------------------------------- */

  /**
   * Prepare rendering context for the effect tab.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {ApplicationRenderContext}
   * @protected
   */
  async _prepareEffectContext(context, options) {
    context.tab = context.tabs.effect;

    if ( context.activity.effects ) {
      const appliedEffects = new Set(context.activity.effects?.map(e => e._id) ?? []);
      context.allEffects = this.item.effects
        .filter(e => e.type !== "enchantment")
        .map(effect => ({
          value: effect.id, label: effect.name, selected: appliedEffects.has(effect.id)
        }));
      context.appliedEffects = context.activity.effects.reduce((arr, data) => {
        if ( !data.effect ) return arr;
        const effect = {
          data,
          collapsed: this.expandedSections.get(`effects.${data._id}`) ? "" : "collapsed",
          effect: data.effect,
          fields: this.activity.schema.fields.effects.element.fields,
          prefix: `effects.${data._index}.`,
          source: context.source.effects[data._index] ?? data,
          contentLink: data.effect.toAnchor().outerHTML,
          additionalSettings: "systems/dnd5e/templates/activity/parts/activity-effect-settings.hbs"
        };
        arr.push(this._prepareAppliedEffectContext(context, effect));
        return arr;
      }, []);
    }

    context.denominationOptions = [
      { value: "", label: "" },
      ...CONFIG.DND5E.dieSteps.map(value => ({ value, label: `d${value}` }))
    ];
    if ( context.activity.damage?.parts ) {
      const scaleKey = (this.item.type === "spell") && (this.item.system.level === 0) ? "labelCantrip" : "label";
      const scalingOptions = [
        { value: "", label: game.i18n.localize("DND5E.DAMAGE.Scaling.None") },
        ...Object.entries(CONFIG.DND5E.damageScalingModes).map(([value, { [scaleKey]: label }]) => ({ value, label }))
      ];
      const typeOptions = Object.entries(CONFIG.DND5E.damageTypes).map(([value, { label }]) => ({ value, label }));
      const makePart = (data, index) => this._prepareDamagePartContext(context, {
        data, index, scalingOptions, typeOptions,
        locked: data.locked || (index === undefined),
        canScale: this.activity.canScaleDamage,
        fields: this.activity.schema.fields.damage.fields.parts.element.fields,
        prefix: index !== undefined ? `damage.parts.${index}.` : "_.",
        source: data
      });
      context.damageParts = [
        ...context.activity.damage.parts
          .filter(p => p._index === undefined)
          .map((data, index) => makePart(data)),
        ...context.source.damage.parts.map((data, index) => makePart(data, index))
      ];
    }

    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare rendering context for the identity tab.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {ApplicationRenderContext}
   * @protected
   */
  async _prepareIdentityContext(context, options) {
    context.tab = context.tabs.identity;
    context.behaviorFields = [];
    if ( context.fields.target?.fields?.prompt ) context.behaviorFields.push({
      field: context.fields.target.fields.prompt,
      value: context.source.target.prompt,
      input: context.inputs.createCheckboxInput
    });
    context.placeholder = {
      name: game.i18n.localize(this.activity.metadata.title),
      img: this.activity.metadata.img
    };

    const addField = (name, lockedValue) => name in context.fields.visibility.fields ? {
      disabled: lockedValue !== undefined,
      field: context.fields.visibility.fields[name],
      input: context.inputs.createCheckboxInput,
      value: lockedValue ?? context.source.visibility[name]
    } : null;
    const itemSystem = this.activity.item.system;
    const isRider = this.activity.isRider;
    context.visibilityFields = [
      // Only show "Require Attunement" if item has an attunement option
      ["required", "optional"].includes(itemSystem.attunement) || isRider ? addField("requireAttunement",
        // If item requires attunement, then the "Require Attunement" option is locked to the "Require Magic" option
        !isRider && (itemSystem.attunement === "required")
          ? context.source.visibility.requireMagic : undefined
      ) : null,
      // Only show "Require Magic" if item is magical or doesn't support the magical property
      (!this.activity.isSpell && (itemSystem.properties?.has("mgc") || !itemSystem.validProperties.has("mgc")))
        || isRider ? addField("requireMagic") : null,
      // Only show "Require Identification" if item can be identified
      "identified" in this.activity.item.system || isRider ? addField("requireIdentification") : null
    ].filter(_ => _);

    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare the tab information for the sheet.
   * @returns {Record<string, Partial<ApplicationTab>>}
   * @protected
   */
  _getTabs() {
    return this._markTabs({
      identity: {
        id: "identity", group: "sheet", icon: "fa-solid fa-tag",
        label: "DND5E.ACTIVITY.SECTIONS.Identity"
      },
      activation: {
        id: "activation", group: "sheet", icon: "fa-solid fa-clapperboard",
        label: "DND5E.ACTIVITY.SECTIONS.Activation",
        tabs: {
          time: {
            id: "time", group: "activation", icon: "fa-solid fa-clock",
            label: "DND5E.ACTIVITY.SECTIONS.Time"
          },
          consumption: {
            id: "consumption", group: "activation", icon: "fa-solid fa-boxes-stacked",
            label: "DND5E.CONSUMPTION.FIELDS.consumption.label"
          },
          targeting: {
            id: "activation-targeting", group: "activation", icon: "fa-solid fa-bullseye",
            label: "DND5E.TARGET.FIELDS.target.label"
          }
        }
      },
      effect: {
        id: "effect", group: "sheet", icon: "fa-solid fa-sun",
        label: "DND5E.ACTIVITY.SECTIONS.Effect"
      }
    });
  }

  /* -------------------------------------------- */

  /**
   * Helper to mark the tabs data structure with the appropriate CSS class if it is active.
   * @param {Record<string, Partial<ApplicationTab>>} tabs  Tabs definition to modify.
   * @returns {Record<string, Partial<ApplicationTab>>}
   * @internal
   */
  _markTabs(tabs) {
    for ( const v of Object.values(tabs) ) {
      v.active = this.tabGroups[v.group] === v.id;
      v.cssClass = v.active ? "active" : "";
      if ( "tabs" in v ) this._markTabs(v.tabs);
    }
    return tabs;
  }

  /* -------------------------------------------- */
  /*  Life-Cycle Handlers                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onRender(context, options) {
    super._onRender(context, options);
    this.#toggleNestedTabs();
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @inheritDoc */
  changeTab(tab, group, options={}) {
    super.changeTab(tab, group, options);
    if ( group !== "sheet" ) return;
    this.#toggleNestedTabs();
  }

  /* -------------------------------------------- */

  /**
   * Apply nested tab classes.
   */
  #toggleNestedTabs() {
    const primary = this.element.querySelector('.window-content > [data-application-part="tabs"]');
    const active = this.element.querySelector('.tab.active[data-group="sheet"]');
    if ( !primary || !active ) return;
    primary.classList.toggle("nested-tabs", active.querySelector(":scope > .sheet-tabs"));
  }

  /* -------------------------------------------- */

  /**
   * Handle adding a new entry to the consumption list.
   * @this {ActivitySheet}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static #addConsumption(event, target) {
    const types = this.activity.validConsumptionTypes;
    const existingTypes = new Set(this.activity.consumption.targets.map(t => t.type));
    const filteredTypes = types.difference(existingTypes);
    let type = filteredTypes.first() ?? types.first();
    if ( (type === "activityUses") && !this.activity.uses.max && this.activity.item.system.uses.max
      && filteredTypes.has("itemUses") ) type="itemUses";
    this.activity.update({
      "consumption.targets": [
        ...this.activity.toObject().consumption.targets,
        { type }
      ]
    });
  }

  /* -------------------------------------------- */

  /**
   * Handle adding a new entry to the damage parts list.
   * @this {ActivitySheet}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static #addDamagePart(event, target) {
    if ( !this.activity.damage?.parts ) return;
    this.activity.update({ "damage.parts": [...this.activity.toObject().damage.parts, {}] });
  }

  /* -------------------------------------------- */

  /**
   * Handle creating a new active effect and adding it to the applied effects list.
   * @this {ActivitySheet}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static async #addEffect(event, target) {
    if ( !this.activity.effects ) return;
    const effectData = this._addEffectData();
    const [created] = await this.item.createEmbeddedDocuments("ActiveEffect", [effectData], { render: false });
    this.activity.update({ effects: [...this.activity.toObject().effects, { _id: created.id }] });
  }

  /* -------------------------------------------- */

  /**
   * The data for a newly created applied effect.
   * @returns {object}
   * @protected
   */
  _addEffectData() {
    return {
      name: this.item.name,
      img: this.item.img,
      origin: this.item.uuid,
      transfer: false
    };
  }

  /* -------------------------------------------- */

  /**
   * Handle adding a new entry to the uses recovery list.
   * @this {ActivitySheet}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static #addRecovery(event, target) {
    const periods = new Set(
      Object.entries(CONFIG.DND5E.limitedUsePeriods).filter(([, config]) => !config.deprecated).map(([k]) => k)
    );
    const existingPeriods = new Set(this.activity.uses.recovery.map(t => t.period));
    const filteredPeriods = periods.difference(existingPeriods);
    this.activity.update({
      "uses.recovery": [
        ...this.activity.toObject().uses.recovery,
        { period: filteredPeriods.first() ?? periods.first() }
      ]
    });
  }

  /* -------------------------------------------- */

  /**
   * Handle removing an entry from the consumption targets list.
   * @this {ActivitySheet}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static #deleteConsumption(event, target) {
    const consumption = this.activity.toObject().consumption.targets;
    consumption.splice(target.closest("[data-index]").dataset.index, 1);
    this.activity.update({ "consumption.targets": consumption });
  }

  /* -------------------------------------------- */

  /**
   * Handle removing an entry from the damage parts list.
   * @this {ActivitySheet}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static #deleteDamagePart(event, target) {
    if ( !this.activity.damage?.parts ) return;
    const parts = this.activity.toObject().damage.parts;
    parts.splice(target.closest("[data-index]").dataset.index, 1);
    this.activity.update({ "damage.parts": parts });
  }

  /* -------------------------------------------- */

  /**
   * Handle deleting an active effect and removing it from the applied effects list.
   * @this {ActivitySheet}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static async #deleteEffect(event, target) {
    if ( !this.activity.effects ) return;
    const effectId = target.closest("[data-effect-id]")?.dataset.effectId;
    const result = await this.item.effects.get(effectId)?.deleteDialog({}, { render: false });
    if ( result instanceof ActiveEffect ) {
      const effects = this.activity.toObject().effects.filter(e => e._id !== effectId);
      this.activity.update({ effects });
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle removing an entry from the uses recovery list.
   * @this {ActivitySheet}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static #deleteRecovery(event, target) {
    const recovery = this.activity.toObject().uses.recovery;
    recovery.splice(target.closest("[data-index]").dataset.index, 1);
    this.activity.update({ "uses.recovery": recovery });
  }

  /* -------------------------------------------- */

  /**
   * Handle dissociating an Active Effect from this Activity.
   * @this {ActivitySheet}
   * @param {PointerEvent} event  The triggering click event.
   * @param {HTMLElement} target  The button that was clicked.
   */
  static #dissociateEffect(event, target) {
    const { effectId } = target.closest("[data-effect-id]")?.dataset ?? {};
    if ( !this.activity.effects || !effectId ) return;
    const effects = this.activity.toObject().effects.filter(e => e._id !== effectId);
    this.activity.update({ effects });
  }

  /* -------------------------------------------- */
  /*  Form Handling                               */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _prepareSubmitData(event, formData) {
    const submitData = super._prepareSubmitData(event, formData);
    for ( const keyPath of this.constructor.CLEAN_ARRAYS ) {
      const data = foundry.utils.getProperty(submitData, keyPath);
      if ( data ) foundry.utils.setProperty(submitData, keyPath, Object.values(data));
    }
    if ( foundry.utils.hasProperty(submitData, "appliedEffects") ) {
      const effects = submitData.effects ?? this.activity.toObject().effects;
      submitData.effects = effects.filter(e => submitData.appliedEffects.includes(e._id));
      for ( const _id of submitData.appliedEffects ) {
        if ( submitData.effects.find(e => e._id === _id) ) continue;
        submitData.effects.push({ _id });
      }
      delete submitData.appliedEffects;
    }
    return submitData;
  }
}

const { BooleanField: BooleanField$O, NumberField: NumberField$Q, StringField: StringField$1k } = foundry.data.fields;

/**
 * @import { ActivityUseConfiguration } from "../../documents/activity/_types.mjs";
 */

/**
 * Dialog for configuring the usage of an activity.
 */
class ActivityUsageDialog extends Dialog5e {
  constructor(options={}) {
    super(options);
    this.#activityId = options.activity.id;
    this.#item = options.activity.item;
    this.#config = options.config;
  }

  /* -------------------------------------------- */

  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["activity-usage"],
    actions: {
      use: ActivityUsageDialog.#onUse
    },
    activity: null,
    button: {
      icon: null,
      label: null
    },
    config: null,
    display: {
      all: true
    },
    form: {
      handler: ActivityUsageDialog.#onSubmitForm,
      submitOnChange: true
    },
    position: {
      width: 420
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    scaling: {
      template: "systems/dnd5e/templates/activity/activity-usage-scaling.hbs"
    },
    concentration: {
      template: "systems/dnd5e/templates/activity/activity-usage-concentration.hbs"
    },
    consumption: {
      template: "systems/dnd5e/templates/activity/activity-usage-consumption.hbs"
    },
    creation: {
      template: "systems/dnd5e/templates/activity/activity-usage-creation.hbs"
    },
    footer: {
      template: "templates/generic/form-footer.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * ID of the activity being activated.
   * @type {Activity}
   */
  #activityId;

  /**
   * Activity being activated.
   * @type {Activity}
   */
  get activity() {
    return this.item.system.activities.get(this.#activityId);
  }

  /* -------------------------------------------- */

  /**
   * Actor using this activity.
   * @type {Actor5e}
   */
  get actor() {
    return this.item.actor;
  }

  /* -------------------------------------------- */

  /**
   * Activity usage configuration data.
   * @type {ActivityUseConfiguration}
   */
  #config;

  get config() {
    return this.#config;
  }

  /* -------------------------------------------- */

  /**
   * Item that contains the activity.
   * @type {Item5e}
   */
  #item;

  get item() {
    return this.#item;
  }

  /* -------------------------------------------- */

  /** @override */
  get title() {
    return this.item.name;
  }

  /* -------------------------------------------- */

  /** @override */
  get subtitle() {
    return this.activity.name;
  }

  /* -------------------------------------------- */

  /**
   * Was the use button clicked?
   * @type {boolean}
   */
  #used = false;

  get used() {
    return this.#used;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _configureRenderOptions(options) {
    super._configureRenderOptions(options);
    if ( options.isFirstRender ) options.window.icon ||= this.activity.img;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    if ( "scaling" in this.config ) {
      this.#item = this.#item.clone({ "flags.dnd5e.scaling": this.config.scaling }, { keepId: true });
    }
    return {
      ...await super._prepareContext(options),
      activity: this.activity,
      linkedActivity: this.config.cause ? this.activity.getLinkedActivity(this.config.cause.activity) : null
    };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    switch ( partId ) {
      case "concentration": return this._prepareConcentrationContext(context, options);
      case "consumption": return this._prepareConsumptionContext(context, options);
      case "creation": return this._prepareCreationContext(context, options);
      case "scaling": return this._prepareScalingContext(context, options);
    }
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare rendering context for the concentration section.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {Promise<ApplicationRenderContext>}
   * @protected
   */
  async _prepareConcentrationContext(context, options) {
    if ( !this.activity.requiresConcentration || game.settings.get("dnd5e", "disableConcentration")
      || !this._shouldDisplay("concentration") ) return context;
    context.hasConcentration = true;
    context.notes = [];

    context.fields = [{
      field: new BooleanField$O({ label: game.i18n.localize("DND5E.Concentration") }),
      name: "concentration.begin",
      value: this.config.concentration?.begin,
      input: context.inputs.createCheckboxInput
    }];
    if ( this.config.concentration?.begin ) {
      const existingConcentration = Array.from(this.actor.concentration.effects).map(effect => {
        const data = effect.getFlag("dnd5e", "item");
        return {
          value: effect.id,
          label: data?.data?.name ?? this.actor.items.get(data?.id)?.name
            ?? game.i18n.localize("DND5E.ConcentratingItemless")
        };
      });
      if ( existingConcentration.length ) {
        const optional = existingConcentration.length < (this.actor.system.attributes?.concentration?.limit ?? 0);
        context.fields.push({
          field: new StringField$1k({
            required: true, label: game.i18n.localize("DND5E.ConcentratingEnd"), blank: optional
          }),
          name: "concentration.end",
          value: this.config.concentration?.end,
          options: optional ? [{ value: "", label: "—" }, ...existingConcentration] : existingConcentration
        });
        context.notes.push({
          type: "info", message: game.i18n.localize(`DND5E.ConcentratingWarnLimit${optional ? "Optional" : ""}`)
        });
      } else if ( !this.actor.system.attributes?.concentration?.limit ) {
        context.notes.push({
          type: "warn", message: game.i18n.localize("DND5E.ConcentratingWarnLimitZero")
        });
      }
    }

    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare rendering context for the consumption section.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {Promise<ApplicationRenderContext>}
   * @protected
   */
  async _prepareConsumptionContext(context, options) {
    context.fields = [];
    context.notes = [];

    const activationConfig = CONFIG.DND5E.activityActivationTypes[this.activity.activation.type];
    if ( activationConfig?.consume && this._shouldDisplay("consume.action") ) {
      const { property } = activationConfig.consume;
      const containsConsumption = this.activity.consumption.targets.find(t => {
        return (t.type === "attribute") && (t.target === `${property}.value`);
      });
      const current = foundry.utils.getProperty(this.actor.system, property);
      if ( current && !containsConsumption ) {
        const plurals = new Intl.PluralRules(game.i18n.lang);
        const value = (this.config.consume !== false) && (this.config.consume?.action !== false);
        const warn = (current.value < this.activity.activation.value) && value;
        context.fields.push({
          value, warn,
          field: new BooleanField$O({
            label: game.i18n.format("DND5E.CONSUMPTION.Type.Action.Prompt", {
              type: activationConfig.label
            }),
            hint: game.i18n.format("DND5E.CONSUMPTION.Type.Action.PromptHint", {
              available: game.i18n.format(`${activationConfig.counted}.${plurals.select(current.value)}`, {
                number: `<strong>${formatNumber(current.value)}</strong>`
              }),
              cost: game.i18n.format(`${activationConfig.counted}.${plurals.select(this.activity.activation.value)}`, {
                number: `<strong>${formatNumber(this.activity.activation.value)}</strong>`
              })
            })
          }),
          input: context.inputs.createCheckboxInput,
          name: "consume.action"
        });
      }
    }

    if ( this.activity.requiresSpellSlot && this.activity.consumption.spellSlot
      && this._shouldDisplay("consume.spellSlot") && !this.config.cause ) context.fields.push({
      field: new BooleanField$O({ label: game.i18n.localize("DND5E.SpellCastConsume") }),
      input: context.inputs.createCheckboxInput,
      name: "consume.spellSlot",
      value: this.config.consume?.spellSlot
    });

    if ( this._shouldDisplay("consume.resources") ) {
      const addResources = (targets, keyPath) => {
        const consume = foundry.utils.getProperty(this.config, keyPath);
        const isArray = foundry.utils.getType(consume) === "Array";
        for ( const [index, target] of targets.entries() ) {
          const value = (isArray && consume.includes(index))
            || (!isArray && (consume !== false) && (this.config.consume !== false));
          const { label, hint, notes, warn } = target.getConsumptionLabels(this.config, value);
          if ( notes?.length ) context.notes.push(...notes);
          context.fields.push({
            field: new BooleanField$O({ label, hint }),
            input: context.inputs.createCheckboxInput,
            name: `${keyPath}.${index}`,
            value,
            warn: value ? warn : false
          });
        }
      };
      addResources(this.activity.consumption.targets, "consume.resources");
      if ( context.linkedActivity && (!this.activity.isSpell || this.activity.consumption.spellSlot) ) {
        addResources(context.linkedActivity.consumption.targets, "cause.resources");
      }
    }

    context.hasConsumption = context.fields.length > 0;

    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare rendering context for the creation section.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {Promise<ApplicationRenderContext>}
   * @protected
   */
  async _prepareCreationContext(context, options) {
    context.hasCreation = false;
    if ( this.activity.target?.template?.type && this._shouldDisplay("create.measuredTemplate") ) {
      context.hasCreation = true;
      context.template = {
        field: new BooleanField$O({ label: game.i18n.localize("DND5E.TARGET.Action.PlaceTemplate") }),
        name: "create.measuredTemplate",
        value: this.config.create?.measuredTemplate
      };
    }
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare rendering context for the footer.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {Promise<ApplicationRenderContext>}
   * @protected
   */
  async _prepareFooterContext(context, options) {
    context.buttons = [{
      action: "use",
      icon: this.options.button.icon ?? `fa-solid fa-${this.activity.isSpell ? "magic" : "fist-raised"}`,
      label: this.options.button.label ?? `DND5E.AbilityUse${this.activity.isSpell ? "Cast" : "Use"}`,
      type: "button"
    }];
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare rendering context for the scaling section.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {Promise<ApplicationRenderContext>}
   * @protected
   */
  async _prepareScalingContext(context, options) {
    context.hasScaling = true;
    context.notes = [];
    if ( !this._shouldDisplay("scaling") ) {
      context.hasScaling = false;
      return context;
    }

    const scale = (context.linkedActivity ?? this.activity).consumption.scaling;
    const rollData = (context.linkedActivity ?? this.activity).getRollData({ deterministic: true });

    if ( this.activity.requiresSpellSlot && context.linkedActivity && (this.config.scaling !== false) ) {
      const max = simplifyBonus(scale.max, rollData);
      const minimumLevel = context.linkedActivity.spell?.level ?? this.item.system.level ?? 1;
      const maximumLevel = scale.allowed ? scale.max ? minimumLevel + max - 1 : Infinity : minimumLevel;
      const spellSlotOptions = Object.entries(CONFIG.DND5E.spellLevels).map(([level, label]) => {
        if ( (Number(level) < minimumLevel) || (Number(level) > maximumLevel) ) return null;
        return { value: `spell${level}`, label };
      }).filter(_ => _);
      context.spellSlots = {
        field: new StringField$1k({ required: true, blank: false, label: game.i18n.localize("DND5E.SpellCastUpcast") }),
        name: "spell.slot",
        value: this.config.spell?.slot,
        options: spellSlotOptions
      };
    }

    else if ( this.activity.requiresSpellSlot && (this.config.scaling !== false) ) {
      const minimumLevel = this.item.system.level ?? 1;
      const maximumLevel = Object.values(this.actor.system.spells)
        .reduce((max, d) => d.max ? Math.max(max, d.level) : max, 0);
      const spellMethod = CONFIG.DND5E.spellcasting[this.item.system.method];

      const consumeSlot = (this.config.consume === true) || this.config.consume?.spellSlot;
      let spellSlotValue = this.actor.system.spells[this.config.spell?.slot]?.value || !consumeSlot
        ? this.config.spell.slot : null;
      const spellSlotOptions = Object.entries(this.actor.system.spells).map(([value, slot]) => {
        if ( !slot.max || (slot.level < minimumLevel) || (slot.level > maximumLevel) || !slot.type ) return null;
        if ( spellMethod?.exclusive.spells && (this.item.system.method !== slot.type) ) return null;
        const model = CONFIG.DND5E.spellcasting[slot.type];
        if ( model?.exclusive.slots && (this.item.system.method !== slot.type) ) return null;
        const label = game.i18n.format(`DND5E.SpellLevel${slot.type.capitalize()}`, {
          level: model?.isSingleLevel ? slot.level : slot.label,
          n: slot.value
        });
        // Set current value if applicable.
        const disabled = (slot.value === 0) && consumeSlot;
        if ( !disabled && !spellSlotValue ) spellSlotValue = value;
        return { value, label, disabled, selected: spellSlotValue === value };
      }).filter(_ => _);

      context.spellSlots = {
        field: new StringField$1k({ required: true, blank: false, label: game.i18n.localize("DND5E.SpellCastUpcast") }),
        name: "spell.slot",
        value: spellSlotValue,
        options: spellSlotOptions
      };

      if ( !spellSlotOptions.some(o => !o.disabled) ) context.notes.push({
        type: "warn", message: game.i18n.format("DND5E.SpellCastNoSlotsLeft", {
          name: this.item.name
        })
      });
    }

    else if ( scale.allowed && (this.config.scaling !== false) ) {
      const max = scale.max ? simplifyBonus(scale.max, rollData) : Infinity;
      if ( max > 1 ) context.scaling = {
        field: new NumberField$Q({ min: 1, max, label: game.i18n.localize("DND5E.ScalingValue") }),
        name: "scalingValue",
        // Config stores the scaling increase, but scaling value (increase + 1) is easier to understand in the UI
        value: Math.clamp((this.config.scaling ?? 0) + 1, 1, max),
        max,
        showRange: max <= 20
      };
      else context.hasScaling = false;
    }

    else {
      context.hasScaling = false;
    }

    return context;
  }

  /* -------------------------------------------- */

  /**
   * Determine whether a particular element should be displayed based on the `display` options.
   * @param {string} section  Key path describing the section to be displayed.
   * @returns {boolean}
   */
  _shouldDisplay(section) {
    const display = this.options.display;
    if ( foundry.utils.hasProperty(display, section) ) return foundry.utils.getProperty(display, section);
    const [group] = section.split(".");
    if ( (group !== section) && (group in display) ) return display[group];
    return this.options.display.all ?? true;
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle form submission.
   * @this {ActivityUsageDialog}
   * @param {SubmitEvent} event          Triggering submit event.
   * @param {HTMLFormElement} form       The form that was submitted.
   * @param {FormDataExtended} formData  Data from the submitted form.
   */
  static async #onSubmitForm(event, form, formData) {
    const submitData = await this._prepareSubmitData(event, formData);
    await this._processSubmitData(event, submitData);
  }

  /* -------------------------------------------- */

  /**
   * Handle clicking the use button.
   * @this {ActivityUsageDialog}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static async #onUse(event, target) {
    const formData = new foundry.applications.ux.FormDataExtended(this.element.querySelector("form"));
    const submitData = await this._prepareSubmitData(event, formData);
    foundry.utils.mergeObject(this.#config, submitData);
    this.#used = true;
    this.close();
  }

  /* -------------------------------------------- */

  /**
   * Perform any pre-processing of the form data to prepare it for updating.
   * @param {SubmitEvent} event          Triggering submit event.
   * @param {FormDataExtended} formData  Data from the submitted form.
   * @returns {Promise<object>}
   */
  async _prepareSubmitData(event, formData) {
    const submitData = foundry.utils.expandObject(formData.object);
    if ( foundry.utils.hasProperty(submitData, "spell.slot") ) {
      submitData.spell.slot ||= this.#config.spell?.slot;
      const level = this.actor.system.spells?.[submitData.spell.slot]?.level ?? 0;
      submitData.scaling = Math.max(0, level - this.item.system.level);
    } else if ( "scalingValue" in submitData ) {
      submitData.scaling = submitData.scalingValue - 1;
      delete submitData.scalingValue;
    }
    for ( const key of ["consume", "cause"] ) {
      if ( foundry.utils.getType(submitData[key]?.resources) === "Object" ) {
        submitData[key].resources = filteredKeys(submitData[key].resources).map(i => Number(i));
      }
    }
    return submitData;
  }

  /* -------------------------------------------- */

  /**
   * Handle updating the usage configuration based on processed submit data.
   * @param {SubmitEvent} event  Triggering submit event.
   * @param {object} submitData  Prepared object for updating.
   */
  async _processSubmitData(event, submitData) {
    foundry.utils.mergeObject(this.#config, submitData);
    this.render();
  }

  /* -------------------------------------------- */
  /*  Factory Methods                             */
  /* -------------------------------------------- */

  /**
   * Display the activity usage dialog.
   * @param {Activity} activity                Activity to use.
   * @param {ActivityUseConfiguration} config  Configuration data for the usage.
   * @param {object} options                   Additional options for the application.
   * @returns {Promise<object|null>}           Form data object with results of the activation.
   */
  static async create(activity, config, options) {
    if ( !activity.item.isOwned ) throw new Error("Cannot activate an activity that is not owned.");

    return new Promise((resolve, reject) => {
      const dialog = new this({ activity, config, ...options });
      dialog.addEventListener("close", event => {
        if ( dialog.used ) resolve(dialog.config);
        else reject();
      }, { once: true });
      dialog.render({ force: true });
    });
  }
}

/**
 * A helper class for building MeasuredTemplates for 5e spells and abilities
 */
class AbilityTemplate extends foundry.canvas.placeables.MeasuredTemplate {

  /**
   * Track the timestamp when the last mouse move event was captured.
   * @type {number}
   */
  #moveTime = 0;

  /* -------------------------------------------- */

  /**
   * Current token that is highlighted when using adjusted size template.
   * @type {Token5e}
   */
  #hoveredToken;

  /* -------------------------------------------- */

  /**
   * 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 Activity instance.
   * @param {Activity} activity         The Activity for which to construct the template.
   * @param {object} [options={}]       Options to modify the created template.
   * @returns {AbilityTemplate[]|null}  The template objects, or null if the item does not produce a template.
   */
  static fromActivity(activity, options={}) {
    const target = activity.target?.template ?? {};
    const templateShape = dnd5e.config.areaTargetTypes[target.type]?.template;
    if ( !templateShape ) return null;

    // Prepare template data
    const rollData = activity.getRollData();
    const templateData = foundry.utils.mergeObject({
      t: templateShape,
      user: game.user.id,
      distance: target.size,
      direction: 0,
      x: 0,
      y: 0,
      fillColor: game.user.color,
      flags: { dnd5e: {
        dimensions: {
          size: target.size,
          width: target.width,
          height: target.height,
          adjustedSize: target.type === "radius"
        },
        item: activity.item.uuid,
        origin: activity.uuid,
        spellLevel: rollData.item.level
      } }
    }, options);

    // Additional type-specific data
    switch ( templateShape ) {
      case "cone":
        templateData.angle = CONFIG.MeasuredTemplate.defaults.angle;
        break;
      case "rect": // 5e rectangular AoEs are always cubes
        templateData.width = target.size;
        if ( game.settings.get("dnd5e", "gridAlignedSquareTemplates") ) {
          templateData.distance = Math.hypot(target.size, target.size);
          templateData.direction = 45;
        } else {
          // Override as 'ray' to make the template able to be rotated without morphing its shape
          templateData.t = "ray";
        }
        break;
      case "ray": // 5e rays are most commonly 1 square (5 ft) in width
        templateData.width = target.width ?? canvas.dimensions.distance;
        break;
    }

    /**
     * A hook event that fires before a template is created for an Activity.
     * @function dnd5e.preCreateActivityTemplate
     * @memberof hookEvents
     * @param {Activity} activity    Activity for which the template is being placed.
     * @param {object} templateData  Data used to create the new template.
     * @returns {boolean}            Explicitly return `false` to prevent the template from being placed.
     */
    if ( Hooks.call("dnd5e.preCreateActivityTemplate", activity, templateData) === false ) return null;

    // Construct the templates from activity data
    const cls = CONFIG.MeasuredTemplate.documentClass;
    const created = Array.fromRange(target.count || 1).map(() => {
      const template = new cls(foundry.utils.deepClone(templateData), { parent: canvas.scene });
      const object = new this(template);
      object.activity = activity;
      object.item = activity.item;
      object.actorSheet = activity.actor?.sheet || null;
      return object;
    });

    /**
     * A hook event that fires after a template are created for an Activity.
     * @function dnd5e.createActivityTemplate
     * @memberof hookEvents
     * @param {Activity} activity            Activity for which the template is being placed.
     * @param {AbilityTemplate[]} templates  The templates being placed.
     */
    Hooks.callAll("dnd5e.createActivityTemplate", activity, created);

    return created;
  }

  /* -------------------------------------------- */

  /**
   * 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("mouseup", 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("mouseup", this.#events.confirm);
    canvas.app.view.oncontextmenu = null;
    canvas.app.view.onwheel = null;
    if ( this.#hoveredToken ) {
      this.#hoveredToken._onHoverOut(event);
      this.#hoveredToken = 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 updates = this.getSnappedPosition(center);

    // Adjust template size to take hovered token into account if `adjustedSize` is set
    const baseDistance = this.document.flags.dnd5e?.dimensions?.size;
    if ( this.document.flags.dnd5e?.dimensions?.adjustedSize && baseDistance ) {
      const rectangle = new PIXI.Rectangle(center.x, center.y, 1, 1);
      const hoveredToken = canvas.tokens.quadtree.getObjects(rectangle, {
        collisionTest: ({ t }) => t.visible && !t.document.isSecret }).first();
      if ( hoveredToken && (hoveredToken !== this.#hoveredToken) ) {
        this.#hoveredToken = hoveredToken;
        this.#hoveredToken._onHoverIn(event);
        const size = Math.max(hoveredToken.document.width, hoveredToken.document.height);
        updates.distance = baseDistance + (size * canvas.grid.distance / 2);
      } else if ( !hoveredToken && this.#hoveredToken ) {
        this.#hoveredToken._onHoverOut(event);
        this.#hoveredToken = null;
        updates.distance = baseDistance;
      }
    }

    this.document.updateSource(updates);
    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 ( this.document.t === "rect" ) return;
    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 destination = canvas.templates.getSnappedPoint({ x: this.document.x, y: this.document.y });
    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();
  }

}

/**
 * @import { MappingFieldInitialValueBuilder, MappingFieldOptions } from "./_types.mjs";
 */

/**
 * 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;
    model.parent = this;
  }

  /* -------------------------------------------- */

  /** @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]) => {
      if ( k.startsWith("-=") ) return;
      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 failure = this._validateValues(value, options);
    if ( failure ) {
      const isV13 = game.release.generation < 14;
      const empty = isV13 ? failure.isEmpty() : failure.empty;
      if ( !empty ) {
        if ( isV13 ) throw failure.asError();
        throw failure;
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Validate each value of the object.
   * @param {object} value     The object to validate.
   * @param {object} options   Validation options.
   * @returns {DataModelValidationFailure|void}
   */
  _validateValues(value, { phase: _phase, ...options }={}) {
    const isV13 = game.release.generation < 14;
    const failure = isV13
      ? new foundry.data.validation.DataModelValidationFailure()
      : new foundry.data.validation.DataModelValidationError();
    for ( const [k, v] of Object.entries(value) ) {
      if ( k.startsWith("-=") ) continue;
      const error = this.model.validate(v, { ...options, strict: false, recursive: true });
      if ( error ) {
        failure.fields[k] = error;
        if ( error.unresolved ) failure.unresolved = true;
      }
    }
    const empty = isV13 ? failure.isEmpty() : failure.empty;
    if ( !empty ) return failure;
  }

  /* -------------------------------------------- */

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

  /* -------------------------------------------- */

  /**
   * Migrate this field's candidate source data.
   * @param {object} sourceData   Candidate source data of the root model
   * @param {any} fieldData       The value of this field within the source data
   */
  migrateSource(sourceData, fieldData) {
    if ( !(this.model.migrateSource instanceof Function) ) return;
    if ( foundry.utils.getType(fieldData) !== "Object" ) return;
    for ( const entry of Object.values(fieldData) ) this.model.migrateSource(sourceData, entry);
  }
}

const { ArrayField: ArrayField$o, NumberField: NumberField$P, ObjectField: ObjectField$2, SchemaField: SchemaField$10, StringField: StringField$1j } = foundry.data.fields;

/**
 * @import { ActorDeltasData, DeltaDisplayContext, IndividualDeltaData } from "./_types.mjs";
 */

/**
 * A field for storing deltas made to an actor or embedded items.
 */
class ActorDeltasField extends SchemaField$10 {
  constructor() {
    super({
      actor: new ArrayField$o(new IndividualDeltaField()),
      created: new ArrayField$o(new StringField$1j(), { required: false }),
      deleted: new ArrayField$o(new ObjectField$2(), { required: false }),
      item: new MappingField(new ArrayField$o(new IndividualDeltaField()))
    });
  }

  /* -------------------------------------------- */

  /**
   * Calculate delta information for an actor document from the given updates.
   * @param {Actor5e} actor                    Actor for which to calculate the deltas.
   * @param {ActorUpdatesDescription} updates  Updates to apply to the actor and contained items.
   * @returns {Partial<ActorDeltasData>}
   */
  static getDeltas(actor, updates) {
    const deltas = {
      actor: IndividualDeltaField.getDeltas(actor, updates.actor),
      created: updates.create?.length ? Array.from(updates.create) : null,
      deleted: updates.delete?.map(i => actor.items.get(i)?.toObject()).filter(_ => _),
      item: updates.item.reduce((obj, { _id, ...changes }) => {
        const deltas = IndividualDeltaField.getDeltas(actor.items.get(_id), changes);
        if ( deltas.length ) obj[_id] = deltas;
        return obj;
      }, {})
    };
    for ( const [k, v] of Object.entries(deltas) ) {
      if ( foundry.utils.isEmpty(v) ) delete deltas[k];
    }
    return deltas;
  }

  /* -------------------------------------------- */

  /**
   * Prepare deltas for display in a chat message.
   * @this {ActorDeltasData}
   * @param {Actor5e} actor   Actor to which this delta applies.
   * @param {Roll[]} [rolls]  Rolls that may be associated with a delta.
   * @returns {DeltaDisplayContext[]}
   */
  static processDeltas(actor, rolls=[]) {
    return [
      ...this.actor.map(d => IndividualDeltaField.processDelta.call(d, actor, rolls
        .filter(r => !r.options.delta?.item && (r.options.delta?.keyPath === d.keyPath)))),
      ...Object.entries(this.item).flatMap(([id, deltas]) =>
        deltas.map(d => IndividualDeltaField.processDelta.call(d, actor.items.get(id), rolls
          .filter(r => (r.options.delta?.item === id) && (r.options.delta?.keyPath === d.keyPath))))
      ),
      ...(this.created?.map(id => {
        const item = actor.items.get(id);
        return item ? { document: item, label: item.name, operation: "create", type: "item" } : null;
      }).filter(_ => _) ?? []),
      ...(this.deleted?.map(data => ({ label: data.name, operation: "delete", type: "item" })) ?? [])
    ];
  }
}

/**
 * A field that stores a delta for an individual property on an actor or item.
 */
class IndividualDeltaField extends SchemaField$10 {
  constructor() {
    super({ delta: new NumberField$P(), keyPath: new StringField$1j() });
  }

  /* -------------------------------------------- */

  /**
   * Calculate delta information for a document from the given updates.
   * @param {DataModel} dataModel      Document for which to calculate the deltas.
   * @param {object} updates           Updates that are to be applied.
   * @returns {IndividualDeltaData[]}
   */
  static getDeltas(dataModel, updates) {
    updates = foundry.utils.flattenObject(updates);
    const deltas = [];
    for ( const [keyPath, value] of Object.entries(updates) ) {
      let currentValue;
      if ( keyPath.startsWith("system.activities") ) {
        const [id, ...kp] = keyPath.slice(18).split(".");
        currentValue = foundry.utils.getProperty(dataModel.system.activities?.get(id) ?? {}, kp.join("."));
      }
      else currentValue = foundry.utils.getProperty(dataModel, keyPath);

      const delta = value - currentValue;
      if ( delta && !Number.isNaN(delta) ) deltas.push({ keyPath, delta });
    }
    return deltas;
  }

  /* -------------------------------------------- */

  /**
   * Prepare a delta for display in a chat message.
   * @this {IndividualDeltaData}
   * @param {Actor5e|Item5e} doc  Actor or item to which this delta applies.
   * @param {Roll[]} [rolls]      Rolls that may be associated with a delta.
   * @returns {DeltaDisplayContext}
   */
  static processDelta(doc, rolls=[]) {
    const type = doc instanceof Actor ? "actor" : "item";
    const value = this.keyPath.endsWith(".spent") ? -this.delta : this.delta;
    return {
      type,
      delta: formatNumber(value, { signDisplay: "always" }),
      document: doc,
      label: getHumanReadableAttributeLabel(this.keyPath, { [type]: doc }) ?? this.keyPath,
      operation: "update",
      rolls: rolls.map(roll => ({ roll, anchor: roll.toAnchor().outerHTML.replace(`${roll.total}</a>`, "</a>") }))
    };
  }
}

/**
 * Mixin used to add support for registering documents in the dependents registry.
 * @template {foundry.abstract.Document} T
 * @param {typeof T} Base  The base document class to wrap.
 * @returns {typeof DependentDocument}
 * @mixin
 */
function DependentDocumentMixin(Base) {
  class DependentDocument extends Base {
    /** @inheritDoc */
    prepareData() {
      super.prepareData();
      if ( this.flags?.dnd5e?.dependentOn && this.uuid ) {
        dnd5e.registry.dependents.track(this.flags.dnd5e.dependentOn, this);
      }
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    _onDelete(options, userId) {
      super._onDelete(options, userId);
      if ( this.flags?.dnd5e?.dependentOn && this.uuid ) {
        dnd5e.registry.dependents.untrack(this.flags.dnd5e.dependentOn, this);
      }
    }
  }
  return DependentDocument;
}

/**
 * System specific document creation dialog with support for icons and hints for each document type.
 */
class CreateDocumentDialog extends Dialog5e {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["create-document"],
    createData: {},
    createOptions: {},
    documentType: null,
    folders: null,
    form: {
      handler: CreateDocumentDialog.#handleFormSubmission
    },
    position: {
      width: 350
    },
    types: null
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    ...super.PARTS,
    content: {
      template: "systems/dnd5e/templates/apps/document-create.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Name of type of document being created.
   * @type {string}
   */
  get documentName() {
    return this.options.documentType.documentName;
  }

  /* -------------------------------------------- */

  /**
   * Type of document being created.
   * @type {typeof Document|typeof PseudoDocument}
   */
  get documentType() {
    return this.options.documentType;
  }

  /* -------------------------------------------- */

  /**
   * The form was submitted.
   * @type {boolean}
   */
  #submitted = false;

  get submitted() {
    return this.#submitted;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @override */
  async _prepareContentContext(context, options) {
    const { pack, parent } = this.options.createOptions;

    let collection;
    if ( !parent ) {
      if ( pack ) collection = game.packs.get(pack);
      else collection = game.collections.get(this.documentName);
    }
    context.folders = this.options.folders ?? collection?._formatFolderSelectOptions() ?? [];
    context.hasFolders = !!context.folders.length;
    context.folders.unshift({ id: "", name: game.i18n.localize("DOCUMENT.Folder"), rule: true });

    context.name = this.options.createData.name;
    context.folder = this.options.createData.folder;

    context.types = [];
    context.hasTypes = false;
    const defaultType = this.options.createData.type ?? CONFIG[this.documentName]?.defaultType;
    const TYPES = this.documentType._createDialogTypes?.(parent) ?? this.documentType.TYPES;
    if ( TYPES?.length > 1 ) {
      if ( this.options.types?.length === 0 ) throw new Error("The array of sub-types to restrict to must not be empty");

      for ( const type of TYPES ) {
        if ( type === CONST.BASE_DOCUMENT_TYPE ) continue;
        if ( this.options.types && !this.options.types.includes(type) ) continue;
        const typeData = { selected: type === defaultType, type };
        if ( this.documentType._createDialogData ) {
          Object.assign(typeData, this.documentType._createDialogData(type, parent));
        } else {
          const label = CONFIG[this.documentName]?.typeLabels?.[type];
          Object.assign(typeData, {
            icon: this.documentType.getDefaultArtwork?.({ type })?.img ?? this.documentType.DEFAULT_ICON,
            label: label && game.i18n.has(label) ? game.i18n.localize(label) : type
          });
        }
        context.types.push(typeData);
      }
      if ( !context.types.length ) throw new Error("No document types were permitted to be created");

      context.types.sort((a, b) => a.label.localeCompare(b.label, game.i18n.lang));
      context.hasTypes = true;
    }

    return context;
  }

  /* -------------------------------------------- */
  /*  Life-Cycle Handlers                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onRender(context, options) {
    super._onRender(context, options);
    const folder = this.element.querySelector('[name="folder"]');
    if ( folder ) this.element.querySelector(".form-footer").prepend(folder);
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle submission of the dialog using the form buttons.
   * @this {CreateDocumentDialog}
   * @param {Event|SubmitEvent} event    The form submission event.
   * @param {HTMLFormElement} form       The submitted form.
   * @param {FormDataExtended} formData  Data from the dialog.
   */
  static async #handleFormSubmission(event, form, formData) {
    if ( !form.checkValidity() ) throw new Error(game.i18n.format("DOCUMENT.DND5E.Warning.SelectType", {
      name: game.i18n.localize(documentType.metadata.label ?? `DOCUMENT.DND5E.${documentType.documentName}`)
    }));
    foundry.utils.mergeObject(this.options.createData, formData.object);
    this.#submitted = true;
    await this.close();
  }

  /* -------------------------------------------- */
  /*  Factory Methods                             */
  /* -------------------------------------------- */

  /**
   * Prompt user for document creation.
   * @param {typeof Document|typeof PseudoDocument} documentType  Type of document to be created.
   * @param {object} [data={}]                                    Document creation data.
   * @param {DatabaseCreateOperation} [createOptions={}]          Document creation options.
   * @param {object} [dialogOptions={}]                           Options forwarded to dialog.
   * @param {object} [dialogOptions.ok={}]                        Options for the OK button.
   * @returns {Promise<Document>}
   */
  static async prompt(documentType, data={}, { folders, types, ...createOptions }={}, { ok={}, ...config }={}) {
    const label = game.i18n.localize(documentType.metadata.label ?? `DOCUMENT.DND5E.${documentType.documentName}`);
    const title = game.i18n.format("DOCUMENT.Create", { type: label });

    foundry.utils.mergeObject(config, {
      createOptions, documentType, folders, types,
      createData: data,
      window: { title }
    });
    config.buttons ??= [];
    config.buttons.unshift(foundry.utils.mergeObject({
      action: "ok", label: title, icon: "fa-solid fa-check", default: true
    }, ok));

    const { promise, resolve } = Promise.withResolvers();
    const dialog = new this(config);
    dialog.addEventListener("close", event => {
      if ( !dialog.submitted ) return;
      const { createData, createOptions } = dialog.options;
      if ( !createData.folder ) delete createData.folder;
      if ( !createData.name?.trim() ) createData.name = documentType.defaultName?.({
        type: createData.type, parent: createOptions.parent, pack: createOptions.pack
      });
      // TODO: Temp patch until advancement data is migrated (https://github.com/foundryvtt/dnd5e/issues/5782)
      else if ( documentType.documentName === "Advancement" ) createData.title = createData.name;

      createOptions.renderSheet ??= true;
      if ( foundry.utils.isSubclass(documentType, foundry.abstract.Document) ) {
        resolve(documentType.create(createData, createOptions));
      } else {
        resolve(createOptions.parent[`create${documentType.documentName}`](createData.type, createData, createOptions));
      }
    });
    dialog.render({ force: true });
    return promise;
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Handle migrating options for `createDialog` to the V13 API.
   * @param {object} createOptions
   * @param {object} dialogOptions
   */
  static migrateOptions(createOptions, dialogOptions) {
    const applicationOptions = {
      top: "position", left: "position", width: "position", height: "position", scale: "position", zIndex: "position",
      title: "window", id: "", classes: "", jQuery: ""
    };

    for ( const [k, v] of Object.entries(createOptions) ) {
      if ( k in applicationOptions ) {
        foundry.utils.logCompatibilityWarning("The ClientDocument.createDialog signature has changed. "
          + "It now accepts database operation options in its second parameter, "
          + "and options for DialogV2.prompt in its third parameter.", { since: 13, until: 15, once: true });
        const dialogOption = applicationOptions[k];
        if ( dialogOption ) foundry.utils.setProperty(dialogOptions, `${dialogOption}.${k}`, v);
        else dialogOptions[k] = v;
        delete createOptions[k];
      }
    }
  }
}

/**
 * A mixin which extends a DataModel to provide behavior shared between activities & advancements.
 * @template {DataModel} T
 * @param {typeof T} Base  The base DataModel to be mixed.
 * @returns {typeof PseudoDocument}
 * @mixin
 */
function PseudoDocumentMixin(Base) {
  class PseudoDocument extends Base {
    constructor(data, { parent=null, ...options }={}) {
      if ( parent instanceof Item ) parent = parent.system;
      super(data, { parent, ...options });
    }

    /* -------------------------------------------- */

    /**
     * Mapping of PseudoDocument UUID to the apps they should re-render.
     * @type {Map<string, Set<Application|ApplicationV2>>}
     * @internal
     */
    static _apps = new Map();

    /* -------------------------------------------- */

    /**
     * Existing sheets of a specific type for a specific document.
     * @type {Map<[PseudoDocument, typeof ApplicationV2], ApplicationV2>}
     */
    static _sheets = new Map();

    /* -------------------------------------------- */
    /*  Model Configuration                         */
    /* -------------------------------------------- */

    /**
     * Configuration information for PseudoDocuments.
     * @type {PseudoDocumentsMetadata}
     */
    get metadata() {
      return this.constructor.metadata;
    }

    /* -------------------------------------------- */

    /**
     * Configuration object that defines types.
     * @type {object}
     */
    static get documentConfig() {
      return CONFIG.DND5E[`${this.documentName.toLowerCase()}Types`];
    }

    get documentConfig() {
      return this.constructor.documentConfig;
    }

    /* -------------------------------------------- */

    /**
     * The canonical name of this PseudoDocument type, for example "Activity".
     * @type {string}
     */
    static get documentName() {
      return this.metadata.name;
    }

    get documentName() {
      return this.constructor.documentName;
    }

    /* -------------------------------------------- */
    /*  Instance Properties                         */
    /* -------------------------------------------- */

    /**
     * Unique identifier for this PseudoDocument within its item.
     * @type {string}
     */
    get id() {
      return this._id;
    }

    /* -------------------------------------------- */

    /**
     * Unique ID for this PseudoDocument on an actor.
     * @type {string}
     */
    get relativeID() {
      return `${this.item.id}.${this.id}`;
    }

    /* -------------------------------------------- */

    /**
     * Globally unique identifier for this PseudoDocument.
     * @type {string}
     */
    get uuid() {
      return `${this.item.uuid}.${this.documentName}.${this.id}`;
    }

    /* -------------------------------------------- */

    /**
     * Item to which this PseudoDocument belongs.
     * @type {Item5e}
     */
    get item() {
      return this.parent.parent;
    }

    /* -------------------------------------------- */

    /**
     * Actor to which this PseudoDocument's item belongs, if the item is embedded.
     * @type {Actor5e|null}
     */
    get actor() {
      return this.item.parent ?? null;
    }

    /* -------------------------------------------- */

    /**
     * Lazily obtain a Application instance used to configure this PseudoDocument, or null if no sheet is available.
     * @type {Application|ApplicationV2|null}
     */
    get sheet() {
      const cls = this.constructor.metadata.sheetClass ?? this.constructor.metadata.apps?.config;
      if ( !cls ) return null;
      if ( !this.constructor._sheets.has(this.uuid) ) {
        let sheet;
        if ( Application.isPrototypeOf(cls) ) sheet = new cls(this);
        else sheet = new cls({ document: this });
        this.constructor._sheets.set(this.uuid, sheet);
      }
      return this.constructor._sheets.get(this.uuid);
    }

    /* -------------------------------------------- */
    /*  Display Methods                             */
    /* -------------------------------------------- */

    /**
     * Render all the Application instances which are connected to this PseudoDocument.
     * @param {ApplicationRenderOptions} [options]  Rendering options.
     */
    render(options) {
      for ( const app of this.constructor._apps.get(this.uuid) ?? [] ) {
        app.render({ window: { title: app.title }, ...options });
      }
    }

    /* -------------------------------------------- */

    /**
     * Register an application to respond to updates to a certain document.
     * @param {PseudoDocument} doc  Pseudo document to watch.
     * @param {Application} app     Application to update.
     * @internal
     */
    static _registerApp(doc, app) {
      if ( !this._apps.has(doc.uuid) ) this._apps.set(doc.uuid, new Set());
      this._apps.get(doc.uuid).add(app);
    }

    /* -------------------------------------------- */

    /**
     * Remove an application from the render registry.
     * @param {PseudoDocument} doc  Pseudo document being watched.
     * @param {Application} app     Application to stop watching.
     */
    static _unregisterApp(doc, app) {
      this._apps.get(doc?.uuid)?.delete(app);
    }

    /* -------------------------------------------- */
    /*  Editing Methods                             */
    /* -------------------------------------------- */

    /**
     * Update this PseudoDocument.
     * @param {object} updates             Updates to apply to this PseudoDocument.
     * @param {object} [options={}]        Additional context which customizes the update workflow.
     * @returns {Promise<PseudoDocument>}  This PseudoDocument after updates have been applied.
     */
    async update(updates, options={}) {
      const result = await this.item[`update${this.documentName}`](this.id, updates, options);
      this.render();
      return result;
    }

    /* -------------------------------------------- */

    /**
     * Update this PseudoDocument's data on the item without performing a database commit.
     * @param {object} updates    Updates to apply to this PseudoDocument.
     * @returns {PseudoDocument}  This PseudoDocument after updates have been applied.
     */
    updateSource(updates) {
      super.updateSource(updates);
      return this;
    }

    /* -------------------------------------------- */

    /**
     * Delete this PseudoDocument, removing it from the database.
     * @param {object} [options={}]        Additional context which customizes the deletion workflow.
     * @returns {Promise<PseudoDocument>}  The deleted PseudoDocument instance.
     */
    async delete(options={}) {
      return await this.item[`delete${this.documentName}`](this.id, options);
    }

    /* -------------------------------------------- */

    /**
     * Present a Dialog form to confirm deletion of this PseudoDocument.
     * @param {object} [options]           Positioning and sizing options for the resulting dialog.
     * @returns {Promise<PseudoDocument>}  A Promise which resolves to the deleted PseudoDocument.
     */
    async deleteDialog(options={}) {
      const type = game.i18n.localize(this.metadata.label);
      return foundry.applications.api.Dialog.confirm({
        window: { title: `${game.i18n.format("DOCUMENT.Delete", { type })}: ${this.name || this.title}` },
        content: `<p><strong>${game.i18n.localize("AreYouSure")}</strong> ${game.i18n.format("SIDEBAR.DeleteWarning", {
          type
        })}</p>`,
        yes: { callback: this.delete.bind(this) },
        ...options
      });
    }

    /* -------------------------------------------- */

    /**
     * Serialize salient information for this PseudoDocument when dragging it.
     * @returns {object}  An object of drag data.
     */
    toDragData() {
      const dragData = { type: this.documentName, data: this.toObject() };
      if ( this.id ) dragData.uuid = this.uuid;
      return dragData;
    }

    /* -------------------------------------------- */
    /*  Importing and Exporting                     */
    /* -------------------------------------------- */

    /**
     * Spawn a dialog for creating a new Activity.
     * @param {object} [data]  Data to pre-populate the Activity with.
     * @param {object} context
     * @param {Item5e} context.parent        A parent for the Activity.
     * @param {string[]|null} [context.types]  A list of types to restrict the choices to, or null for no restriction.
     * @returns {Promise<PseudoDocument|null>}
     */
    static async createDialog(data={}, createOptions={}, dialogOptions={}) {
      CreateDocumentDialog.migrateOptions(createOptions, dialogOptions);
      return CreateDocumentDialog.prompt(this, data, createOptions, dialogOptions);
    }

    /* -------------------------------------------- */

    /**
     * Prepare the data needed for the creation dialog.
     * @param {string} type  Specific type of the PseudoDocument to prepare.
     * @param {Item5e} parent  Parent document within which this PseudoDocument will be created.
     * @returns {{ type: string, label: string, icon: string, [hint]: string, [disabled]: boolean }}
     * @protected
     */
    static _createDialogData(type, parent) {
      const label = this.documentConfig[type]?.documentClass?.metadata?.title;
      const hint = this.documentConfig[type]?.documentClass?.metadata?.hint;
      return {
        type,
        label: game.i18n.has(label) ? game.i18n.localize(label) : type,
        hint: game.i18n.has(hint) ? game.i18n.localize(hint) : null,
        icon: this.documentConfig[type]?.documentClass?.metadata?.img
      };
    }

    /* -------------------------------------------- */

    /**
     * Prepare default list of types if none are specified.
     * @param {Item5e} parent  Parent document within which this PseudoDocument will be created.
     * @returns {string[]}
     * @protected
     */
    static _createDialogTypes(parent) {
      return Object.keys(this.documentConfig);
    }
  }
  return PseudoDocument;
}

/**
 * @import { FavoriteData5e } from "../../data/abstract/_types.mjs";
 * @import { ActorDeltasData } from "../../data/chat-message/fields/_types.mjs";
 * @import {
 *   BasicRollDialogConfiguration, BasicRollMessageConfiguration, DamageRollProcessConfiguration
 * } from "../../dice/_types.mjs";
 * @import {
 *   ActivityConsumptionDescriptor, ActivityDialogConfiguration, ActivityMessageConfiguration, ActivityMetadata,
 *   ActivityUsageChatButton, ActivityUsageResults, ActivityUsageUpdates, ActivityUseConfiguration
 * } from "./_types.mjs";
 */

/**
 * Mixin used to provide base logic to all activities.
 * @template {BaseActivityData} T
 * @param {typeof T} Base  The base activity data class to wrap.
 * @returns {typeof Activity}
 * @mixin
 */
function ActivityMixin(Base) {
  class Activity extends DependentDocumentMixin(PseudoDocumentMixin(Base)) {
    /**
     * Configuration information for this PseudoDocument.
     * @type {Readonly<ActivityMetadata>}
     */
    static metadata = Object.freeze({
      name: "Activity",
      label: "DOCUMENT.DND5E.Activity",
      sheetClass: ActivitySheet,
      usage: {
        actions: {},
        chatCard: "systems/dnd5e/templates/chat/activity-card.hbs",
        dialog: ActivityUsageDialog
      }
    });

    /* -------------------------------------------- */

    /**
     * Perform the pre-localization of this data model.
     */
    static localize() {
      foundry.helpers.Localization.localizeDataModel(this);
      const fields = this.schema.fields;
      if ( fields.damage?.fields.parts ) {
        localizeSchema(fields.damage.fields.parts.element, ["DND5E.DAMAGE.FIELDS.damage.parts"]);
      }
      if ( fields.consumption ) {
        localizeSchema(fields.consumption.fields.targets.element, ["DND5E.CONSUMPTION.FIELDS.consumption.targets"]);
      }
      if ( fields.uses ) localizeSchema(fields.uses.fields.recovery.element, ["DND5E.USES.FIELDS.uses.recovery"]);
    }

    /* -------------------------------------------- */

    /**
     * Perform pre-localization on the contents of a SchemaField. Necessary because the `localizeSchema` method
     * on `Localization` is private.
     * @param {SchemaField} schema
     * @param {string[]} prefixes
     * @internal
     */
    static _localizeSchema(schema, prefixes) {
      localizeSchema(schema, prefixes);
    }

    /* -------------------------------------------- */
    /*  Properties                                  */
    /* -------------------------------------------- */

    /**
     * Should this activity be visible on the item sheet?
     * @type {boolean}
     */
    get canConfigure() {
      if ( CONFIG.DND5E.activityTypes[this.type]?.configurable === false ) return false;
      if ( this.visibility?.requireIdentification && !this.item.system.identified && !game.user.isGM ) return false;
      if ( this.dependentOrigin?.active === false ) return false;
      return true;
    }

    /* -------------------------------------------- */

    /**
     * Should this activity be able to be used?
     * @type {boolean}
     */
    get canUse() {
      if ( this.isRider ) return false;
      if ( this.dependentOrigin?.active === false ) return false;
      if ( this.visibility?.requireAttunement && !this.item.system.attuned ) return false;
      if ( this.visibility?.requireMagic && (this.item.system.magicAvailable === false) ) return false;
      if ( this.visibility?.requireIdentification && !this.item.system.identified ) return false;
      const level = this.relevantLevel;
      if ( ((this.visibility?.level?.min ?? -Infinity) > level)
        || ((this.visibility?.level?.max ?? Infinity) < level) ) return false;
      return true;
    }

    /* -------------------------------------------- */

    /**
     * Description used in chat message flavor for messages created with `rollDamage`.
     * @type {string}
     */
    get damageFlavor() {
      return game.i18n.localize("DND5E.DamageRoll");
    }

    /* -------------------------------------------- */

    /**
     * Active effect that granted this activity as a rider.
     * @type {ActiveEffect5e|null}
     */
    get dependentOrigin() {
      return this.item.effects.get(this.flags?.dnd5e?.dependentOn) ?? null;
    }

    /* -------------------------------------------- */

    /**
     * Create the data added to messages flags.
     * @type {object}
     */
    get messageFlags() {
      return {
        activity: { type: this.type, id: this.id, uuid: this.uuid },
        item: { type: this.item.type, id: this.item.id, uuid: this.item.uuid },
        targets: getTargetDescriptors()
      };
    }

    /* -------------------------------------------- */

    /**
     * Relative UUID for this activity on an actor.
     * @type {string}
     */
    get relativeUUID() {
      return `.Item.${this.item.id}.Activity.${this.id}`;
    }

    /* -------------------------------------------- */

    /**
     * Consumption targets that can be use for this activity.
     * @type {Set<string>}
     */
    get validConsumptionTypes() {
      const types = new Set(Object.keys(CONFIG.DND5E.activityConsumptionTypes));
      if ( this.isSpell ) types.delete("spellSlots");
      return types;
    }

    /* -------------------------------------------- */
    /*  Activation                                  */
    /* -------------------------------------------- */

    /**
     * Activate this activity.
     * @param {ActivityUseConfiguration} usage        Configuration info for the activation.
     * @param {ActivityDialogConfiguration} dialog    Configuration info for the usage dialog.
     * @param {ActivityMessageConfiguration} message  Configuration info for the created chat message.
     * @returns {Promise<ActivityUsageResults|void>}  Details on the usage process if not canceled.
     */
    async use(usage={}, dialog={}, message={}) {
      if ( !this.item.isEmbedded || this.item.pack ) return;
      if ( !this.item.isOwner ) {
        ui.notifications.error("DND5E.DocumentUseWarn", { localize: true });
        return;
      }
      if ( !this.canUse ) {
        ui.notifications.error("DND5E.ACTIVITY.Warning.UsageNotAllowed", { localize: true });
        return;
      }

      // Create an item clone to work with throughout the rest of the process
      let item = this.item.clone({}, { keepId: true });
      let activity = item.system.activities.get(this.id);

      const usageConfig = activity._prepareUsageConfig(usage);

      const dialogConfig = foundry.utils.mergeObject({
        configure: true,
        applicationClass: this.metadata.usage.dialog
      }, dialog);

      const messageConfig = foundry.utils.mergeObject({
        create: true,
        data: {
          flags: {
            dnd5e: {
              ...this.messageFlags,
              messageType: "usage"
            }
          }
        },
        hasConsumption: usageConfig.hasConsumption
      }, message);

      /**
       * A hook event that fires before an activity usage is configured.
       * @function dnd5e.preUseActivity
       * @memberof hookEvents
       * @param {Activity} activity                           Activity being used.
       * @param {ActivityUseConfiguration} usageConfig        Configuration info for the activation.
       * @param {ActivityDialogConfiguration} dialogConfig    Configuration info for the usage dialog.
       * @param {ActivityMessageConfiguration} messageConfig  Configuration info for the created chat message.
       * @returns {boolean}  Explicitly return `false` to prevent activity from being used.
       */
      if ( Hooks.call("dnd5e.preUseActivity", activity, usageConfig, dialogConfig, messageConfig) === false ) return;

      // Display configuration window if necessary
      if ( dialogConfig.configure && activity._requiresConfigurationDialog(usageConfig) ) {
        try {
          await dialogConfig.applicationClass.create(activity, usageConfig, dialogConfig.options);
        } catch(err) {
          return;
        }
      }

      // Handle scaling
      await activity._prepareUsageScaling(usageConfig, messageConfig, item);
      activity = item.system.activities.get(this.id);

      // Handle consumption
      const updates = await activity.consume(usageConfig, messageConfig);
      if ( updates === false ) return;
      const results = { effects: [], templates: [], updates };

      // Create concentration effect & end previous effects
      if ( usageConfig.concentration?.begin ) {
        const effect = await item.actor.beginConcentrating(activity, { "flags.dnd5e.scaling": usageConfig.scaling });
        if ( effect ) {
          results.effects ??= [];
          results.effects.push(effect);
          foundry.utils.setProperty(messageConfig.data, "flags.dnd5e.use.concentrationId", effect.id);
        }
        if ( usageConfig.concentration?.end ) {
          const deleted = await item.actor.endConcentration(usageConfig.concentration.end);
          results.effects.push(...deleted);
        }
      }

      // Create chat message
      activity._finalizeMessageConfig(usageConfig, messageConfig, results);
      results.message = await activity._createUsageMessage(messageConfig);

      // Perform any final usage steps
      await activity._finalizeUsage(usageConfig, results);

      /**
       * A hook event that fires when an activity is activated.
       * @function dnd5e.postUseActivity
       * @memberof hookEvents
       * @param {Activity} activity                     Activity being activated.
       * @param {ActivityUseConfiguration} usageConfig  Configuration data for the activation.
       * @param {ActivityUsageResults} results          Final details on the activation.
       * @returns {boolean}  Explicitly return `false` to prevent any subsequent actions from being triggered.
       */
      if ( Hooks.call("dnd5e.postUseActivity", activity, usageConfig, results) === false ) return results;

      // Trigger any primary action provided by this activity
      if ( usageConfig.subsequentActions !== false ) {
        const deltas = results.message?.flags?.dnd5e?.use?.consumed
          ?? results.message?.data?.flags?.dnd5e?.use?.consumed;
        const consumed = this.createConsumedFlag(this.actor, deltas);
        if ( consumed ) item.updateSource({ "flags.dnd5e.consumed": consumed });
        activity._triggerSubsequentActions(usageConfig, results);
      }

      return results;
    }

    /* -------------------------------------------- */

    /**
     * Consume this activation's usage.
     * @param {ActivityUseConfiguration} usageConfig        Usage configuration.
     * @param {ActivityMessageConfiguration} messageConfig  Configuration data for the chat message.
     * @returns {ActivityUsageUpdates|false}
     */
    async consume(usageConfig, messageConfig) {
      /**
       * A hook event that fires before an item's resource consumption is calculated.
       * @function dnd5e.preActivityConsumption
       * @memberof hookEvents
       * @param {Activity} activity                           Activity being activated.
       * @param {ActivityUseConfiguration} usageConfig        Configuration data for the activation.
       * @param {ActivityMessageConfiguration} messageConfig  Configuration info for the created chat message.
       * @returns {boolean}  Explicitly return `false` to prevent activity from being activated.
       */
      if ( Hooks.call("dnd5e.preActivityConsumption", this, usageConfig, messageConfig) === false ) return false;

      const updates = await this._prepareUsageUpdates(usageConfig);
      if ( !updates ) return false;

      /**
       * A hook event that fires after an item's resource consumption is calculated, but before any updates are
       * performed.
       * @function dnd5e.activityConsumption
       * @memberof hookEvents
       * @param {Activity} activity                           Activity being activated.
       * @param {ActivityUseConfiguration} usageConfig        Configuration data for the activation.
       * @param {ActivityMessageConfiguration} messageConfig  Configuration info for the created chat message.
       * @param {ActivityUsageUpdates} updates                Updates to apply to the actor and other documents.
       * @returns {boolean}  Explicitly return `false` to prevent activity from being activated.
       */
      if ( Hooks.call("dnd5e.activityConsumption", this, usageConfig, messageConfig, updates) === false ) return false;

      const consumed = await this.#applyUsageUpdates(updates);
      if ( !foundry.utils.isEmpty(consumed) ) {
        foundry.utils.setProperty(messageConfig, "data.flags.dnd5e.use.consumed", consumed);
      }
      if ( usageConfig.cause?.activity ) {
        foundry.utils.setProperty(messageConfig, "data.flags.dnd5e.use.cause", usageConfig.cause.activity);
      }

      /**
       * A hook event that fires after an item's resource consumption is calculated and applied.
       * @function dnd5e.postActivityConsumption
       * @memberof hookEvents
       * @param {Activity} activity                           Activity being activated.
       * @param {ActivityUseConfiguration} usageConfig        Configuration data for the activation.
       * @param {ActivityMessageConfiguration} messageConfig  Configuration info for the created chat message.
       * @param {ActivityUsageUpdates} updates                Applied updates to the actor and other documents.
       * @returns {boolean}  Explicitly return `false` to prevent activity from being activated.
       */
      if ( Hooks.call("dnd5e.postActivityConsumption", this, usageConfig, messageConfig, updates) === false ) return false;

      return updates;
    }

    /* -------------------------------------------- */

    /**
     * Refund previously used consumption for an activity.
     * @param {ActorDeltasData} consumed  Data on the consumption that occurred.
     */
    async refund(consumed) {
      const updates = {
        activity: {}, actor: {}, create: consumed.deleted ?? [], delete: consumed.created ?? [], item: []
      };
      for ( const { keyPath, delta } of consumed.actor ?? [] ) {
        const value = foundry.utils.getProperty(this.actor, keyPath) - delta;
        if ( !Number.isNaN(value) ) updates.actor[keyPath] = value;
      }
      for ( const [id, changes] of Object.entries(consumed.item ?? {}) ) {
        const item = this.actor.items.get(id);
        if ( !item ) continue;
        const itemUpdate = {};
        for ( const { keyPath, delta } of changes ) {
          let currentValue;
          if ( keyPath.startsWith("system.activities") ) {
            const [id, ...kp] = keyPath.slice(18).split(".");
            currentValue = foundry.utils.getProperty(item.system.activities?.get(id) ?? {}, kp.join("."));
          } else currentValue = foundry.utils.getProperty(item, keyPath);
          const value = currentValue - delta;
          if ( !Number.isNaN(value) ) itemUpdate[keyPath] = value;
        }
        if ( !foundry.utils.isEmpty(itemUpdate) ) {
          itemUpdate._id = id;
          updates.item.push(itemUpdate);
        }
      }
      await this.#applyUsageUpdates(updates);
    }

    /* -------------------------------------------- */

    /**
     * Merge activity updates into the appropriate item updates and apply.
     * @param {ActivityUsageUpdates} updates
     * @returns {ActorDeltasData}  Information on consumption performed to store in message flag.
     */
    async #applyUsageUpdates(updates) {
      this._mergeActivityUpdates(updates);

      // Ensure no existing items are created again & no non-existent items try to be deleted
      updates.create = updates.create?.filter(i => !this.actor.items.has(i._id));
      updates.delete = updates.delete?.filter(i => this.actor.items.has(i));

      // Create the consumed flag
      const consumed = ActorDeltasField.getDeltas(this.actor, updates);

      // Update documents with consumption
      if ( !foundry.utils.isEmpty(updates.actor) ) await this.actor.update(updates.actor);
      if ( !foundry.utils.isEmpty(updates.create) ) {
        await this.actor.createEmbeddedDocuments("Item", updates.create, { keepId: true });
      }
      if ( !foundry.utils.isEmpty(updates.delete) ) await this.actor.deleteEmbeddedDocuments("Item", updates.delete);
      if ( !foundry.utils.isEmpty(updates.item) ) await this.actor.updateEmbeddedDocuments("Item", updates.item);

      return consumed;
    }

    /* -------------------------------------------- */

    /**
     * Prepare usage configuration with the necessary defaults.
     * @param {ActivityUseConfiguration} config  Configuration object passed to the `use` method.
     * @returns {ActivityUseConfiguration}
     * @protected
     */
    _prepareUsageConfig(config) {
      config = foundry.utils.deepClone(config);
      const linked = this.getLinkedActivity(config.cause?.activity);

      if ( config.create !== false ) {
        config.create ??= {};
        config.create.measuredTemplate ??= !!this.target.template.type && this.target.prompt;
        // TODO: Handle permissions checks in `ActivityUsageDialog`
      }

      const ignoreLinkedConsumption = this.isSpell && !this.consumption.spellSlot;
      if ( config.consume !== false ) {
        const activationConfig = CONFIG.DND5E.activityActivationTypes[this.activation.type] ?? {};
        const hasActionConsumption = activationConfig.consume
          && (activationConfig.consume.canConsume?.(this) !== false);
        const hasResourceConsumption = this.consumption.targets.length > 0;
        const hasLinkedConsumption = (linked?.consumption.targets.length > 0) && !ignoreLinkedConsumption;
        const hasSpellSlotConsumption = this.requiresSpellSlot && this.consumption.spellSlot;
        config.consume ??= {};
        config.consume.action ??= hasActionConsumption;
        config.consume.resources ??= Array.from(this.consumption.targets.entries())
          .filter(([, target]) => !target.combatOnly || this.actor.inCombat)
          .map(([index]) => index);
        config.consume.spellSlot ??= !linked && hasSpellSlotConsumption;
        config.hasConsumption = hasActionConsumption || hasResourceConsumption || hasLinkedConsumption
          || (!linked && hasSpellSlotConsumption);
      }

      const levelingFlag = this.item.getFlag("dnd5e", "spellLevel");
      if ( levelingFlag ) {
        // Handle fixed scaling from spell scrolls
        config.scaling = false;
        config.spell ??= {};
        config.spell.slot = levelingFlag.value;
      }

      else {
        const canScale = linked ? linked.consumption.scaling.allowed : this.canScale;
        const linkedDelta = (linked?.spell?.level ?? Infinity) - this.item.system.level;
        if ( !canScale ) config.scaling = false;
        else if ( Number.isFinite(linkedDelta) ) config.scaling ??= linkedDelta;

        if ( this.requiresSpellSlot ) {
          const { level, method } = this.item.system;
          const model = CONFIG.DND5E.spellcasting[method];
          config.spell ??= {};
          config.spell.slot ??= linked?.spell?.level
            ? `spell${linked.spell.level}`
            : (model?.getSpellSlotKey(level) ?? `spell${level}`);
          const scaling = (this.actor.system.spells?.[config.spell.slot]?.level ?? 0) - this.item.system.level;
          if ( scaling > 0 ) config.scaling ??= scaling;
        }
        config.scaling ??= 0;
      }

      if ( this.requiresConcentration && !game.settings.get("dnd5e", "disableConcentration") ) {
        config.concentration ??= {};
        config.concentration.begin ??= true;
        const { effects } = this.actor.concentration;
        const limit = this.actor.system.attributes?.concentration?.limit ?? 0;
        if ( limit && (limit <= effects.size) ) config.concentration.end ??= effects.find(e => {
          const data = e.flags.dnd5e?.item?.data ?? {};
          return (data === this.id) || (data._id === this.id);
        })?.id ?? effects.first()?.id ?? null;
      }

      if ( linked ) {
        config.cause ??= {};
        config.cause.activity ??= linked.relativeUUID;
        config.cause.resources ??= (linked.consumption.targets.length > 0) && !ignoreLinkedConsumption;
      }

      return config;
    }

    /* -------------------------------------------- */

    /**
     * Determine scaling values and update item clone if necessary.
     * @param {ActivityUseConfiguration} usageConfig        Configuration data for the activation.
     * @param {ActivityMessageConfiguration} messageConfig  Configuration data for the chat message.
     * @param {Item5e} item                                 Clone of the item that contains this activity.
     * @protected
     */
    async _prepareUsageScaling(usageConfig, messageConfig, item) {
      const levelingFlag = this.item.getFlag("dnd5e", "spellLevel");
      if ( levelingFlag ) {
        usageConfig.scaling = Math.max(0, levelingFlag.value - levelingFlag.base);
      } else if ( this.isSpell ) {
        const level = this.actor.system.spells?.[usageConfig.spell?.slot]?.level;
        if ( level ) {
          usageConfig.scaling = level - item.system.level;
          foundry.utils.setProperty(messageConfig, "data.flags.dnd5e.use.spellLevel", level);
        }
      }

      if ( usageConfig.scaling ) {
        foundry.utils.setProperty(messageConfig, "data.flags.dnd5e.scaling", usageConfig.scaling);
        if ( usageConfig.scaling !== item.flags.dnd5e?.scaling ) {
          item.actor._embeddedPreparation = true;
          item.updateSource({ "flags.dnd5e.scaling": usageConfig.scaling });
          delete item.actor._embeddedPreparation;
          item.prepareFinalAttributes();
        }
      }
    }

    /* -------------------------------------------- */

    /**
     * Calculate changes to actor, items, & this activity based on resource consumption.
     * @param {ActivityUseConfiguration} config                  Usage configuration.
     * @param {object} [options={}]
     * @param {boolean} [options.returnErrors=false]             Return array of errors, rather than displaying them.
     * @returns {ActivityUsageUpdates|ConsumptionError[]|false}  Updates to perform, an array of ConsumptionErrors,
     *                                                           or `false` if a consumption error occurred.
     * @protected
     */
    async _prepareUsageUpdates(config, { returnErrors=false }={}) {
      const updates = { activity: {}, actor: {}, create: [], delete: [], item: [], rolls: [] };
      if ( config.consume === false ) return updates;
      const errors = [];

      // Handle auto consumption.
      const activationConfig = CONFIG.DND5E.activityActivationTypes[this.activation.type];
      if ( ((config.consume === true) || config.consume.action) && activationConfig?.consume ) {
        const { property } = activationConfig.consume;
        const valueProperty = `${property}.value`;
        const containsConsumption = this.consumption.targets.find(t => {
          return (t.type === "attribute") && (t.target === valueProperty);
        });
        const count = this.activation.value ?? 1;
        const current = foundry.utils.getProperty(this.actor.system, property);
        if ( current && !containsConsumption ) {
          let message;
          if ( current.value < 1 ) message = "DND5E.ACTIVATION.Warning.NoActions";
          else if ( count > current.value ) message = "DND5E.ACTIVATION.Warning.NotEnoughActions";
          if ( message ) {
            const err = new ConsumptionError(game.i18n.format(message, {
              type: activationConfig.label,
              required: formatNumber(count),
              available: formatNumber(current.value)
            }));
            errors.push(err);
          } else {
            updates.actor[`system.${property}.spent`] = current.spent + count;
          }
        }
      }

      // Handle consumption targets
      if ( (config.consume === true) || config.consume.resources ) {
        const indexes = (config.consume === true) || (config.consume.resources === true)
          ? this.consumption.targets.keys() : config.consume.resources;
        for ( const index of indexes ) {
          const target = this.consumption.targets[index];
          try {
            await target.consume(config, updates);
          } catch(err) {
            if ( err instanceof ConsumptionError ) errors.push(err);
            else throw err;
          }
        }
      }

      // Handle consumption on a linked activity
      if ( config.cause ) {
        const linkedActivity = this.getLinkedActivity(config.cause.activity);
        if ( linkedActivity ) {
          const consume = {
            resources: (config.consume === true) || (config.cause?.resources === true)
              ? linkedActivity.consumption.targets.keys() : config.cause?.resources,
            spellSlot: false
          };
          const usageConfig = foundry.utils.mergeObject(config, { consume, cause: false }, { inplace: false });
          const results = await linkedActivity._prepareUsageUpdates(usageConfig, { returnErrors: true });
          if ( foundry.utils.getType(results) === "Object" ) {
            linkedActivity._mergeActivityUpdates(results);
            foundry.utils.mergeObject(updates.actor, results.actor);
            updates.delete.push(...results.delete);
            updates.item.push(...results.item);
            updates.rolls.push(...results.rolls);
            // Mark this item for deletion if it is linked to a cast activity that will be deleted
            const otherLinkedActivity = linkedActivity.type === "forward"
              ? linkedActivity.item.system.activities.get(linkedActivity.activity.id) : linkedActivity;
            if ( updates.delete.includes(linkedActivity.item.id)
              && (this.item.getFlag("dnd5e", "cachedFor") === otherLinkedActivity?.relativeUUID) ) {
              updates.delete.push(this.item.id);
            }
          } else if ( results?.length ) {
            errors.push(...results);
          }
        }
      }

      // Handle spell slot consumption
      else if ( ((config.consume === true) || config.consume.spellSlot)
        && this.requiresSpellSlot && this.consumption.spellSlot ) {
        const spellcasting = CONFIG.DND5E.spellcasting[this.item.system.method];
        const effectiveLevel = this.item.system.level + (config.scaling ?? 0);
        const slot = config.spell?.slot ?? spellcasting?.getSpellSlotKey(effectiveLevel) ?? this.item.system.method;
        const slotData = this.actor.system.spells?.[slot];
        if ( slotData ) {
          if ( slotData.value ) {
            const newValue = Math.max(slotData.value - 1, 0);
            foundry.utils.mergeObject(updates.actor, { [`system.spells.${slot}.value`]: newValue });
          } else {
            const err = new ConsumptionError(game.i18n.format("DND5E.SpellCastNoSlots", {
              name: this.item.name, level: slotData.label
            }));
            errors.push(err);
          }
        }
      }

      // Ensure concentration can be handled
      if ( config.concentration?.begin ) {
        const { effects } = this.actor.concentration;
        // Ensure existing concentration effect exists when replacing concentration
        if ( config.concentration.end ) {
          const replacedEffect = effects.find(i => i.id === config.concentration.end);
          if ( !replacedEffect ) errors.push(
            new ConsumptionError(game.i18n.localize("DND5E.ConcentratingMissingItem"))
          );
        }

        // Cannot begin more concentrations than the limit
        else if ( effects.size >= this.actor.system.attributes?.concentration?.limit ) errors.push(
          new ConsumptionError(game.i18n.localize("DND5E.ConcentratingLimited"))
        );
      }

      if ( !returnErrors ) errors.forEach(err => ui.notifications.error(err.message, { console: false }));
      return errors.length ? returnErrors ? errors : false : updates;
    }

    /* -------------------------------------------- */

    /**
     * Determine if the configuration dialog is required based on the configuration options. Does not guarantee a dialog
     * is shown if the dialog is suppressed in the activation dialog configuration.
     * @param {ActivityUseConfiguration} config
     * @returns {boolean}
     * @protected
     */
    _requiresConfigurationDialog(config) {
      const checkObject = obj => (foundry.utils.getType(obj) === "Object")
        && Object.values(obj).some(v => v === true || v?.length);
      return config.concentration?.begin === true
        || checkObject(config.create)
        || ((checkObject(config.consume) || (config.cause?.resources === true)) && config.hasConsumption)
        || (config.scaling !== false);
    }

    /* -------------------------------------------- */

    /**
     * Prepare the context used to render the usage chat card.
     * @param {ActivityMessageConfiguration} message  Configuration info for the created message.
     * @returns {object}
     * @protected
     */
    async _usageChatContext(message) {
      const data = await this.item.system.getCardData({ activity: this });
      const properties = [...(data.tags ?? []), ...(data.properties ?? [])];
      const supplements = [];
      if ( this.activation.condition ) {
        supplements.push(`<strong>${game.i18n.localize("DND5E.Trigger")}</strong> ${this.activation.condition}`);
      }
      if ( data.materials?.value ) {
        supplements.push(`<strong>${game.i18n.localize("DND5E.Materials")}</strong> ${data.materials.value}`);
      }
      const buttons = this._usageChatButtons(message);

      // Include spell level in the subtitle.
      if ( this.item.type === "spell" ) {
        const spellLevel = foundry.utils.getProperty(message, "data.flags.dnd5e.use.spellLevel");
        const { spellLevels, spellSchools } = CONFIG.DND5E;
        data.subtitle = [spellLevels[spellLevel], spellSchools[this.item.system.school]?.label].filterJoin(" &bull; ");
      }

      return {
        activity: this,
        actor: this.item.actor,
        item: this.item,
        token: this.item.actor?.token,
        buttons: buttons.length ? buttons : null,
        description: data.description,
        properties: properties.length ? properties : null,
        subtitle: this.description.chatFlavor || data.subtitle,
        supplements
      };
    }

    /* -------------------------------------------- */

    /**
     * Apply any final modifications to message config immediately before message is created.
     * @param {ActivityUseConfiguration} usageConfig        Configuration data for the activation.
     * @param {ActivityMessageConfiguration} messageConfig  Configuration data for the chat message.
     * @param {ActivityUsageResults} results                Final details on the activation.
     * @protected
     */
    _finalizeMessageConfig(usageConfig, messageConfig, results) {
      messageConfig.data.rolls = (messageConfig.data.rolls ?? []).concat(results.updates.rolls);
      const effects = this.applicableEffects?.map(e => e.id);
      if ( effects ) foundry.utils.setProperty(messageConfig.data, "flags.dnd5e.use.effects", effects);
    }

    /* -------------------------------------------- */

    /**
     * Create the buttons that will be displayed in chat.
     * @param {ActivityMessageConfiguration} message  Configuration info for the created message.
     * @returns {ActivityUsageChatButton[]}
     * @protected
     */
    _usageChatButtons(message) {
      const buttons = [];

      if ( this.target?.template?.type ) buttons.push({
        label: game.i18n.localize("DND5E.TARGET.Action.PlaceTemplate"),
        icon: '<i class="fas fa-bullseye" inert></i>',
        dataset: {
          action: "placeTemplate"
        }
      });

      if ( message.hasConsumption ) buttons.push({
        label: game.i18n.localize("DND5E.CONSUMPTION.Action.ConsumeResource"),
        icon: '<i class="fa-solid fa-cubes-stacked" inert></i>',
        dataset: {
          action: "consumeResource"
        }
      }, {
        label: game.i18n.localize("DND5E.CONSUMPTION.Action.RefundResource"),
        icon: '<i class="fa-solid fa-clock-rotate-left"></i>',
        dataset: {
          action: "refundResource"
        }
      });

      return buttons;
    }

    /* -------------------------------------------- */

    /**
     * Determine whether the provided button in a chat message should be visible.
     * @param {HTMLButtonElement} button  The button to check.
     * @param {ChatMessage5e} message     Chat message containing the button.
     * @returns {boolean}
     */
    shouldHideChatButton(button, message) {
      const flag = message.getFlag("dnd5e", "use.consumed");
      switch ( button.dataset.action ) {
        case "consumeResource": return !!flag;
        case "refundResource": return !flag;
        case "placeTemplate": return !game.user.can("TEMPLATE_CREATE") || !game.canvas.scene;
      }
      return false;
    }

    /* -------------------------------------------- */

    /**
     * Display a chat message for this usage.
     * @param {ActivityMessageConfiguration} message  Configuration info for the created message.
     * @returns {Promise<ChatMessage5e|object>}
     * @protected
     */
    async _createUsageMessage(message) {
      const context = await this._usageChatContext(message);
      const messageConfig = foundry.utils.mergeObject({
        rollMode: game.settings.get("core", "rollMode"),
        data: {
          content: await foundry.applications.handlebars.renderTemplate(this.metadata.usage.chatCard, context),
          speaker: ChatMessage.getSpeaker({ actor: this.item.actor }),
          title: `${this.item.name} - ${this.name}`
        }
      }, message);

      /**
       * A hook event that fires before an activity usage card is created.
       * @function dnd5e.preCreateUsageMessage
       * @memberof hookEvents
       * @param {Activity} activity                     Activity for which the card will be created.
       * @param {ActivityMessageConfiguration} message  Configuration info for the created message.
       */
      Hooks.callAll("dnd5e.preCreateUsageMessage", this, messageConfig);

      ChatMessage.applyRollMode(messageConfig.data, messageConfig.rollMode);
      const card = messageConfig.create === false ? messageConfig.data : await ChatMessage.create(messageConfig.data);

      /**
       * A hook event that fires after an activity usage card is created.
       * @function dnd5e.postCreateUsageMessage
       * @memberof hookEvents
       * @param {Activity} activity          Activity for which the card was created.
       * @param {ChatMessage5e|object} card  Created card or configuration data if not created.
       */
      Hooks.callAll("dnd5e.postCreateUsageMessage", this, card);

      return card;
    }

    /* -------------------------------------------- */

    /**
     * Perform any final steps of the activation including creating measured templates.
     * @param {ActivityUseConfiguration} config  Configuration data for the activation.
     * @param {ActivityUsageResults} results     Final details on the activation.
     * @protected
     */
    async _finalizeUsage(config, results) {
      results.templates = config.create?.measuredTemplate ? await this.#placeTemplate() : [];
    }

    /* -------------------------------------------- */

    /**
     * Trigger a primary activation action defined by the activity (such as opening the attack dialog for attack rolls).
     * @param {ActivityUseConfiguration} config  Configuration data for the activation.
     * @param {ActivityUsageResults} results     Final details on the activation.
     * @protected
     */
    async _triggerSubsequentActions(config, results) {}

    /* -------------------------------------------- */
    /*  Rolling                                     */
    /* -------------------------------------------- */

    /**
     * Perform a damage roll.
     * @param {Partial<DamageRollProcessConfiguration>} config  Configuration information for the roll.
     * @param {Partial<BasicRollDialogConfiguration>} dialog    Configuration for the roll dialog.
     * @param {Partial<BasicRollMessageConfiguration>} message  Configuration for the roll message.
     * @returns {Promise<DamageRoll[]|void>}
     */
    async rollDamage(config={}, dialog={}, message={}) {
      const rollConfig = this.getDamageConfig(config);
      rollConfig.hookNames = [...(config.hookNames ?? []), "damage"];
      rollConfig.subject = this;

      const dialogConfig = foundry.utils.mergeObject({
        options: {
          position: {
            width: 400,
            top: config.event ? config.event.clientY - 80 : null,
            left: window.innerWidth - 710
          },
          window: {
            title: this.damageFlavor,
            subtitle: this.item.name,
            icon: this.item.img
          }
        }
      }, dialog);

      const messageConfig = foundry.utils.mergeObject({
        create: true,
        data: {
          flavor: `${this.item.name} - ${this.damageFlavor}`,
          flags: {
            dnd5e: {
              ...this.messageFlags,
              messageType: "roll",
              roll: { type: "damage" }
            }
          },
          speaker: ChatMessage.getSpeaker({ actor: this.actor })
        }
      }, message);

      const rolls = await CONFIG.Dice.DamageRoll.build(rollConfig, dialogConfig, messageConfig);
      if ( !rolls?.length ) return;

      const canUpdate = this.item.isOwner && !this.item.inCompendium;
      const lastDamageTypes = rolls.reduce((obj, roll, index) => {
        if ( roll.options.type ) obj[index] = roll.options.type;
        return obj;
      }, {});
      if ( canUpdate && !foundry.utils.isEmpty(lastDamageTypes)
        && (this.actor && this.actor.items.has(this.item.id)) ) {
        await this.item.setFlag("dnd5e", `last.${this.id}.damageType`, lastDamageTypes);
      }

      /**
       * A hook event that fires after damage has been rolled.
       * @function dnd5e.rollDamage
       * @memberof hookEvents
       * @param {DamageRoll[]} rolls       The resulting rolls.
       * @param {object} [data]
       * @param {Activity} [data.subject]  The activity that performed the roll.
       */
      Hooks.callAll("dnd5e.rollDamage", rolls, { subject: this });
      Hooks.callAll("dnd5e.rollDamageV2", rolls, { subject: this });

      return rolls;
    }

    /* -------------------------------------------- */
    /*  Event Listeners and Handlers                */
    /* -------------------------------------------- */

    /**
     * Activate listeners on a chat message.
     * @param {ChatMessage} message  Associated chat message.
     * @param {HTMLElement} html     Element in the chat log.
     */
    activateChatListeners(message, html) {
      html.addEventListener("click", event => {
        const target = event.target.closest("[data-action]");
        if ( target ) this.#onChatAction(event, target, message);
      });
    }

    /* -------------------------------------------- */

    /**
     * Construct context menu options for this Activity.
     * @returns {ContextMenuEntry[]}
     */
    getContextMenuOptions() {
      const entries = [];
      const compendiumLocked = this.item.collection?.locked;

      if ( this.item.isOwner && !compendiumLocked ) {
        entries.push({
          name: "DND5E.ContextMenuActionEdit",
          icon: '<i class="fas fa-pen-to-square fa-fw"></i>',
          callback: () => this.sheet.render({ force: true })
        }, {
          name: "DND5E.ContextMenuActionDuplicate",
          icon: '<i class="fas fa-copy fa-fw"></i>',
          callback: () => {
            const createData = this.toObject();
            delete createData._id;
            this.item.createActivity(createData.type, createData, { renderSheet: false });
          }
        }, {
          name: "DND5E.ContextMenuActionDelete",
          icon: '<i class="fas fa-trash fa-fw"></i>',
          callback: () => this.deleteDialog()
        });
      } else {
        entries.push({
          name: "DND5E.ContextMenuActionView",
          icon: '<i class="fas fa-eye fa-fw"></i>',
          callback: () => this.sheet.render({ force: true })
        });
      }

      if ( "favorites" in (this.actor?.system ?? {}) ) {
        const uuid = `${this.item.getRelativeUUID(this.actor)}.Activity.${this.id}`;
        const isFavorited = this.actor.system.hasFavorite(uuid);
        entries.push({
          name: isFavorited ? "DND5E.FavoriteRemove" : "DND5E.Favorite",
          icon: '<i class="fas fa-bookmark fa-fw"></i>',
          condition: () => this.item.isOwner && !compendiumLocked,
          callback: () => {
            if ( isFavorited ) this.actor.system.removeFavorite(uuid);
            else this.actor.system.addFavorite({ type: "activity", id: uuid });
          },
          group: "state"
        });
      }

      return entries;
    }

    /* -------------------------------------------- */

    /**
     * Handle an action activated from an activity's chat message.
     * @param {PointerEvent} event     Triggering click event.
     * @param {HTMLElement} target     The capturing HTML element which defined a [data-action].
     * @param {ChatMessage5e} message  Message associated with the activation.
     */
    async #onChatAction(event, target, message) {
      const consumed = this.createConsumedFlag(message.getAssociatedActor(), message.getFlag("dnd5e", "use.consumed"));
      const scaling = message.getFlag("dnd5e", "scaling") ?? 0;
      const item = (consumed || scaling) ? this.item.clone({
        "flags.dnd5e": { consumed, scaling }
      }, { keepId: true }) : this.item;
      const activity = item.system.activities.get(this.id);

      const action = target.dataset.action;
      const handler = this.metadata.usage?.actions?.[action];
      target.disabled = true;
      try {
        if ( handler ) await handler.call(activity, event, target, message);
        else if ( action === "consumeResource" ) await this.#consumeResource(event, target, message);
        else if ( action === "refundResource" ) await this.#refundResource(event, target, message);
        else if ( action === "placeTemplate" ) await this.#placeTemplate();
        else await activity._onChatAction(event, target, message);
      } catch(err) {
        Hooks.onError("Activity#onChatAction", err, { log: "error", notify: "error" });
      } finally {
        target.disabled = false;
      }
    }

    /* -------------------------------------------- */

    /**
     * Handle an action activated from an activity's chat message. Action handlers in metadata are called first.
     * This method is only called for actions which have no defined handler.
     * @param {PointerEvent} event     Triggering click event.
     * @param {HTMLElement} target     The capturing HTML element which defined a [data-action].
     * @param {ChatMessage5e} message  Message associated with the activation.
     * @protected
     */
    async _onChatAction(event, target, message) {}

    /* -------------------------------------------- */

    /**
     * Handle context menu events on activities.
     * @param {Item5e} item         The Item the Activity belongs to.
     * @param {HTMLElement} target  The element the menu was triggered on.
     */
    static onContextMenu(item, target) {
      const { activityId } = target.closest("[data-activity-id]")?.dataset ?? {};
      const activity = item.system.activities?.get(activityId);
      if ( !activity ) return;
      const menuItems = activity.getContextMenuOptions();

      /**
       * A hook even that fires when the context menu for an Activity is opened.
       * @function dnd5e.getItemActivityContext
       * @memberof hookEvents
       * @param {Activity} activity             The Activity.
       * @param {HTMLElement} target            The element that menu was triggered on.
       * @param {ContextMenuEntry[]} menuItems  The context menu entries.
       */
      Hooks.callAll("dnd5e.getItemActivityContext", activity, target, menuItems);
      ui.context.menuItems = menuItems;
    }

    /* -------------------------------------------- */

    /**
     * Handle consuming resources from the chat card.
     * @param {PointerEvent} event     Triggering click event.
     * @param {HTMLElement} target     The capturing HTML element which defined a [data-action].
     * @param {ChatMessage5e} message  Message associated with the activation.
     */
    async #consumeResource(event, target, message) {
      const messageConfig = {};
      const scaling = message.getFlag("dnd5e", "scaling");
      const usageConfig = { consume: true, event, scaling };
      const linkedActivity = this.getLinkedActivity(message.getFlag("dnd5e", "use.cause"));
      if ( linkedActivity ) usageConfig.cause = {
        activity: linkedActivity.relativeUUID, resources: linkedActivity.consumption.targets.length > 0
      };
      await this.consume(usageConfig, messageConfig);
      if ( !foundry.utils.isEmpty(messageConfig.data) ) await message.update(messageConfig.data);
    }

    /* -------------------------------------------- */

    /**
     * Handle refunding consumption from a chat card.
     * @param {PointerEvent} event     Triggering click event.
     * @param {HTMLElement} target     The capturing HTML element which defined a [data-action].
     * @param {ChatMessage5e} message  Message associated with the activation.
     */
    async #refundResource(event, target, message) {
      const consumed = message.getFlag("dnd5e", "use.consumed");
      if ( !foundry.utils.isEmpty(consumed) ) {
        await this.refund(consumed);
        await message.unsetFlag("dnd5e", "use.consumed");
      }
    }

    /* -------------------------------------------- */

    /**
     * Handle placing a measured template in the scene.
     * @returns {MeasuredTemplateDocument[]}
     */
    async #placeTemplate() {
      const templates = [];
      try {
        for ( const template of AbilityTemplate.fromActivity(this) ) {
          const result = await template.drawPreview();
          if ( result ) templates.push(result);
        }
      } catch(err) {
        Hooks.onError("Activity#placeTemplate", err, {
          msg: game.i18n.localize("DND5E.TARGET.Warning.PlaceTemplate"),
          log: "error",
          notify: "error"
        });
      }
      return templates;
    }

    /* -------------------------------------------- */
    /*  Helpers                                     */
    /* -------------------------------------------- */

    /**
     * Retrieve consumed flag for given update data.
     * @param {Actor5e} actor
     * @type {ActorDeltasData} deltas
     * @returns {{ hd: string }|void}
     */
    createConsumedFlag(actor, deltas) {
      if ( !actor || !deltas ) return;
      const hitDice = Object.entries(deltas.item ?? [])
        .reduce((obj, [id, changes]) => {
          const hdChange = changes.find(c => (c.keyPath === "system.hd.spent") && (c.delta > 0))?.delta;
          const hdDenom = actor.items.get(changes._id ?? id)?.system?.hd?.denomination;
          if ( hdChange && hdDenom ) obj[hdDenom] = (obj[hdDenom] ?? 0) + hdChange;
          return obj;
        }, {});
      if ( foundry.utils.isEmpty(hitDice) ) return;
      return { hd: Object.entries(hitDice).map(([d, n]) => `${n}${d}`).join(" + ") };
    }

    /* -------------------------------------------- */

    /**
     * Prepare activity favorite data.
     * @returns {Promise<FavoriteData5e>}
     */
    async getFavoriteData() {
      return {
        img: this.img,
        title: this.name,
        subtitle: [this.labels.activation, this.labels.recovery],
        range: this.range,
        uses: { ...this.uses, name: "uses.value" }
      };
    }

    /* -------------------------------------------- */

    /**
     * Retrieve a linked activity based on the provided relative UUID, or the stored `cachedFor` value.
     * @param {string} relativeUUID  Relative UUID for an activity on this actor.
     * @returns {Activity|null}
     */
    getLinkedActivity(relativeUUID) {
      if ( !this.actor ) return null;
      relativeUUID ??= this.item.getFlag("dnd5e", "cachedFor");
      return fromUuidSync(relativeUUID, { relative: this.actor, strict: false });
    }

    /* -------------------------------------------- */

    /**
     * Prepare a data object which defines the data schema used by dice roll commands against this Activity.
     * @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.
     * @returns {object}
     */
    getRollData(options) {
      const rollData = this.item.getRollData(options);
      rollData.activity = { ...this };
      rollData.consumed = this.item.flags.dnd5e?.consumed;
      rollData.mod = this.actor?.system.abilities?.[this.ability]?.mod ?? 0;
      return rollData;
    }

    /* -------------------------------------------- */

    /**
     * Get the best matched token from which this activity is being used if one can be found for this actor
     * in the current scene.
     * @returns {TokenDocument|void}
     */
    getUsageToken() {
      return getSceneTargets(this.actor)[0]?.document;
    }

    /* -------------------------------------------- */

    /**
     * Merge the activity updates into this activity's item updates.
     * @param {ActivityUsageUpdates} updates
     * @internal
     */
    _mergeActivityUpdates(updates) {
      if ( foundry.utils.isEmpty(updates.activity) ) return;
      const itemIndex = updates.item.findIndex(i => i._id === this.item.id);
      const keyPath = `system.activities.${this.id}`;
      const activityUpdates = foundry.utils.expandObject(updates.activity);
      if ( itemIndex === -1 ) updates.item.push({ _id: this.item.id, [keyPath]: activityUpdates });
      else updates.item[itemIndex][keyPath] = activityUpdates;
    }

    /* -------------------------------------------- */
    /*  Importing and Exporting                     */
    /* -------------------------------------------- */

    /**
     * Can an activity of this type be added to the provided item?
     * @param {Item5e} item  Candidate item to which the activity might be added.
     * @returns {boolean}    Should this activity be available?
     */
    static availableForItem(item) {
      return true;
    }

    /* -------------------------------------------- */

    /** @override */
    static _createDialogTypes(parent) {
      return Object.entries(CONFIG.DND5E.activityTypes)
        .filter(([, c]) => (c.configurable !== false) && c.documentClass.availableForItem(parent))
        .map(([k]) => k);
    }
  }
  return Activity;
}

/**
 * Sheet for the attack activity.
 */
class AttackSheet extends ActivitySheet {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["attack-activity"]
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  static PARTS = {
    ...super.PARTS,
    identity: {
      template: "systems/dnd5e/templates/activity/attack-identity.hbs",
      templates: [
        ...super.PARTS.identity.templates,
        "systems/dnd5e/templates/activity/parts/attack-identity.hbs"
      ]
    },
    effect: {
      template: "systems/dnd5e/templates/activity/attack-effect.hbs",
      templates: [
        ...super.PARTS.effect.templates,
        "systems/dnd5e/templates/activity/parts/attack-damage.hbs",
        "systems/dnd5e/templates/activity/parts/attack-details.hbs",
        "systems/dnd5e/templates/activity/parts/damage-part.hbs",
        "systems/dnd5e/templates/activity/parts/damage-parts.hbs"
      ]
    }
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareEffectContext(context, options) {
    context = await super._prepareEffectContext(context, options);

    const availableAbilities = this.activity.availableAbilities;
    context.abilityOptions = [
      {
        value: "", label: game.i18n.format("DND5E.DefaultSpecific", {
          default: this.activity.attack.type.classification === "spell"
            ? game.i18n.localize("DND5E.Spellcasting").toLowerCase()
            : availableAbilities.size
              ? game.i18n.getListFormatter({ style: "short", type: "disjunction" }).format(
                Array.from(availableAbilities).map(a => CONFIG.DND5E.abilities[a].label.toLowerCase())
              )
              : game.i18n.localize("DND5E.None").toLowerCase()
        })
      },
      { rule: true },
      { value: "none", label: game.i18n.localize("DND5E.None") },
      { value: "spellcasting", label: game.i18n.localize("DND5E.Spellcasting") },
      ...Object.entries(CONFIG.DND5E.abilities).map(([value, config]) => ({
        value, label: config.label, group: game.i18n.localize("DND5E.Abilities")
      }))
    ];

    context.hasBaseDamage = this.item.system.offersBaseDamage;

    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareIdentityContext(context, options) {
    context = await super._prepareIdentityContext(context, options);

    context.attackTypeOptions = Object.entries(CONFIG.DND5E.attackTypes)
      .map(([value, config]) => ({ value, label: config.label }));
    if ( this.item.system.validAttackTypes?.size ) context.attackTypeOptions.unshift({
      value: "",
      label: game.i18n.format("DND5E.DefaultSpecific", {
        default: game.i18n.getListFormatter({ type: "disjunction" }).format(
          Array.from(this.item.system.validAttackTypes).map(t => CONFIG.DND5E.attackTypes[t].label.toLowerCase())
        )
      })
    });

    context.attackClassificationOptions = Object.entries(CONFIG.DND5E.attackClassifications)
      .map(([value, config]) => ({ value, label: config.label }));
    if ( this.item.system.attackClassification ) context.attackClassificationOptions.unshift({
      value: "",
      label: game.i18n.format("DND5E.DefaultSpecific", {
        default: CONFIG.DND5E.attackClassifications[this.item.system.attackClassification].label.toLowerCase()
      })
    });

    return context;
  }
}

const { DiceTerm: DiceTerm$2 } = foundry.dice.terms;

/**
 * @import {
 *   BasicRollConfigurationDialogOptions, BasicRollDialogConfiguration,
 *   BasicRollMessageConfiguration, BasicRollProcessConfiguration
 * } from "../../dice/_types.mjs";
 */

/**
 * Dialog for configuring one or more rolls.
 * @extends {Dialog5e<ApplicationConfiguration & BasicRollConfigurationDialogOptions>}
 *
 * @param {BasicRollProcessConfiguration} [config={}]         Initial roll configuration.
 * @param {BasicRollMessageConfiguration} [message={}]        Message configuration.
 * @param {BasicRollConfigurationDialogOptions} [options={}]  Dialog rendering options.
 */
class RollConfigurationDialog extends Dialog5e {
  constructor(config={}, message={}, options={}) {
    super(options);

    this.#config = config;
    this.#message = message;
    this.#buildRolls(foundry.utils.deepClone(this.#config));
  }

  /* -------------------------------------------- */

  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["roll-configuration"],
    window: {
      title: "DND5E.RollConfiguration.Title",
      icon: "fa-solid fa-dice"
    },
    form: {
      handler: RollConfigurationDialog.#handleFormSubmission
    },
    position: {
      width: 400
    },
    buildConfig: null,
    rendering: {
      dice: {
        max: 5,
        denominations: new Set(["d4", "d6", "d8", "d10", "d12", "d20"])
      }
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    formulas: {
      template: "systems/dnd5e/templates/dice/roll-formulas.hbs"
    },
    configuration: {
      template: "systems/dnd5e/templates/dice/roll-configuration.hbs"
    },
    buttons: {
      template: "systems/dnd5e/templates/dice/roll-buttons.hbs"
    }
  };

  /* -------------------------------------------- */

  /**
   * Roll type to use when constructing the rolls.
   * @type {typeof BasicRoll}
   */
  static get rollType() {
    return CONFIG.Dice.BasicRoll;
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Roll configuration.
   * @type {BasicRollProcessConfiguration}
   */
  #config;

  get config() {
    return this.#config;
  }

  /* -------------------------------------------- */

  /**
   * Configuration information for the roll message.
   * @type {BasicRollMessageConfiguration}
   */
  #message;

  get message() {
    return this.#message;
  }

  /* -------------------------------------------- */

  /**
   * The rolls being configured.
   * @type {BasicRoll[]}
   */
  #rolls;

  get rolls() {
    return this.#rolls;
  }

  /* -------------------------------------------- */

  /**
   * Roll type to use when constructing the rolls.
   * @type {typeof BasicRoll}
   */
  get rollType() {
    return this.options.rollType ?? this.constructor.rollType;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /**
   * Identify DiceTerms in this app's rolls.
   * @returns {{ icon: string, label: string }[]}
   * @protected
   */
  _identifyDiceTerms() {
    let dice = [];
    let shouldDisplay = true;

    /**
     * Determine if a given term is displayable.
     * @param {RollTerm} term  The term.
     * @returns {boolean|void}
     */
    const identifyTerm = term => {
      if ( !(term instanceof DiceTerm$2) ) return;
      // If any of the terms have complex components, do not attempt to display only some dice, bail out entirely.
      if ( !Number.isFinite(term.number) || !Number.isFinite(term.faces) ) return shouldDisplay = false;
      // If any of the terms are of an unsupported denomination, do not attempt to display only some dice, bail out
      // entirely.
      if ( !this.options.rendering.dice.denominations.has(term.denomination) ) return shouldDisplay = false;
      for ( let i = 0; i < term.number; i++ ) dice.push({
        icon: `systems/dnd5e/icons/svg/dice/${term.denomination}.svg`,
        label: term.denomination,
        denomination: term.denomination
      });
    };

    /**
     * Identify any DiceTerms in the given terms.
     * @param {RollTerm[]} terms  The terms.
     */
    const identifyDice = (terms=[]) => {
      for ( const term of terms ) {
        identifyTerm(term);
        if ( "dice" in term ) identifyDice(term.dice);
      }
    };

    this.rolls.forEach(roll => identifyDice(roll.terms));
    if ( dice.length > this.options.rendering.dice.max ) {
      // Compact dice display.
      const byDenom = dice.reduce((obj, { icon, denomination }) => {
        obj[denomination] ??= { icon, count: 0 };
        obj[denomination].count++;
        return obj;
      }, {});
      dice = Object.entries(byDenom).map(([d, { icon, count }]) => ({ icon, label: `${count}${d}` }));
      if ( dice.length > this.options.rendering.dice.max ) shouldDisplay = false;
    }
    else if ( !dice.length ) shouldDisplay = false;
    return shouldDisplay ? dice : [];
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    switch ( partId ) {
      case "buttons":
        return this._prepareButtonsContext(context, options);
      case "configuration":
        return this._prepareConfigurationContext(context, options);
      case "formulas":
        return this._prepareFormulasContext(context, options);
      default:
        return context;
    }
  }

  /* -------------------------------------------- */

  /**
   * Prepare the context for the buttons.
   * @param {ApplicationRenderContext} context  Shared context provided by _prepareContext.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {Promise<ApplicationRenderContext>}
   * @protected
   */
  async _prepareButtonsContext(context, options) {
    context.buttons = {
      roll: {
        default: true,
        icon: '<i class="fa-solid fa-dice" inert></i>',
        label: game.i18n.localize("DND5E.Roll")
      }
    };
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare the context for the roll configuration section.
   * @param {ApplicationRenderContext} context  Shared context provided by _prepareContext.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {Promise<ApplicationRenderContext>}
   * @protected
   */
  async _prepareConfigurationContext(context, options) {
    context.fields = [{
      field: new foundry.data.fields.StringField({
        label: game.i18n.localize("DND5E.RollMode"), blank: false, required: true
      }),
      name: "rollMode",
      value: this.message.rollMode ?? this.options.default?.rollMode ?? game.settings.get("core", "rollMode"),
      options: Object.entries(CONFIG.Dice.rollModes)
        .map(([value, l]) => ({ value, label: game.i18n.localize(l.label) }))
    }];
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare the context for the formulas list.
   * @param {ApplicationRenderContext} context  Shared context provided by _prepareContext.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {Promise<ApplicationRenderContext>}
   * @protected
   */
  async _prepareFormulasContext(context, options) {
    context.rolls = this.rolls.map(roll => ({ roll }));
    context.dice = this._identifyDiceTerms() || [];
    return context;
  }

  /* -------------------------------------------- */
  /*  Roll Handling                               */
  /* -------------------------------------------- */

  /**
   * Build a roll from the provided configuration objects.
   * @param {BasicRollProcessConfiguration} config  Roll configuration data.
   * @param {FormDataExtended} [formData]           Any data entered into the rolling prompt.
   */
  #buildRolls(config, formData) {
    const RollType = this.rollType;
    this.#rolls = config.rolls?.map((config, index) =>
      RollType.fromConfig(this.#buildConfig(config, formData, index), this.config)
    ) ?? [];
  }

  /* -------------------------------------------- */

  /**
   * Call the necessary hooks and config building methods before roll is fully built.
   * @param {BasicRollConfiguration} config  Roll configuration data.
   * @param {FormDataExtended} [formData]    Any data entered into the rolling prompt.
   * @param {number} index                   Index of the roll within all rolls being prepared.
   * @returns {BasicRollConfiguration}
   */
  #buildConfig(config, formData, index) {
    config = foundry.utils.mergeObject({ parts: [], data: {}, options: {} }, config);

    /**
     * A hook event that fires when a roll config is built using the roll prompt. Multiple hooks may be called depending
     * on the rolling method (e.g. `dnd5e.buildSkillRollConfig`, `dnd5e.buildAbilityCheckRollConfig`,
     * `dnd5e.buildRollConfig`).
     * @function dnd5e.buildRollConfig
     * @memberof hookEvents
     * @param {RollConfigurationDialog} app    Roll configuration dialog.
     * @param {BasicRollConfiguration} config  Roll configuration data.
     * @param {FormDataExtended} [formData]    Any data entered into the rolling prompt.
     * @param {number} index                   Index of the roll within all rolls being prepared.
     */
    for ( const hookName of this.#config.hookNames ?? [""] ) {
      Hooks.callAll(`dnd5e.build${hookName.capitalize()}RollConfig`, this, config, formData, index);
    }

    config = this._buildConfig(config, formData, index);
    this.options.buildConfig?.(this.config, config, formData, index);

    /**
     * A hook event that fires after a roll config has been built using the roll prompt. Multiple hooks may be called
     * depending on the rolling method (e.g. `dnd5e.postBuildSkillRollConfig`, `dnd5e.postBuildAbilityCheckRollConfig`,
     * `dnd5e.postBuildRollConfig`).
     * @function dnd5e.postBuildRollConfig
     * @memberof hookEvents
     * @param {BasicRollProcessConfiguration} process  Full process configuration data.
     * @param {BasicRollConfiguration} config          Roll configuration data.
     * @param {number} index                           Index of the roll within all rolls being prepared.
     * @param {object} [options]
     * @param {RollConfigurationDialog} [options.app]  Roll configuration dialog.
     * @param {FormDataExtended} [options.formData]    Any data entered into the rolling prompt.
     */
    for ( const hookName of this.#config.hookNames ?? [""] ) {
      Hooks.callAll(`dnd5e.postBuild${hookName.capitalize()}RollConfig`, this.config, config, index, {
        app: this, formData
      });
    }

    return config;
  }

  /* -------------------------------------------- */

  /**
   * Prepare individual configuration object before building a roll.
   * @param {BasicRollConfiguration} config  Roll configuration data.
   * @param {FormDataExtended} [formData]    Any data entered into the rolling prompt.
   * @param {number} index                   Index of the roll within all rolls being prepared.
   * @returns {BasicRollConfiguration}
   * @protected
   */
  _buildConfig(config, formData, index) {
    const situational = formData?.get(`roll.${index}.situational`);
    if ( situational && (config.situational !== false) ) {
      config.parts.push("@situational");
      config.data.situational = situational;
    } else {
      config.parts.findSplice(v => v === "@situational");
    }
    return config;
  }

  /* -------------------------------------------- */

  /**
   * Make any final modifications to rolls based on the button clicked.
   * @param {string} action  Action on the button clicked.
   * @returns {BasicRoll[]}
   * @protected
   */
  _finalizeRolls(action) {
    return this.rolls;
  }

  /* -------------------------------------------- */

  /**
   * Rebuild rolls based on an updated config and re-render the dialog.
   */
  rebuild() {
    this._onChangeForm(this.options.form, new Event("change"));
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle submission of the dialog using the form buttons.
   * @this {RollConfigurationDialog}
   * @param {Event|SubmitEvent} event    The form submission event.
   * @param {HTMLFormElement} form       The submitted form.
   * @param {FormDataExtended} formData  Data from the dialog.
   */
  static async #handleFormSubmission(event, form, formData) {
    if ( formData.has("rollMode") ) this.message.rollMode = formData.get("rollMode");
    this.#rolls = this._finalizeRolls(event.submitter?.dataset?.action);
    await this.close({ dnd5e: { submitted: true } });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onChangeForm(formConfig, event) {
    super._onChangeForm(formConfig, event);

    const formData = new foundry.applications.ux.FormDataExtended(this.form);
    if ( formData.has("rollMode") ) this.message.rollMode = formData.get("rollMode");
    this.#buildRolls(foundry.utils.deepClone(this.#config), formData);
    this.render({ parts: ["formulas"] });
  }

  /* -------------------------------------------- */

  /** @override */
  _onClose(options={}) {
    if ( !options.dnd5e?.submitted ) this.#rolls = [];
  }

  /* -------------------------------------------- */
  /*  Factory Methods                             */
  /* -------------------------------------------- */

  /**
   * A helper to handle displaying and responding to the dialog.
   * @param {BasicRollProcessConfiguration} [config]   Initial roll configuration.
   * @param {BasicRollDialogConfiguration} [dialog]    Dialog configuration options.
   * @param {BasicRollMessageConfiguration} [message]  Message configuration.
   * @returns {Promise<BasicRoll[]>}
   */
  static async configure(config={}, dialog={}, message={}) {
    return new Promise(resolve => {
      const app = new this(config, message, dialog.options);
      app.addEventListener("close", () => resolve(app.rolls), { once: true });
      app.render({ force: true });
    });
  }
}

/**
 * @import {
 *   BasicRollConfigurationDialogOptions, BasicRollMessageConfiguration, D20RollProcessConfiguration
 * } from "../../dice/_types.mjs";
 */

/**
 * Dialog for configuring d20 rolls.
 * @extends {RollConfigurationDialog}
 *
 * @param {D20RollProcessConfiguration} [config={}]           Initial roll configuration.
 * @param {BasicRollMessageConfiguration} [message={}]        Message configuration.
 * @param {BasicRollConfigurationDialogOptions} [options={}]  Dialog rendering options.
 */
class D20RollConfigurationDialog extends RollConfigurationDialog {

  /** @override */
  static get rollType() {
    return CONFIG.Dice.D20Roll;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @override */
  async _prepareButtonsContext(context, options) {
    let defaultButton = this.options.defaultButton;
    if ( !defaultButton ) {
      let advantage = false;
      let disadvantage = false;
      for ( const roll of this.config.rolls ) {
        if ( !roll.options ) continue;
        if ( roll.options.advantageMode === CONFIG.Dice.D20Roll.ADV_MODE.ADVANTAGE ) advantage = true;
        else if ( roll.options.advantageMode === CONFIG.Dice.D20Roll.ADV_MODE.DISADVANTAGE ) disadvantage = true;
        else if ( roll.options.advantage && !roll.options.disadvantage ) advantage = true;
        else if ( !roll.options.advantage && roll.options.disadvantage ) disadvantage = true;
      }
      if ( advantage && !disadvantage ) defaultButton = "advantage";
      else if ( !advantage && disadvantage ) defaultButton = "disadvantage";
    }
    context.buttons = {
      advantage: {
        default: defaultButton === "advantage",
        label: game.i18n.localize("DND5E.Advantage")
      },
      normal: {
        default: !["advantage", "disadvantage"].includes(defaultButton),
        label: game.i18n.localize("DND5E.Normal")
      },
      disadvantage: {
        default: defaultButton === "disadvantage",
        label: game.i18n.localize("DND5E.Disadvantage")
      }
    };
    return context;
  }

  /* -------------------------------------------- */
  /*  Roll Handling                               */
  /* -------------------------------------------- */

  /** @override */
  _finalizeRolls(action) {
    let advantageMode = CONFIG.Dice.D20Roll.ADV_MODE.NORMAL;
    if ( action === "advantage" ) advantageMode = CONFIG.Dice.D20Roll.ADV_MODE.ADVANTAGE;
    else if ( action === "disadvantage" ) advantageMode = CONFIG.Dice.D20Roll.ADV_MODE.DISADVANTAGE;
    return this.rolls.map(roll => {
      roll.options.advantageMode = advantageMode;
      roll.configureModifiers();
      return roll;
    });
  }
}

/**
 * @import { AttackRollConfigurationDialogOptions } from "../../dice/_types.mjs";
 */

/**
 * Extended roll configuration dialog that allows selecting attack mode, ammunition, and weapon mastery.
 * @extends D20RollConfigurationDialog<AttackRollConfigurationDialogOptions>
 */
class AttackRollConfigurationDialog extends D20RollConfigurationDialog {
  /** @override */
  static DEFAULT_OPTIONS = {
    ammunitionOptions: [],
    attackModeOptions: [],
    masteryOptions: []
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareConfigurationContext(context, options) {
    context = await super._prepareConfigurationContext(context, options);
    const optionsFields = [
      { key: "attackMode", label: "DND5E.ATTACK.Mode.Label", options: this.options.attackModeOptions },
      { key: "ammunition", label: "DND5E.CONSUMABLE.Type.Ammunition.Label", options: this.options.ammunitionOptions },
      { key: "mastery", label: "DND5E.WEAPON.Mastery.Label", options: this.options.masteryOptions }
    ];
    context.fields = [
      ...optionsFields.map(({ key, label, options }) => options.length ? {
        field: new foundry.data.fields.StringField({ label: game.i18n.localize(label), blank: false, required: true }),
        name: key,
        options,
        value: this.config[key]
      } : null).filter(_ => _),
      ...context.fields
    ];
    return context;
  }
}

/**
 * Lightweight class containing scaling information for an item that is used in roll data to ensure it is available
 * in the correct format in roll formulas: `@scaling` is the scaling value, and `@scaling.increase` as the scaling
 * steps above baseline.
 *
 * @param {number} increase  Scaling steps above baseline.
 */
class Scaling {
  constructor(increase) {
    this.#increase = increase;
  }

  /* -------------------------------------------- */

  /**
   * Scaling steps above baseline.
   * @type {number}
   */
  #increase;

  get increase() {
    return this.#increase;
  }

  /* -------------------------------------------- */

  /**
   * Value of the scaling starting 1.
   * @type {string}
   */
  get value() {
    return this.#increase + 1;
  }

  /* -------------------------------------------- */

  /** @override */
  toString() {
    return this.value;
  }
}

const { BooleanField: BooleanField$N, NumberField: NumberField$O, SchemaField: SchemaField$$, SetField: SetField$C, StringField: StringField$1i } = foundry.data.fields;

/**
 * Field for storing damage data.
 */
class DamageField extends EmbeddedDataField5e {
  constructor(options) {
    super(DamageData, options);
  }
}

/* -------------------------------------------- */

/**
 * Data model that stores information on a single damage part.
 */
class DamageData extends foundry.abstract.DataModel {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static defineSchema() {
    return {
      number: new NumberField$O({ min: 0, integer: true }),
      denomination: new NumberField$O({ min: 0, integer: true }),
      bonus: new FormulaField(),
      types: new SetField$C(new StringField$1i()),
      custom: new SchemaField$$({
        enabled: new BooleanField$N(),
        formula: new FormulaField()
      }),
      scaling: new SchemaField$$({
        mode: new StringField$1i(),
        number: new NumberField$O({ initial: 1, min: 0, integer: true }),
        formula: new FormulaField()
      })
    };
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The default damage formula.
   * @type {string}
   */
  get formula() {
    if ( this.custom.enabled ) return this.custom.formula ?? "";
    return this._automaticFormula();
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Produce the auto-generated formula from the `number`, `denomination`, and `bonus`.
   * @param {number} [increase=0]  Amount to increase the die count.
   * @returns {string}
   * @protected
   */
  _automaticFormula(increase=0) {
    let formula;
    const number = (this.number ?? 0) + increase;
    if ( number && this.denomination ) formula = `${number}d${this.denomination}`;
    if ( this.bonus ) formula = formula ? `${formula} + ${this.bonus}` : this.bonus;
    return formula ?? "";
  }

  /* -------------------------------------------- */

  /**
   * Scale the damage by a number of steps using its configured scaling configuration.
   * @param {number|Scaling} increase  Number of steps above base damage to scaling.
   * @returns {string}
   */
  scaledFormula(increase) {
    if ( increase instanceof Scaling ) increase = increase.increase;

    switch ( this.scaling.mode ) {
      case "whole": break;
      case "half": increase = Math.floor(increase * .5); break;
      default: increase = 0; break;
    }
    if ( !increase ) return this.formula;
    let formula;

    // If dice count scaling, increase the count on the first die rolled
    const dieIncrease = (this.scaling.number ?? 0) * increase;
    if ( this.custom.enabled ) {
      formula = this.custom.formula;
      formula = formula.replace(/^(\d)+d/, (match, number) => `${Number(number) + dieIncrease}d`);
    } else {
      formula = this._automaticFormula(dieIncrease);
    }

    // If custom scaling included, modify to match increase and append for formula
    if ( this.scaling.formula ) {
      let roll = new Roll(this.scaling.formula);
      roll = roll.alter(increase, 0, { multiplyNumeric: true });
      formula = formula ? `${formula} + ${roll.formula}` : roll.formula;
    }

    return formula;
  }

  /* -------------------------------------------- */

  /**
   * Step the die denomination up or down by a number of steps, sticking to proper die sizes. Will return `null` if
   * stepping reduced the denomination below minimum die size.
   * @param {number} [steps=1]  Number of steps to increase or decrease the denomination.
   * @returns {number|null}
   */
  steppedDenomination(steps=1) {
    return CONFIG.DND5E.dieSteps[Math.min(
      CONFIG.DND5E.dieSteps.indexOf(this.denomination) + steps,
      CONFIG.DND5E.dieSteps.length - 1
    )] ?? null;
  }
}

const { OperatorTerm: OperatorTerm$1, RollTerm } = foundry.dice.terms;

/**
 * Parse the provided rolls, splitting parts based on damage types & properties, taking flavor into account.
 * @param {DamageRoll[]} rolls                   Damage rolls to aggregate.
 * @param {object} [options={}]
 * @param {boolean} [options.respectProperties]  Should damage properties also affect grouping?
 * @returns {DamageRoll[]}
 */
function aggregateDamageRolls(rolls, { respectProperties }={}) {
  const makeHash = (type, properties=[]) => [type, ...(respectProperties ? Array.from(properties).sort() : [])].join();

  // Split rolls into new sets of terms based on damage type & properties
  const types = new Map();
  for ( const roll of rolls ) {
    for ( const chunk of chunkTerms(roll.terms, roll.options.type) ) {
      const key = makeHash(chunk.type, roll.options.properties);
      if ( !types.has(key) ) types.set(key, { type: chunk.type, properties: new Set(), terms: [] });
      const data = types.get(key);
      data.terms.push(new OperatorTerm$1({ operator: chunk.negative ? "-" : "+" }), ...chunk.terms);
      if ( roll.options.properties ) data.properties = data.properties.union(new Set(roll.options.properties));
    }
  }

  // Create new damage rolls based on the aggregated terms
  const newRolls = [];
  for ( const { terms, type, properties } of types.values() ) {
    newRolls.push(CONFIG.Dice.DamageRoll.fromTerms(terms, { type, properties: Array.from(properties) }));
  }

  return newRolls;
}

/* -------------------------------------------- */

/**
 * Split terms into groups based on operators. Addition & subtraction will split groups while multiplication and
 * division will keep groups together. These groups also contain information on contained types written in flavor
 * and whether they are negative.
 * @param {RollTerm[]} terms  Terms to chunk.
 * @param {string} type       Type specified in the roll as a whole.
 * @returns {{ terms: RollTerm[], negative: boolean, type: string }[]}
 */
function chunkTerms(terms, type) {
  const pushChunk = () => {
    currentChunk.type ??= type;
    chunks.push(currentChunk);
    currentChunk = null;
    negative = false;
  };
  const isValidType = t => ((t in CONFIG.DND5E.damageTypes) || (t in CONFIG.DND5E.healingTypes));
  const chunks = [];
  let currentChunk;
  let negative = false;

  for ( let term of terms ) {
    // Plus or minus operators split chunks
    if ( (term instanceof OperatorTerm$1) && ["+", "-"].includes(term.operator) ) {
      if ( currentChunk ) pushChunk();
      if ( term.operator === "-" ) negative = !negative;
      continue;
    }

    // All other terms get added to the current chunk
    term = RollTerm.fromData(foundry.utils.deepClone(term.toJSON()));
    currentChunk ??= { terms: [], negative, type: null };
    currentChunk.terms.push(term);
    const flavor = term.flavor?.toLowerCase().trim();
    if ( isValidType(flavor) ) {
      currentChunk.type ??= flavor;
      term.options.flavor = "";
    }
  }

  if ( currentChunk ) pushChunk();
  return chunks;
}

/**
 * Special case StringField that includes automatic validation for identifiers.
 */
class IdentifierField extends foundry.data.fields.StringField {
  /** @inheritDoc */
  static get _defaults() {
    return foundry.utils.mergeObject(super._defaults, {
      allowType: false
    });
  }

  /* -------------------------------------------- */

  /** @override */
  _validateType(value) {
    if ( !dnd5e.utils.validators.isValidIdentifier(value, { allowType: this.allowType }) ) {
      throw new Error(game.i18n.localize("DND5E.IdentifierError"));
    }
  }
}

const { NumberField: NumberField$N, SchemaField: SchemaField$_, StringField: StringField$1h } = foundry.data.fields;

/**
 * Field for storing activation data.
 */
class ActivationField extends SchemaField$_ {
  constructor(fields={}, options={}) {
    fields = {
      type: new StringField$1h({ initial: "action" }),
      value: new NumberField$N({ min: 0, integer: true }),
      condition: new StringField$1h(),
      ...fields
    };
    super(fields, options);
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /**
   * Prepare data for this field. Should be called during the `prepareFinalData` stage.
   * @this {ItemDataModel|BaseActivityData}
   * @param {object} rollData  Roll data used for formula replacements.
   * @param {object} [labels]  Object in which to insert generated labels.
   */
  static prepareData(rollData, labels) {
    this.activation.scalar = CONFIG.DND5E.activityActivationTypes[this.activation.type]?.scalar ?? false;
    if ( !this.activation.scalar ) this.activation.value = null;

    if ( labels && this.activation.type ) {
      labels.activation = [
        this.activation.value, CONFIG.DND5E.activityActivationTypes[this.activation.type]?.label
      ].filterJoin(" ");
      const formatter = game.i18n.getListFormatter({ type: "disjunction" });
      labels.ritualActivation = this.properties?.has?.("ritual")
        ? formatter.format([labels.activation, game.i18n.localize("DND5E.Ritual")]) : labels.activation;
    }
  }
}

const { SchemaField: SchemaField$Z, StringField: StringField$1g } = foundry.data.fields;

/**
 * Field for storing duration data.
 */
class DurationField extends SchemaField$Z {
  constructor(fields={}, options={}) {
    fields = {
      value: new FormulaField({ deterministic: true }),
      units: new StringField$1g({ required: true, blank: false, initial: "inst" }),
      special: new StringField$1g(),
      ...fields
    };
    super(fields, options);
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /**
   * Prepare data for this field. Should be called during the `prepareFinalData` stage.
   * @this {ItemDataModel|BaseActivityData}
   * @param {object} rollData  Roll data used for formula replacements.
   * @param {object} [labels]  Object in which to insert generated labels.
   */
  static prepareData(rollData, labels) {
    this.duration.scalar = this.duration.units in CONFIG.DND5E.scalarTimePeriods;
    if ( this.duration.scalar ) {
      prepareFormulaValue(this, "duration.value", "DND5E.DURATION.FIELDS.duration.value.label", rollData);
    } else this.duration.value = null;

    if ( labels && this.duration.units ) {
      if ( this.duration.value && (this.duration.units in CONFIG.DND5E.timeUnits) ) {
        labels.duration = formatTime(this.duration.value, this.duration.units);
      } else labels.duration = CONFIG.DND5E.timePeriods[this.duration.units] ?? "";
      labels.concentrationDuration = this.duration.concentration || this.properties?.has("concentration")
        ? game.i18n.format("DND5E.ConcentrationDuration", { duration: labels.duration }) : labels.duration;
    }

    Object.defineProperty(this.duration, "getEffectData", {
      value: DurationField.getEffectDuration.bind(this.duration),
      configurable: true
    });
  }

  /* -------------------------------------------- */

  /**
   * Create duration data usable for an active effect based on this duration.
   * @this {DurationData}
   * @returns {EffectDurationData}
   */
  static getEffectDuration() {
    if ( !Number.isNumeric(this.value) ) return {};
    switch ( this.units ) {
      case "turn": return { turns: this.value };
      case "round": return { rounds: this.value };
      case "minute": return { seconds: this.value * 60 };
      case "hour": return { seconds: this.value * 60 * 60 };
      case "day": return { seconds: this.value * 60 * 60 * 24 };
      case "month": return { seconds: this.value * 60 * 60 * 24 * 30 };
      case "year": return { seconds: this.value * 60 * 60 * 24 * 365 };
      default: return {};
    }
  }
}

const { SchemaField: SchemaField$Y, StringField: StringField$1f } = foundry.data.fields;

/**
 * Field for storing range data.
 */
class RangeField extends SchemaField$Y {
  constructor(fields={}, options={}) {
    fields = {
      value: new FormulaField({ deterministic: true }),
      units: new StringField$1f({ required: true, blank: false, initial: "self" }),
      special: new StringField$1f(),
      ...fields
    };
    super(fields, options);
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /**
   * Prepare data for this field. Should be called during the `prepareFinalData` stage.
   * @this {ItemDataModel|BaseActivityData}
   * @param {object} rollData  Roll data used for formula replacements.
   * @param {object} [labels]  Object in which to insert generated labels.
   */
  static prepareData(rollData, labels) {
    this.range.scalar = this.range.units in CONFIG.DND5E.movementUnits;
    if ( this.range.scalar ) {
      prepareFormulaValue(this, "range.value", "DND5E.RANGE.FIELDS.range.value.label", rollData);
    } else this.range.value = null;

    if ( labels && this.range.units ) {
      if ( this.range.scalar && this.range.value ) {
        labels.range = formatLength(this.range.value, this.range.units);
        labels.rangeParts = formatLength(this.range.value, this.range.units, { parts: true });
      } else if ( !this.range.scalar ) {
        labels.range = CONFIG.DND5E.distanceUnits[this.range.units];
      }
    } else if ( labels ) labels.range = game.i18n.localize("DND5E.DistSelf");
  }
}

const { BooleanField: BooleanField$M, SchemaField: SchemaField$X, StringField: StringField$1e } = foundry.data.fields;

/**
 * Field for storing target data.
 */
class TargetField extends SchemaField$X {
  constructor(fields={}, options={}) {
    fields = {
      template: new SchemaField$X({
        count: new FormulaField({ deterministic: true }),
        contiguous: new BooleanField$M(),
        type: new StringField$1e(),
        size: new FormulaField({ deterministic: true }),
        width: new FormulaField({ deterministic: true }),
        height: new FormulaField({ deterministic: true }),
        units: new StringField$1e({ required: true, blank: false, initial: () => defaultUnits("length") })
      }),
      affects: new SchemaField$X({
        count: new FormulaField({ deterministic: true }),
        type: new StringField$1e(),
        choice: new BooleanField$M(),
        special: new StringField$1e()
      }),
      ...fields
    };
    super(fields, options);
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /**
   * Prepare data for this field. Should be called during the `prepareFinalData` stage.
   * @this {ItemDataModel|BaseActivityData}
   * @param {object} rollData  Roll data used for formula replacements.
   * @param {object} [labels]  Object in which to insert generated labels.
   */
  static prepareData(rollData, labels) {
    this.target.affects.scalar = this.target.affects.type
      && (CONFIG.DND5E.individualTargetTypes[this.target.affects.type]?.scalar !== false);
    if ( this.target.affects.scalar ) {
      prepareFormulaValue(this, "target.affects.count", "DND5E.TARGET.FIELDS.target.affects.count.label", rollData);
    } else this.target.affects.count = null;

    this.target.template.dimensions = TargetField.templateDimensions(this.target.template.type);

    if ( this.target.template.type ) {
      this.target.template.count ||= "1";
      if ( this.target.template.dimensions.width ) this.target.template.width ||= "5";
      if ( this.target.template.dimensions.height ) this.target.template.height ||= "5";
      prepareFormulaValue(this, "target.template.count", "DND5E.TARGET.FIELDS.target.template.count.label", rollData);
      prepareFormulaValue(this, "target.template.size", "DND5E.TARGET.FIELDS.target.template.size.label", rollData);
      prepareFormulaValue(this, "target.template.width", "DND5E.TARGET.FIELDS.target.template.width.label", rollData);
      prepareFormulaValue(this, "target.template.height", "DND5E.TARGET.FIELDS.target.template.height.label", rollData);
    } else {
      this.target.template.count = null;
      this.target.template.size = null;
      this.target.template.width = null;
      this.target.template.height = null;
    }

    const pr = getPluralRules();

    // Generate the template label
    const templateConfig = CONFIG.DND5E.areaTargetTypes[this.target.template.type];
    if ( templateConfig ) {
      const parts = [];
      if ( this.target.template.count > 1 ) parts.push(`${this.target.template.count} ×`);
      if ( this.target.template.units in CONFIG.DND5E.movementUnits ) {
        parts.push(formatLength(this.target.template.size, this.target.template.units));
      }
      this.target.template.label = game.i18n.format(
        `${templateConfig.counted}.${pr.select(this.target.template.count || 1)}`, { number: parts.filterJoin(" ") }
      ).trim().capitalize();
    } else this.target.template.label = "";

    // Generate the affects label
    const affectsConfig = CONFIG.DND5E.individualTargetTypes[this.target.affects.type];
    this.target.affects.labels = {
      sheet: affectsConfig?.counted ? game.i18n.format(
        `${affectsConfig.counted}.${this.target.affects.count ? pr.select(this.target.affects.count) : "other"}`, {
          number: this.target.affects.count ? formatNumber(this.target.affects.count)
            : game.i18n.localize(`DND5E.TARGET.Count.${this.target.template.type ? "Every" : "Any"}`)
        }
      ).trim().capitalize() : (affectsConfig?.label ?? ""),
      statblock: game.i18n.format(
        `${affectsConfig?.counted ?? "DND5E.TARGET.Type.Target.Counted"}.${pr.select(this.target.affects.count || 1)}`,
        { number: formatNumber(this.target.affects.count || 1, { words: true }) }
      )
    };

    if ( labels ) labels.target = this.target.template.label || this.target.affects.labels.sheet;
  }

  /* -------------------------------------------- */

  /**
   * Create the template dimensions labels for a template type.
   * @param {string} type  Area of effect type.
   * @returns {{ size: string, [width]: string, [height]: string }}
   */
  static templateDimensions(type) {
    const sizes = CONFIG.DND5E.areaTargetTypes[type]?.sizes;
    const dimensions = { size: "DND5E.AreaOfEffect.Size.Label" };
    if ( sizes ) {
      dimensions.width = sizes.includes("width") && (sizes.includes("length") || sizes.includes("radius"));
      dimensions.height = sizes.includes("height");
      if ( sizes.includes("radius") ) dimensions.size = "DND5E.AreaOfEffect.Size.Radius";
      else if ( sizes.includes("length") ) dimensions.size = "DND5E.AreaOfEffect.Size.Length";
      else if ( sizes.includes("width") ) dimensions.size = "DND5E.AreaOfEffect.Size.Width";
      if ( sizes.includes("thickness") ) dimensions.width = "DND5E.AreaOfEffect.Size.Thickness";
      else if ( dimensions.width ) dimensions.width = "DND5E.AreaOfEffect.Size.Width";
      if ( dimensions.height ) dimensions.height = "DND5E.AreaOfEffect.Size.Height";
    }
    return dimensions;
  }
}

const { DocumentIdField: DocumentIdField$d, NumberField: NumberField$M, SchemaField: SchemaField$W } = foundry.data.fields;

/**
 * Field for storing an active effects applied by an activity.
 */
class AppliedEffectField extends SchemaField$W {
  constructor(fields={}, options={}) {
    fields = {
      _id: new DocumentIdField$d(),
      level: new SchemaField$W({
        min: new NumberField$M({ min: 0, integer: true }),
        max: new NumberField$M({ min: 0, integer: true })
      }),
      ...fields
    };
    Object.entries(fields).forEach(([k, v]) => !v ? delete fields[k] : null);
    super(fields, options);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  initialize(value, model, options={}) {
    const obj = super.initialize(value, model, options);
    const item = model.item;

    Object.defineProperty(obj, "effect", {
      get() { return item?.effects.get(this._id); },
      configurable: true
    });

    return obj;
  }
}

const {
  ArrayField: ArrayField$n, BooleanField: BooleanField$L, DocumentFlagsField, DocumentIdField: DocumentIdField$c,
  FilePathField: FilePathField$3, IntegerSortField: IntegerSortField$2, NumberField: NumberField$L, SchemaField: SchemaField$V, StringField: StringField$1d
} = foundry.data.fields;

/**
 * @import { DamageRollConfiguration, DamageRollProcessConfiguration } from "../../dice/_types.mjs";
 * @import { ActivityData } from "./_types.mjs";
 */

/**
 * Data model for activities.
 * @extends {foundry.abstract.DataModel<ActivityData>}
 * @mixes ActivityData
 */
class BaseActivityData extends foundry.abstract.DataModel {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static LOCALIZATION_PREFIXES = [
    "DND5E.ACTIVITY", "DND5E.ACTIVATION", "DND5E.CONSUMPTION",
    "DND5E.DURATION", "DND5E.RANGE", "DND5E.TARGET", "DND5E.USES"
  ];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return {
      _id: new DocumentIdField$c({ initial: () => foundry.utils.randomID() }),
      type: new StringField$1d({
        blank: false, required: true, readOnly: true, initial: () => this.metadata.type
      }),
      name: new StringField$1d({ initial: undefined }),
      img: new FilePathField$3({ initial: undefined, categories: ["IMAGE"], base64: false }),
      sort: new IntegerSortField$2(),
      activation: new ActivationField({
        override: new BooleanField$L()
      }),
      consumption: new SchemaField$V({
        scaling: new SchemaField$V({
          allowed: new BooleanField$L(),
          max: new FormulaField({ deterministic: true })
        }),
        spellSlot: new BooleanField$L({ initial: true }),
        targets: new ConsumptionTargetsField()
      }),
      description: new SchemaField$V({
        chatFlavor: new StringField$1d()
      }),
      duration: new DurationField({
        concentration: new BooleanField$L(),
        override: new BooleanField$L()
      }),
      effects: new ArrayField$n(new AppliedEffectField()),
      flags: new DocumentFlagsField(),
      range: new RangeField({
        override: new BooleanField$L()
      }),
      target: new TargetField({
        override: new BooleanField$L(),
        prompt: new BooleanField$L({ initial: true })
      }),
      uses: new UsesField(),
      visibility: new SchemaField$V({
        identifier: new IdentifierField(),
        level: new SchemaField$V({
          min: new NumberField$L({ integer: true, min: 0 }),
          max: new NumberField$L({ integer: true, min: 0 })
        }),
        requireAttunement: new BooleanField$L(),
        requireIdentification: new BooleanField$L(),
        requireMagic: new BooleanField$L()
      })
    };
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The primary ability for this activity that will be available as `@mod` in roll data.
   * @type {string|null}
   */
  get ability() {
    return this.isSpell ? this.spellcastingAbility : null;
  }

  /* -------------------------------------------- */

  /**
   * Helper property to translate this activity type into the old `actionType`.
   * @type {string}
   */
  get actionType() {
    return this.metadata.type;
  }

  /* -------------------------------------------- */

  /**
   * A specific set of activation-specific labels displayed in chat cards.
   * @type {object|null}
   */
  get activationLabels() {
    if ( !this.activation.type || this.isSpell ) return null;
    const { activation, duration, range, reach, target } = this.labels;
    return { activation, duration, range, reach, target };
  }

  /* -------------------------------------------- */

  /**
   * Effects that can be applied from this activity.
   * @type {ActiveEffect5e[]|null}
   */
  get applicableEffects() {
    const level = this.relevantLevel;
    return this.effects?.filter(e =>
      e.effect && ((e.level?.min ?? -Infinity) <= level) && (level <= (e.level?.max ?? Infinity))
    ).map(e => e.effect) ?? null;
  }

  /* -------------------------------------------- */

  /**
   * Can consumption scaling be configured?
   * @type {boolean}
   */
  get canConfigureScaling() {
    return this.consumption.scaling.allowed || this.item.system.canConfigureScaling;
  }

  /* -------------------------------------------- */

  /**
   * Is scaling possible with this activity?
   * @type {boolean}
   */
  get canScale() {
    return this.consumption.scaling.allowed || this.item.system.canScale;
  }

  /* -------------------------------------------- */

  /**
   * Can this activity's damage be scaled?
   * @type {boolean}
   */
  get canScaleDamage() {
    return this.consumption.scaling.allowed || this.isScaledScroll || this.item.system.canScaleDamage;
  }

  /* -------------------------------------------- */

  /**
   * Is this activity a rider for a non-applied enchantment?
   * @type {boolean}
   */
  get isRider() {
    return !!this.item.getFlag("dnd5e", "riders.activity")?.includes(this.id);
  }

  /* -------------------------------------------- */

  /**
   * Is this activity on a spell scroll that is scaled.
   * @type {boolean}
   */
  get isScaledScroll() {
    return !!this.item.getFlag("dnd5e", "spellLevel");
  }

  /* -------------------------------------------- */

  /**
   * Is this activity on a spell?
   * @type {boolean}
   */
  get isSpell() {
    return this.item.type === "spell";
  }

  /* -------------------------------------------- */

  /**
   * Determine the level used to determine visibility limits, based on the spell level for spells or either the
   * character or class level, depending on whether `classIdentifier` is set.
   * @type {number}
   */
  get relevantLevel() {
    const keyPath = (this.item.type === "spell") && (this.item.system.level > 0) ? "item.level"
      : this.visibility?.identifier ? `classes.${this.visibility.identifier}.levels` : "details.level";
    return foundry.utils.getProperty(this.getRollData(), keyPath) ?? 0;
  }

  /* -------------------------------------------- */

  /**
   * Does this activity or its item require concentration?
   * @type {boolean}
   */
  get requiresConcentration() {
    return this.duration.concentration;
  }

  /* -------------------------------------------- */

  /**
   * Does activating this activity consume a spell slot?
   * @type {boolean}
   */
  get requiresSpellSlot() {
    if ( !this.isSpell || !this.actor?.system.spells ) return false;
    return this.canScale;
  }

  /* -------------------------------------------- */

  /**
   * Retrieve the spellcasting ability that can be used with this activity.
   * @type {string|null}
   */
  get spellcastingAbility() {
    let ability;
    if ( this.isSpell ) ability = this.item.system.availableAbilities?.first();
    return ability ?? this.actor?.system.attributes?.spellcasting ?? null;
  }

  /* -------------------------------------------- */
  /*  Data Migration                              */
  /* -------------------------------------------- */

  /**
   * Static ID used for the automatically generated activity created during migration.
   * @type {string}
   */
  static INITIAL_ID = staticID("dnd5eactivity");

  /* -------------------------------------------- */

  /**
   * Migrate data from the item to a newly created activity.
   * @param {object} source              Item's candidate source data.
   * @param {object} [options={}]
   * @param {number} [options.offset=0]  Adjust the default ID using this number when creating multiple activities.
   */
  static createInitialActivity(source, { offset=0, ...options }={}) {
    const activityData = this.transformTypeData(source, {
      _id: this.INITIAL_ID.replace("0", offset),
      type: this.metadata.type,
      activation: this.transformActivationData(source, options),
      consumption: this.transformConsumptionData(source, options),
      description: this.transformDescriptionData(source, options),
      duration: this.transformDurationData(source, options),
      effects: this.transformEffectsData(source, options),
      range: this.transformRangeData(source, options),
      target: this.transformTargetData(source, options),
      uses: this.transformUsesData(source, options)
    }, options);
    foundry.utils.setProperty(source, `system.activities.${activityData._id}`, activityData);
    foundry.utils.setProperty(source, "flags.dnd5e.persistSourceMigration", true);
  }

  /* -------------------------------------------- */

  /**
   * Fetch data from the item source and transform it into an activity's activation object.
   * @param {object} source   Item's candidate source data to transform.
   * @param {object} options  Additional options passed to the creation process.
   * @returns {object}        Creation data for new activity.
   */
  static transformActivationData(source, options) {
    if ( source.type === "spell" ) return {};
    return {
      type: source.system.activation?.type === "none" ? "" : (source.system.activation?.type ?? ""),
      value: source.system.activation?.cost ?? null,
      condition: source.system.activation?.condition ?? ""
    };
  }

  /* -------------------------------------------- */

  /**
   * Fetch data from the item source and transform it into an activity's consumption object.
   * @param {object} source   Item's candidate source data to transform.
   * @param {object} options  Additional options passed to the creation process.
   * @returns {object}        Creation data for new activity.
   */
  static transformConsumptionData(source, options) {
    const targets = [];

    const type = {
      attribute: "attribute",
      hitDice: "hitDice",
      material: "material",
      charges: "itemUses"
    }[source.system.consume?.type];

    if ( type ) targets.push({
      type,
      target: source.system.consume?.target ?? "",
      value: source.system.consume?.amount ?? "1",
      scaling: {
        mode: source.system.consume?.scale ? "amount" : "",
        formula: ""
      }
    });

    // If no target type set but this item has max uses, set consumption type to itemUses with blank target
    else if ( source.system.uses?.max ) targets.push({
      type: "itemUses",
      target: "",
      value: "1",
      scaling: {
        mode: source.system.consume?.scale ? "amount" : "",
        formula: ""
      }
    });

    if ( source.system.recharge?.value && source.system.uses?.per ) targets.push({
      type: source.system.uses?.max ? "activityUses" : "itemUses",
      target: "",
      value: "1",
      scaling: { mode: "", formula: "" }
    });

    return {
      targets,
      scaling: {
        allowed: source.system.consume?.scale ?? false,
        max: ""
      }
    };
  }

  /* -------------------------------------------- */

  /**
   * Transform an old damage part into the new damage part format.
   * @param {object} source  Item's candidate source data to transform.
   * @param {string[]} part  The damage part to transform.
   * @returns {object}       Creation data for new activity.
   */
  static transformDamagePartData(source, [formula, type]) {
    const data = {
      number: null,
      denomination: null,
      bonus: "",
      types: type ? [type] : [],
      custom: {
        enabled: false,
        formula: ""
      },
      scaling: {
        mode: source?.system.scaling?.mode !== "none" ? "whole" : "",
        number: null,
        formula: source?.system.scaling?.formula ?? ""
      }
    };

    const parsed = (formula ?? "").match(/^\s*(\d+)d(\d+)(?:\s*([+|-])\s*(@?[\w\d.-]+))?\s*$/i);
    if ( parsed && CONFIG.DND5E.dieSteps.includes(Number(parsed[2])) ) {
      data.number = Number(parsed[1]);
      data.denomination = Number(parsed[2]);
      if ( parsed[4] ) data.bonus = parsed[3] === "-" ? `-${parsed[4]}` : parsed[4];
    } else if ( formula ) {
      data.custom.enabled = true;
      data.custom.formula = formula;
    }

    // If scaling denomination matches the damage denomination, set scaling using number rather than formula
    const scaling = data.scaling.formula.match(/^\s*(\d+)d(\d+)\s*$/i);
    if ( (scaling && (Number(scaling[2]) === data.denomination)) || (source.system.scaling?.mode === "cantrip") ) {
      data.scaling.number = Number(scaling?.[1] || 1);
      data.scaling.formula = "";
    }

    return data;
  }

  /* -------------------------------------------- */

  /**
   * Fetch data from the item source and transform it into an activity's description object.
   * @param {object} source   Item's candidate source data to transform.
   * @param {object} options  Additional options passed to the creation process.
   * @returns {object}        Creation data for new activity.
   */
  static transformDescriptionData(source, options) {
    return {
      chatFlavor: source.system.chatFlavor ?? ""
    };
  }

  /* -------------------------------------------- */

  /**
   * Fetch data from the item source and transform it into an activity's duration object.
   * @param {object} source   Item's candidate source data to transform.
   * @param {object} options  Additional options passed to the creation process.
   * @returns {object}        Creation data for new activity.
   */
  static transformDurationData(source, options) {
    if ( source.type === "spell" ) return {};
    const concentration = !!source.system.properties?.findSplice(p => p === "concentration");
    return {
      concentration,
      value: source.system.duration?.value ?? null,
      units: source.system.duration?.units ?? "inst",
      special: ""
    };
  }

  /* -------------------------------------------- */

  /**
   * Fetch data from the item source and transform it into an activity's effects array.
   * @param {object} source   Item's candidate source data to transform.
   * @param {object} options  Additional options passed to the creation process.
   * @returns {object[]}      Creation data for new activity.
   */
  static transformEffectsData(source, options) {
    return source.effects
      .filter(e => !e.transfer && (e.type !== "enchantment") && (e.flags?.dnd5e?.type !== "enchantment"))
      .map(e => ({ _id: e._id }));
  }

  /* -------------------------------------------- */

  /**
   * Fetch data from the item source and transform it into an activity's range object.
   * @param {object} source   Item's candidate source data to transform.
   * @param {object} options  Additional options passed to the creation process.
   * @returns {object}        Creation data for new activity.
   */
  static transformRangeData(source, options) {
    if ( source.type === "spell" ) return {};
    return {
      value: source.system.range?.value ?? null,
      units: source.system.range?.units ?? "",
      special: ""
    };
  }

  /* -------------------------------------------- */

  /**
   * Fetch data from the item source and transform it into an activity's target object.
   * @param {object} source   Item's candidate source data to transform.
   * @param {object} options  Additional options passed to the creation process.
   * @returns {object}        Creation data for new activity.
   */
  static transformTargetData(source, options) {
    if ( source.type === "spell" ) return {
      prompt: source.system.target?.prompt ?? true
    };

    const data = {
      template: {
        count: "",
        contiguous: false,
        type: "",
        size: "",
        width: "",
        height: "",
        units: source.system.target?.units ?? "ft"
      },
      affects: {
        count: "",
        type: "",
        choice: false,
        special: ""
      },
      prompt: source.system.target?.prompt ?? true
    };

    if ( source.system.target?.type in CONFIG.DND5E.areaTargetTypes ) foundry.utils.mergeObject(data, {
      template: {
        type: source.system.target?.type ?? "",
        size: source.system.target?.value ?? "",
        width: source.system.target?.width ?? ""
      }
    });

    else foundry.utils.mergeObject(data, {
      affects: {
        count: source.system.target?.value ?? "",
        type: source.system.target?.type ?? ""
      }
    });

    return data;
  }

  /* -------------------------------------------- */

  /**
   * Perform any type-specific data transformations.
   * @param {object} source        Item's candidate source data to transform.
   * @param {object} activityData  In progress creation data.
   * @param {object} options       Additional options passed to the creation process.
   * @returns {object}             Creation data for new activity.
   */
  static transformTypeData(source, activityData, options) {
    return activityData;
  }

  /* -------------------------------------------- */

  /**
   * Fetch data from the item source and transform it into an activity's uses object.
   * @param {object} source   Item's candidate source data to transform.
   * @param {object} options  Additional options passed to the creation process.
   * @returns {object}        Creation data for new activity.
   */
  static transformUsesData(source, options) {
    // Do not add a recharge recovery to the activity if the parent item would already get recharge recovery.
    if ( !source.system.recharge?.value || !source.system.uses?.max || !source.system.uses?.per ) {
      return { spent: 0, max: "", recovery: [] };
    }
    return {
      spent: source.system.recharge.charged ? 0 : 1,
      max: "1",
      recovery: [{
        period: "recharge",
        type: "recoverAll",
        formula: String(source.system.recharge.value)
      }]
    };
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /**
   * Prepare data related to this activity.
   */
  prepareData() {
    this.name = this.name || game.i18n.localize(this.metadata?.title);
    this.img = this.img || this.metadata?.img;
    this.labels ??= {};
    const addBaseIndices = data => data?.forEach((d, idx) => Object.defineProperty(d, "_index", { value: idx }));
    addBaseIndices(this.consumption?.targets);
    addBaseIndices(this.damage?.parts);
    addBaseIndices(this.effects);
    addBaseIndices(this.uses?.recovery);
  }

  /* -------------------------------------------- */

  /**
   * Perform final preparation after containing item is prepared.
   * @param {object} [rollData]  Deterministic roll data from the activity.
   */
  prepareFinalData(rollData) {
    rollData ??= this.getRollData({ deterministic: true });

    if ( this.activation ) this._setOverride("activation");
    if ( this.duration ) this._setOverride("duration");
    if ( this.range ) this._setOverride("range");
    if ( this.target ) this._setOverride("target");

    Object.defineProperty(this, "_inferredSource", {
      value: Object.freeze(this.toObject(false)),
      configurable: false,
      enumerable: false,
      writable: false
    });

    if ( this.visibility && !this.isRider ) {
      if ( !this.item.system.properties?.has("mgc") && this.item.system.validProperties.has("mgc") ) {
        this.visibility.requireAttunement = false;
        this.visibility.requireMagic = false;
      } else if ( this.item.system.attunement === "required" ) {
        this.visibility.requireAttunement = this.visibility.requireMagic;
      } else {
        if ( !this.item.system.canAttune ) this.visibility.requireAttunement = false;
        if ( this.isSpell ) this.visibility.requireMagic = true;
      }
      if ( !("identified" in this.item.system) ) this.visibility.requireIdentification = false;
    }

    // TODO: Temporarily add parent to consumption targets & damage parts added by enchantment
    // Can be removed once https://github.com/foundryvtt/foundryvtt/issues/12528 is implemented
    if ( this.consumption?.targets ) this.consumption.targets = this.consumption.targets.map(c => {
      if ( c.parent ) return c;
      return c.clone({}, { parent: this });
    });
    if ( this.damage?.parts ) this.damage.parts = this.damage.parts.map(c => {
      if ( c.parent ) return c;
      return c.clone({}, { parent: this });
    });

    if ( this.activation ) ActivationField.prepareData.call(this, rollData, this.labels);
    if ( this.duration ) DurationField.prepareData.call(this, rollData, this.labels);
    if ( this.range ) RangeField.prepareData.call(this, rollData, this.labels);
    if ( this.target ) TargetField.prepareData.call(this, rollData, this.labels);
    if ( this.uses ) UsesField.prepareData.call(this, rollData, this.labels);

    const actor = this.item.actor;
    if ( !actor || !("consumption" in this) ) return;
    for ( const target of this.consumption.targets ) {
      if ( !["itemUses", "material"].includes(target.type) || !target.target ) continue;

      // Re-link UUID or identifier target to explicit item on the actor
      target.target = this._remapConsumptionTarget(target.target);

      // If targeted item isn't found, display preparation warning
      if ( !actor.items.has(target.target) ) {
        const message = game.i18n.format("DND5E.CONSUMPTION.Warning.MissingItem", {
          activity: this.name, item: this.item.name
        });
        actor._preparationWarnings.push({ message, link: this.uuid, type: "warning" });
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Prepare the label for a compiled and simplified damage formula.
   * @param {object} rollData  Deterministic roll data from the item.
   */
  prepareDamageLabel(rollData) {
    const config = this.getDamageConfig({}, { rollData });
    const rolls = aggregateDamageRolls(config.rolls.map(({ base, data, options, parts }) => {
      const formula = parts.join(" + ");
      try {
        return new CONFIG.Dice.DamageRoll(formula, data, { base, ...options });
      } catch(err) {
        console.warn(`Unable to prepare formula "${formula}" for ${this.name} in item ${this.item.name}${
          this.actor ? ` on ${this.actor.name} (${this.actor.id})` : ""
        } (${this.uuid})`, err);
        return null;
      }
    }).filter(_ => _));
    this.labels.damage = this.labels.damages = rolls.map(roll => {
      let formula;
      try {
        roll.simplify();
        formula = simplifyRollFormula(roll.formula, { preserveFlavor: false });
      } catch(err) {
        console.warn(`Unable to simplify formula "${roll.formula}" for ${this.name} in item ${this.item.name}${
          this.actor ? ` on ${this.actor.name} (${this.actor.id})` : ""
        } (${this.uuid})`, err);
      }

      let label = formula;
      const types = roll.options.types ?? (roll.options.type ? [roll.options.type] : []);
      if ( types.length ) {
        label = `${formula} ${game.i18n.getListFormatter({ type: "conjunction" }).format(
          types.map(p => CONFIG.DND5E.damageTypes[p]?.label ?? CONFIG.DND5E.healingTypes[p]?.label).filter(_ => _)
        )}`;
      }

      return {
        formula, label,
        damageType: types.length === 1 ? types[0] : null
      };
    });
  }

  /* -------------------------------------------- */

  /**
   * Prepare context to display this activity in a parent sheet.
   * @returns {object}
   */
  prepareSheetContext() {
    return { ...this, _id: this._id };
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /**
   * Perform preliminary operations before an Activity is created.
   * @param {object} data     The initial data object provided to the document creation request.
   * @returns {boolean|void}  A return value of false indicates the creation operation should be cancelled.
   * @protected
   */
  _preCreate(data) {}

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Retrieve the action type reflecting changes based on the provided attack mode.
   * @param {string} [attackMode=""]
   * @returns {string}
   */
  getActionType(attackMode="") {
    let actionType = this.actionType;
    if ( (actionType === "mwak") && (attackMode?.startsWith("thrown") || (attackMode === "ranged")) ) return "rwak";
    return actionType;
  }

  /* -------------------------------------------- */

  /**
   * Get the roll parts used to create the damage rolls.
   * @param {Partial<DamageRollProcessConfiguration>} [config={}]  Existing damage configuration to merge into this one.
   * @param {object} [options]                                     Damage configuration options.
   * @param {object} [options.rollData]                            Use pre-existing roll data.
   * @returns {DamageRollProcessConfiguration}
   */
  getDamageConfig(config={}, { rollData }={}) {
    if ( !this.damage?.parts ) return foundry.utils.mergeObject({ rolls: [] }, config);

    const rollConfig = foundry.utils.deepClone(config);
    rollData ??= this.getRollData();
    rollConfig.rolls = this.damage.parts
      .map((d, index) => this._processDamagePart(d, rollConfig, rollData, index))
      .filter(d => d.parts.length)
      .concat(config.rolls ?? []);

    return rollConfig;
  }

  /* -------------------------------------------- */

  /**
   * Process a single damage part into a roll configuration.
   * @param {DamageData} damage                                   Damage to prepare for the roll.
   * @param {Partial<DamageRollProcessConfiguration>} rollConfig  Roll configuration being built.
   * @param {object} rollData                                     Roll data to populate with damage data.
   * @param {number} [index=0]                                    Index of the damage part.
   * @returns {DamageRollConfiguration}
   * @protected
   */
  _processDamagePart(damage, rollConfig, rollData, index=0) {
    const scaledFormula = damage.scaledFormula(rollConfig.scaling ?? rollData.scaling);
    const parts = scaledFormula ? [scaledFormula] : [];
    const data = { ...rollData };

    if ( index === 0 ) {
      const actionType = this.getActionType(rollConfig.attackMode);
      const bonus = foundry.utils.getProperty(this.actor ?? {}, `system.bonuses.${actionType}.damage`);
      if ( bonus && !/^0+$/.test(bonus) ) parts.push(bonus);
      if ( this.item.system.damageBonus ) parts.push(String(this.item.system.damageBonus));
    }

    const lastType = this.item.getFlag("dnd5e", `last.${this.id}.damageType.${index}`);

    return {
      data, parts,
      options: {
        type: (damage.types.has(lastType) ? lastType : null) ?? damage.types.first(),
        types: Array.from(damage.types),
        properties: Array.from(this.item.system.properties ?? [])
          .filter(p => CONFIG.DND5E.itemProperties[p]?.isPhysical)
      }
    };
  }

  /* -------------------------------------------- */

  /**
   * Remap a UUID or identifier in a consumption target to the ID of an item on the actor.
   * @param {string} target
   * @returns {string}
   * @internal
   */
  _remapConsumptionTarget(target) {
    if ( !target || !this.actor || this.actor.items.has(target) ) return target;

    // Re-link UUID target
    const { type } = foundry.utils.parseUuid(target) ?? {};
    if ( type === "Item" ) {
      const item = this.actor.sourcedItems?.get(target)?.first();
      if ( item ) return item.id;
    }

    // Re-link identifier target
    else {
      const item = this.actor.identifiedItems?.get(target)?.first();
      if ( item ) return item.id;
    }

    return target;
  }

  /* -------------------------------------------- */

  /**
   * Add an `canOverride` property to the provided object and, if `override` is `false`, replace the data on the
   * activity with data from the item.
   * @param {string} keyPath  Path of the property to set on the activity.
   * @internal
   */
  _setOverride(keyPath) {
    const obj = foundry.utils.getProperty(this, keyPath);
    Object.defineProperty(obj, "canOverride", {
      value: safePropertyExists(this.item.system, keyPath),
      configurable: true,
      enumerable: false
    });
    if ( obj.canOverride && !obj.override && !this.isRider ) {
      foundry.utils.mergeObject(obj, foundry.utils.getProperty(this.item.system, keyPath));
    }
  }
}

const { ArrayField: ArrayField$m, BooleanField: BooleanField$K, NumberField: NumberField$K, SchemaField: SchemaField$U, StringField: StringField$1c } = foundry.data.fields;

/**
 * @import { AttackDamageRollProcessConfiguration } from "../../dice/_types.mjs";
 * @import { AttackActivityData } from "./_types.mjs";
 */

/**
 * Data model for an attack activity.
 * @extends {BaseActivityData<AttackActivityData>}
 * @mixes AttackActivityData
 */
class BaseAttackActivityData extends BaseActivityData {
  /** @inheritDoc */
  static defineSchema() {
    return {
      ...super.defineSchema(),
      attack: new SchemaField$U({
        ability: new StringField$1c(),
        bonus: new FormulaField(),
        critical: new SchemaField$U({
          threshold: new NumberField$K({ integer: true, positive: true })
        }),
        flat: new BooleanField$K(),
        type: new SchemaField$U({
          value: new StringField$1c(),
          classification: new StringField$1c()
        })
      }),
      damage: new SchemaField$U({
        critical: new SchemaField$U({
          bonus: new FormulaField()
        }),
        includeBase: new BooleanField$K({ initial: true }),
        parts: new ArrayField$m(new DamageField())
      })
    };
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  get ability() {
    if ( this.attack.ability === "none" ) return null;
    if ( this.attack.ability === "spellcasting" ) return this.spellcastingAbility;
    if ( this.attack.ability in CONFIG.DND5E.abilities ) return this.attack.ability;

    const availableAbilities = this.availableAbilities;
    if ( !availableAbilities?.size ) return null;
    if ( availableAbilities?.size === 1 ) return availableAbilities.first();
    const abilities = this.actor?.system.abilities ?? {};
    return availableAbilities.reduce((largest, ability) =>
      (abilities[ability]?.mod ?? -Infinity) > (abilities[largest]?.mod ?? -Infinity) ? ability : largest
    , availableAbilities.first());
  }

  /* -------------------------------------------- */

  /** @override */
  get actionType() {
    const type = this.attack.type;
    return `${type.value === "ranged" ? "r" : "m"}${type.classification === "spell" ? "sak" : "wak"}`;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  get activationLabels() {
    const labels = super.activationLabels;
    if ( labels && (this.item.type === "weapon") && !this.range.override ) {
      if ( this.item.labels?.range ) labels.range = this.item.labels.range;
      if ( this.item.labels?.reach ) labels.reach = this.item.labels.reach;
    }
    return labels;
  }

  /* -------------------------------------------- */

  /**
   * Abilities that could potentially be used with this attack. Unless a specific ability is specified then
   * whichever ability has the highest modifier will be selected when making an attack.
   * @type {Set<string>}
   */
  get availableAbilities() {
    // Defer to item if available and matching attack classification
    if ( this.item.system.availableAbilities && (this.item.type === this.attack.type.classification) ) {
      return this.item.system.availableAbilities;
    }

    // Natural weapons also defer to the item if using any classification other than spell.
    if ( this.item.system.availableAbilities && (this.item.system.type?.value === "natural")
      && (this.attack.type.classification !== "spell") ) {
      return this.item.system.availableAbilities;
    }

    // Spell attack not associated with a single class, use highest spellcasting ability on actor
    if ( this.attack.type.classification === "spell" ) return new Set(
      this.actor?.system.attributes?.spellcasting
        ? [this.actor.system.attributes.spellcasting]
        : Object.values(this.actor?.spellcastingClasses ?? {}).map(c => c.spellcasting.ability)
    );

    // Weapon & unarmed attacks uses melee or ranged ability depending on type, or both if actor is an NPC
    const melee = CONFIG.DND5E.defaultAbilities.meleeAttack;
    const ranged = CONFIG.DND5E.defaultAbilities.rangedAttack;
    return new Set([this.attack.type.value === "melee" ? melee : ranged]);
  }

  /* -------------------------------------------- */

  /**
   * Critical threshold for attacks with this activity.
   * @type {number}
   */
  get criticalThreshold() {
    // TODO: Fetch threshold from ammo
    const threshold = Math.min(
      this.attack.critical.threshold ?? Infinity,
      this.item.system.criticalThreshold ?? Infinity,
      Infinity
    );
    return threshold < Infinity ? threshold : 20;
  }

  /* -------------------------------------------- */

  /**
   * Potential attack types when attacking with this activity.
   * @type {Set<string>}
   */
  get validAttackTypes() {
    const sourceType = this._source.attack.type.value;
    if ( sourceType ) return new Set([sourceType]);
    return this.item.system.validAttackTypes ?? new Set();
  }

  /* -------------------------------------------- */
  /*  Data Migration                              */
  /* -------------------------------------------- */

  /** @override */
  static transformTypeData(source, activityData, options) {
    // For weapons and ammunition, separate the first part from the rest to be used as the base damage and keep the rest
    let damageParts = source.system.damage?.parts ?? [];
    const hasBase = (source.type === "weapon")
      || ((source.type === "consumable") && (source.system?.type?.value === "ammo"));
    if ( hasBase && damageParts.length && !source.system.damage?.base ) {
      const [base, ...rest] = damageParts;
      source.system.damage.parts = [base];
      damageParts = rest;
    }

    return foundry.utils.mergeObject(activityData, {
      attack: {
        ability: source.system.ability ?? "",
        bonus: source.system.attack?.bonus ?? "",
        critical: {
          threshold: source.system.critical?.threshold
        },
        flat: source.system.attack?.flat ?? false,
        type: {
          value: source.system.actionType.startsWith("m") ? "melee" : "ranged",
          classification: source.system.actionType.endsWith("wak") ? "weapon" : "spell"
        }
      },
      damage: {
        critical: {
          bonus: source.system.critical?.damage
        },
        includeBase: true,
        parts: damageParts.map(part => this.transformDamagePartData(source, part)) ?? []
      }
    });
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareData() {
    super.prepareData();
    this.attack.type.value ||= this.item.system.attackType ?? "melee";
    this.attack.type.classification ||= this.item.system.attackClassification ?? "weapon";
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareFinalData(rollData) {
    if ( this.damage.includeBase && this.item.system.offersBaseDamage && this.item.system.damage.base.formula ) {
      const basePart = this.item.system.damage.base.clone(this.item.system.damage.base.toObject(false));
      basePart.base = true;
      basePart.locked = true;
      this.damage.parts.unshift(basePart);
    }

    rollData ??= this.getRollData({ deterministic: true });
    super.prepareFinalData(rollData);
    this.prepareDamageLabel(rollData);

    const { data, parts } = this.getAttackData();
    const roll = new Roll(parts.join("+"), data);
    this.labels.modifier = simplifyRollFormula(roll.formula, { deterministic: true }).replaceAll(" ", "") || "0";
    const formula = simplifyRollFormula(roll.formula).trim() || "0";
    this.labels.toHit = !/^[+-]/.test(formula) ? `+${formula}` : formula;
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * The game term label for this attack.
   * @param {string} [attackMode]  The mode the attack was made with.
   * @returns {string}
   */
  getActionLabel(attackMode) {
    let attackModeLabel;
    if ( attackMode ) {
      const key = attackMode.split("-").map(s => s.capitalize()).join("");
      attackModeLabel = game.i18n.localize(`DND5E.ATTACK.Mode.${key}`);
    }
    const actionType = this.getActionType(attackMode);
    let actionTypeLabel = game.i18n.localize(`DND5E.Action${actionType.toUpperCase()}`);
    const isLegacy = game.settings.get("dnd5e", "rulesVersion") === "legacy";
    const isUnarmed = this.attack.type.classification === "unarmed";
    if ( isUnarmed ) attackModeLabel = game.i18n.localize("DND5E.ATTACK.Classification.Unarmed");
    const isSpell = (actionType === "rsak") || (actionType === "msak");
    if ( isLegacy || isSpell ) return [actionTypeLabel, attackModeLabel].filterJoin(" • ");
    actionTypeLabel = game.i18n.localize(`DND5E.ATTACK.Attack.${actionType}`);
    if ( isUnarmed ) return [actionTypeLabel, attackModeLabel].filterJoin(" • ");
    const weaponType = CONFIG.DND5E.weaponTypeMap[this.item.system.type?.value];
    const weaponTypeLabel = weaponType
      ? game.i18n.localize(`DND5E.ATTACK.Weapon.${weaponType.capitalize()}`)
      : CONFIG.DND5E.weaponTypes[this.item.system.type?.value];
    return [actionTypeLabel, weaponTypeLabel, attackModeLabel].filterJoin(" • ");
  }

  /* -------------------------------------------- */

  /**
   * Get the roll parts used to create the attack roll.
   * @param {object} [config={}]
   * @param {string} [config.ammunition]
   * @param {string} [config.attackMode]
   * @param {string} [config.situational]
   * @returns {{ data: object, parts: string[] }}
   */
  getAttackData({ ammunition, attackMode, situational }={}) {
    const rollData = this.getRollData();
    if ( this.attack.flat ) return CONFIG.Dice.BasicRoll.constructParts({ toHit: this.attack.bonus }, rollData);

    const weapon = this.item.system;
    const ammo = this.actor?.items.get(ammunition)?.system;
    const { parts, data } = CONFIG.Dice.BasicRoll.constructParts({
      mod: this.attack.ability !== "none" ? rollData.mod : null,
      prof: weapon.prof?.term,
      bonus: this.attack.bonus,
      weaponMagic: weapon.magicAvailable ? weapon.magicalBonus : null,
      ammoMagic: ammo?.magicAvailable ? ammo.magicalBonus : null,
      actorBonus: this.actor?.system.bonuses?.[this.getActionType(attackMode)]?.attack,
      situational
    }, rollData);

    // Add exhaustion reduction
    this.actor?.addRollExhaustion(parts, data);

    return { data, parts };
  }

  /* -------------------------------------------- */

  /**
   * Get the roll parts used to create the damage rolls.
   * @param {Partial<AttackDamageRollProcessConfiguration>} [config={}]
   * @returns {AttackDamageRollProcessConfiguration}
   */
  getDamageConfig(config={}) {
    const rollConfig = super.getDamageConfig(config);

    // Handle ammunition
    const ammo = config.ammunition?.system;
    if ( ammo ) {
      const properties = Array.from(ammo.properties).filter(p => CONFIG.DND5E.itemProperties[p]?.isPhysical);
      if ( this.item.system.properties?.has("mgc") && !properties.includes("mgc") ) properties.push("mgc");

      // Add any new physical properties from the ammunition to the damage properties
      for ( const roll of rollConfig.rolls ) {
        for ( const property of properties ) {
          if ( !roll.options.properties.includes(property) ) roll.options.properties.push(property);
        }
      }

      // Add the ammunition's damage
      if ( ammo.damage.base.formula ) {
        const basePartIndex = rollConfig.rolls.findIndex(i => i.base);
        const damage = ammo.damage.base.clone(ammo.damage.base);
        const rollData = this.getRollData();

        // If mode is "replace" and base part is present, replace the base part
        if ( ammo.damage.replace & (basePartIndex !== -1) ) {
          damage.base = true;
          rollConfig.rolls.splice(basePartIndex, 1, this._processDamagePart(damage, config, rollData, basePartIndex));
        }

        // Otherwise stick the ammo damage after base part (or as first part)
        else {
          damage.ammo = true;
          rollConfig.rolls.splice(
            basePartIndex + 1, 0, this._processDamagePart(damage, rollConfig, rollData, basePartIndex + 1)
          );
        }
      }
    }

    if ( this.damage.critical.bonus && !rollConfig.rolls[0]?.options?.critical?.bonusDamage ) {
      foundry.utils.setProperty(rollConfig.rolls[0], "options.critical.bonusDamage", this.damage.critical.bonus);
    }

    return rollConfig;
  }

  /* -------------------------------------------- */

  /**
   * Create a label based on this activity's settings and, if contained in a weapon, additional details from the weapon.
   * @returns {string}
   */
  getRangeLabel() {
    if ( this.item.type !== "weapon" ) return this.labels?.range ?? "";

    const parts = [];

    // Add reach for melee weapons, unless the activity is explicitly specified as a ranged attack
    if ( this.validAttackTypes.has("melee") ) {
      let { reach, units } = this.item.system.range;
      if ( !reach ) reach = convertLength(5, "ft", units);
      parts.push(game.i18n.format("DND5E.RANGE.Formatted.Reach", {
        reach: formatLength(reach, units, { strict: false })
      }));
    }

    // Add range for ranged or thrown weapons, unless the activity is explicitly specified as melee
    if ( this.validAttackTypes.has("ranged") ) {
      let range;
      if ( this.range.override ) range = `${this.range.value} ${this.range.units ?? ""}`;
      else {
        const { value, long, units } = this.item.system.range;
        range = !long || (long === value) ? formatLength(value, units)
          : `${formatNumber(value)}/${formatLength(long, units)}`;
      }
      if ( range ) parts.push(game.i18n.format("DND5E.RANGE.Formatted.Range", { range }));
    }

    return game.i18n.getListFormatter({ type: "disjunction" }).format(parts.filter(_ => _));
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _processDamagePart(damage, rollConfig, rollData, index=0) {
    if ( !damage.base ) return super._processDamagePart(damage, rollConfig, rollData, index);

    // Swap base damage for versatile if two-handed attack is made on versatile weapon
    if ( this.item.system.isVersatile && (rollConfig.attackMode === "twoHanded") ) {
      const versatile = this.item.system.damage.versatile.clone(this.item.system.damage.versatile);
      versatile.base = true;
      versatile.denomination ||= damage.steppedDenomination();
      versatile.number ||= damage.number;
      versatile.types = damage.types;
      damage = versatile;
    }

    const roll = super._processDamagePart(damage, rollConfig, rollData, index);
    roll.base = true;

    if ( this.item.type === "weapon" ) {
      // Ensure `@mod` is present in damage unless it is positive and an off-hand attack or damage is a flat value
      const isDeterministic = new Roll(roll.parts[0]).isDeterministic;
      const includeMod = (!rollConfig.attackMode?.endsWith("offhand") || (roll.data.mod < 0)) && !isDeterministic
        && !((this.attack.type.classification === "spell") && (this.item.system.type.value === "natural"));
      if ( includeMod && !roll.parts.some(p => p.includes("@mod")) ) roll.parts.push("@mod");

      // Add magical bonus
      if ( this.item.system.magicalBonus && this.item.system.magicAvailable ) {
        roll.parts.push("@magicalBonus");
        roll.data.magicalBonus = this.item.system.magicalBonus;
      }

      // Add ammunition bonus
      const ammo = rollConfig.ammunition?.system;
      if ( ammo?.magicAvailable && ammo.magicalBonus ) {
        roll.parts.push("@ammoBonus");
        roll.data.ammoBonus = ammo.magicalBonus;
      }
    }

    const criticalBonusDice = this.actor?.getFlag("dnd5e", "meleeCriticalDamageDice") ?? 0;
    if ( (this.getActionType(rollConfig.attackMode) === "mwak") && (parseInt(criticalBonusDice) !== 0) ) {
      foundry.utils.setProperty(roll, "options.critical.bonusDice", criticalBonusDice);
    }

    return roll;
  }
}

/**
 * @import {
 *   AttackRollDialogConfiguration, AttackRollProcessConfiguration, BasicRollMessageConfiguration, D20RollConfiguration
 * } from "../../dice/_types.mjs";
 * @import { AmmunitionUpdate } from "./_types.mjs";
 */

/**
 * Activity for making attacks and rolling damage.
 */
class AttackActivity extends ActivityMixin(BaseAttackActivityData) {
  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static LOCALIZATION_PREFIXES = [...super.LOCALIZATION_PREFIXES, "DND5E.ATTACK"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static metadata = Object.freeze(
    foundry.utils.mergeObject(super.metadata, {
      type: "attack",
      img: "systems/dnd5e/icons/svg/activity/attack.svg",
      title: "DND5E.ATTACK.Title.one",
      hint: "DND5E.ATTACK.Hint",
      sheetClass: AttackSheet,
      usage: {
        actions: {
          rollAttack: AttackActivity.#rollAttack,
          rollDamage: AttackActivity.#rollDamage
        }
      }
    }, { inplace: false })
  );

  /* -------------------------------------------- */
  /*  Activation                                  */
  /* -------------------------------------------- */

  /** @override */
  _usageChatButtons(message) {
    const buttons = [{
      label: game.i18n.localize("DND5E.Attack"),
      icon: '<i class="dnd5e-icon" data-src="systems/dnd5e/icons/svg/trait-weapon-proficiencies.svg" inert></i>',
      dataset: {
        action: "rollAttack"
      }
    }];
    if ( this.damage.parts.length || this.item.system.properties?.has("amm") ) buttons.push({
      label: game.i18n.localize("DND5E.Damage"),
      icon: '<i class="fa-solid fa-burst" inert></i>',
      dataset: {
        action: "rollDamage"
      }
    });
    return buttons.concat(super._usageChatButtons(message));
  }

  /* -------------------------------------------- */

  /** @override */
  async _triggerSubsequentActions(config, results) {
    this.rollAttack({ event: config.event }, {}, { data: { "flags.dnd5e.originatingMessage": results.message?.id } });
  }

  /* -------------------------------------------- */
  /*  Rolling                                     */
  /* -------------------------------------------- */

  /**
   * Perform an attack roll.
   * @param {AttackRollProcessConfiguration} config  Configuration information for the roll.
   * @param {AttackRollDialogConfiguration} dialog   Configuration for the roll dialog.
   * @param {BasicRollMessageConfiguration} message  Configuration for the roll message.
   * @returns {Promise<D20Roll[]|null>}
   */
  async rollAttack(config={}, dialog={}, message={}) {
    const targets = getTargetDescriptors();

    if ( (this.item.type === "weapon") && (this.item.system.quantity === 0) ) {
      ui.notifications.warn("DND5E.ATTACK.Warning.NoQuantity", { localize: true });
    }

    const buildConfig = this._buildAttackConfig.bind(this);

    const rollConfig = foundry.utils.mergeObject({
      ammunition: this.item.getFlag("dnd5e", `last.${this.id}.ammunition`),
      attackMode: this.item.getFlag("dnd5e", `last.${this.id}.attackMode`),
      elvenAccuracy: this.actor?.getFlag("dnd5e", "elvenAccuracy")
        && CONFIG.DND5E.characterFlags.elvenAccuracy.abilities.includes(this.ability),
      halflingLucky: this.actor?.getFlag("dnd5e", "halflingLucky"),
      mastery: this.item.getFlag("dnd5e", `last.${this.id}.mastery`),
      target: targets.length === 1 ? targets[0].ac : undefined
    }, config);

    const ammunitionOptions = this.item.system.ammunitionOptions ?? [];
    if ( ammunitionOptions.length ) ammunitionOptions.unshift({ value: "", label: "" });
    if ( rollConfig.ammunition === undefined ) rollConfig.ammunition = ammunitionOptions?.[1]?.value;
    else if ( !ammunitionOptions?.find(m => m.value === rollConfig.ammunition) ) {
      rollConfig.ammunition = ammunitionOptions?.[0]?.value;
    }
    const attackModeOptions = this.item.system.attackModes;
    if ( !attackModeOptions?.find(m => m.value === rollConfig.attackMode) ) {
      rollConfig.attackMode = attackModeOptions?.[0]?.value;
    }
    const masteryOptions = this.item.system.masteryOptions;
    if ( !masteryOptions?.find(m => m.value === rollConfig.mastery) ) {
      rollConfig.mastery = masteryOptions?.[0]?.value;
    }

    rollConfig.hookNames = [...(config.hookNames ?? []), "attack", "d20Test"];
    rollConfig.rolls = [CONFIG.Dice.D20Roll.mergeConfigs({
      options: {
        ammunition: rollConfig.ammunition,
        attackMode: rollConfig.attackMode,
        criticalSuccess: this.criticalThreshold,
        mastery: rollConfig.mastery
      }
    }, config.rolls?.shift())].concat(config.rolls ?? []);
    rollConfig.subject = this;

    const dialogConfig = foundry.utils.mergeObject({
      applicationClass: AttackRollConfigurationDialog,
      options: {
        ammunitionOptions: rollConfig.ammunition !== false ? ammunitionOptions : [],
        attackModeOptions,
        buildConfig,
        masteryOptions: (masteryOptions?.length > 1) && !config.mastery ? masteryOptions : [],
        position: {
          top: config.event ? config.event.clientY - 80 : null,
          left: window.innerWidth - 710
        },
        window: {
          title: game.i18n.localize("DND5E.AttackRoll"),
          subtitle: this.item.name,
          icon: this.item.img
        }
      }
    }, dialog);

    const messageConfig = foundry.utils.mergeObject({
      create: true,
      data: {
        flavor: `${this.item.name} - ${game.i18n.localize("DND5E.AttackRoll")}`,
        flags: {
          dnd5e: {
            ...this.messageFlags,
            messageType: "roll",
            roll: { type: "attack" }
          }
        },
        speaker: ChatMessage.getSpeaker({ actor: this.actor })
      }
    }, message);

    const rolls = await CONFIG.Dice.D20Roll.buildConfigure(rollConfig, dialogConfig, messageConfig);
    await CONFIG.Dice.D20Roll.buildEvaluate(rolls, rollConfig, messageConfig);
    if ( !rolls.length ) return null;
    for ( const key of ["ammunition", "attackMode", "mastery"] ) {
      if ( !rolls[0].options[key] ) continue;
      foundry.utils.setProperty(messageConfig.data, `flags.dnd5e.roll.${key}`, rolls[0].options[key]);
    }
    await CONFIG.Dice.D20Roll.buildPost(rolls, rollConfig, messageConfig);

    const flags = {};
    let ammoUpdate = null;

    const canUpdate = this.item.isOwner && !this.item.inCompendium;
    if ( rolls[0].options.ammunition ) {
      const ammo = this.actor?.items.get(rolls[0].options.ammunition);
      if ( ammo ) {
        if ( !ammo.system.properties?.has("ret") ) {
          ammoUpdate = { id: ammo.id, quantity: Math.max(0, ammo.system.quantity - 1) };
          ammoUpdate.destroy = ammo.system.uses.autoDestroy && (ammoUpdate.quantity === 0);
        }
        flags.ammunition = rolls[0].options.ammunition;
      }
    } else if ( rolls[0].options.attackMode?.startsWith("thrown") && !this.item.system.properties?.has("ret") ) {
      ammoUpdate = { id: this.item.id, quantity: Math.max(0, this.item.system.quantity - 1) };
    } else if ( !rolls[0].options.ammunition && dialogConfig.options?.ammunitionOptions?.length ) {
      flags.ammunition = "";
    }
    if ( rolls[0].options.attackMode ) flags.attackMode = rolls[0].options.attackMode;
    else if ( rollConfig.attackMode ) rolls[0].options.attackMode = rollConfig.attackMode;
    if ( rolls[0].options.mastery ) flags.mastery = rolls[0].options.mastery;
    if ( canUpdate && !foundry.utils.isEmpty(flags) && (this.actor && this.actor.items.has(this.item.id)) ) {
      await this.item.setFlag("dnd5e", `last.${this.id}`, flags);
    }

    /**
     * A hook event that fires after an attack has been rolled but before any ammunition is consumed.
     * @function dnd5e.rollAttack
     * @memberof hookEvents
     * @param {D20Roll[]} rolls                        The resulting rolls.
     * @param {object} data
     * @param {AttackActivity|null} data.subject       The Activity that performed the attack.
     * @param {AmmunitionUpdate|null} data.ammoUpdate  Any updates related to ammo consumption for this attack.
     */
    Hooks.callAll("dnd5e.rollAttack", rolls, { subject: this, ammoUpdate });
    Hooks.callAll("dnd5e.rollAttackV2", rolls, { subject: this, ammoUpdate });

    // Commit ammunition consumption on attack rolls resource consumption if the attack roll was made
    if ( canUpdate && ammoUpdate?.destroy ) {
      // If ammunition was deleted, store a copy of it in the roll message
      const data = this.actor.items.get(ammoUpdate.id).toObject();
      const messageId = messageConfig.data?.flags?.dnd5e?.originatingMessage
        ?? rollConfig.event?.target.closest("[data-message-id]")?.dataset.messageId;
      const attackMessage = dnd5e.registry.messages.get(messageId, "attack")?.pop();
      await attackMessage?.setFlag("dnd5e", "roll.ammunitionData", data);
      await this.actor.deleteEmbeddedDocuments("Item", [ammoUpdate.id]);
    }
    else if ( canUpdate && ammoUpdate ) await this.actor?.updateEmbeddedDocuments("Item", [
      { _id: ammoUpdate.id, "system.quantity": ammoUpdate.quantity }
    ]);

    /**
     * A hook event that fires after an attack has been rolled and ammunition has been consumed.
     * @function dnd5e.postRollAttack
     * @memberof hookEvents
     * @param {D20Roll[]} rolls                   The resulting rolls.
     * @param {object} data
     * @param {AttackActivity|null} data.subject  The activity that performed the attack.
     */
    Hooks.callAll("dnd5e.postRollAttack", rolls, { subject: this });

    return rolls;
  }

  /* -------------------------------------------- */

  /**
   * Configure a roll config for each roll performed as part of the attack process. Will be called once per roll
   * in the process each time an option is changed in the roll configuration interface.
   * @param {AttackRollProcessConfiguration} process       Configuration for the entire rolling process.
   * @param {D20RollConfiguration} config                  Configuration for a specific roll.
   * @param {FormDataExtended} [formData]                  Any data entered into the rolling prompt.
   * @param {number} index                                 Index of the roll within all rolls being prepared.
   */
  _buildAttackConfig(process, config, formData, index) {
    const ammunition = formData?.get("ammunition") ?? process.ammunition;
    const attackMode = formData?.get("attackMode") ?? process.attackMode;
    const mastery = formData?.get("mastery") ?? process.mastery;

    let { parts, data } = this.getAttackData({ ammunition, attackMode });
    const options = config.options ?? {};
    if ( ammunition !== undefined ) options.ammunition = ammunition;
    if ( attackMode !== undefined ) options.attackMode = attackMode;
    if ( mastery !== undefined ) options.mastery = mastery;

    config.parts = [...(config.parts ?? []), ...parts];
    config.data = { ...data, ...(config.data ?? {}) };
    config.options = options;
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle performing an attack roll.
   * @this {AttackActivity}
   * @param {PointerEvent} event     Triggering click event.
   * @param {HTMLElement} target     The capturing HTML element which defined a [data-action].
   * @param {ChatMessage5e} message  Message associated with the activation.
   */
  static #rollAttack(event, target, message) {
    this.rollAttack({ event });
  }

  /* -------------------------------------------- */

  /**
   * Handle performing a damage roll.
   * @this {AttackActivity}
   * @param {PointerEvent} event     Triggering click event.
   * @param {HTMLElement} target     The capturing HTML element which defined a [data-action].
   * @param {ChatMessage5e} message  Message associated with the activation.
   */
  static #rollDamage(event, target, message) {
    const lastAttack = message.getAssociatedRolls("attack").pop();
    const attackMode = lastAttack?.getFlag("dnd5e", "roll.attackMode");

    // Fetch the ammunition used with the last attack roll
    let ammunition;
    const actor = lastAttack?.getAssociatedActor();
    if ( actor ) {
      const storedData = lastAttack.getFlag("dnd5e", "roll.ammunitionData");
      ammunition = storedData
        ? new Item.implementation(storedData, { parent: actor })
        : actor.items.get(lastAttack.getFlag("dnd5e", "roll.ammunition"));
    }

    const isCritical = lastAttack?.rolls[0]?.isCritical;
    const dialogConfig = {};
    if ( isCritical ) dialogConfig.options = { defaultButton: "critical" };

    this.rollDamage({ event, ammunition, attackMode, isCritical }, dialogConfig);
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async getFavoriteData() {
    return foundry.utils.mergeObject(await super.getFavoriteData(), { modifier: this.labels.modifier });
  }
}

/**
 * Sheet for the cast activity.
 */
class CastSheet extends ActivitySheet {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["cast-activity"],
    actions: {
      removeSpell: CastSheet.#removeSpell
    }
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  static PARTS = {
    ...super.PARTS,
    effect: {
      template: "systems/dnd5e/templates/activity/cast-effect.hbs",
      templates: [
        "systems/dnd5e/templates/activity/parts/cast-spell.hbs",
        "systems/dnd5e/templates/activity/parts/cast-details.hbs"
      ]
    }
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    return {
      ...await super._prepareContext(options),
      spell: await fromUuid(this.activity.spell.uuid)
    };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareEffectContext(context, options) {
    context = await super._prepareEffectContext(context, options);

    if ( context.spell ) {
      context.contentLink = context.spell.toAnchor().outerHTML;
      if ( context.spell.system.level > 0 ) context.levelOptions = Object.entries(CONFIG.DND5E.spellLevels)
        .filter(([level]) => Number(level) >= context.spell.system.level)
        .map(([value, label]) => ({ value, label }));
    }

    context.abilityOptions = [
      { value: "", label: game.i18n.localize("DND5E.Spellcasting") },
      { rule: true },
      ...Object.entries(CONFIG.DND5E.abilities).map(([value, { label }]) => ({ value, label }))
    ];
    context.propertyOptions = Array.from(CONFIG.DND5E.validProperties.spell).map(value => ({
      value, label: CONFIG.DND5E.itemProperties[value]?.label ?? ""
    }));

    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareIdentityContext(context, options) {
    context = await super._prepareIdentityContext(context, options);
    context.behaviorFields = [{
      field: context.fields.spell.fields.spellbook,
      value: context.source.spell.spellbook,
      input: context.inputs.createCheckboxInput
    }];
    if ( context.spell ) context.placeholder = { name: context.spell.name, img: context.spell.img };
    const requireAttunementField = context.visibilityFields.find(f => f.field.name === "requireAttunement");
    if ( requireAttunementField?.disabled ) requireAttunementField.value = true;
    const requireMagicField = context.visibilityFields.find(f => f.field.name === "requireMagic");
    if ( requireMagicField ) Object.assign(requireMagicField, { disabled: true, value: true });
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _getTabs() {
    const tabs = super._getTabs();
    tabs.effect.label = "DND5E.CAST.SECTIONS.Spell";
    tabs.effect.icon = "fa-solid fa-wand-sparkles";
    return tabs;
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle removing the associated spell.
   * @this {CastSheet}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static #removeSpell(event, target) {
    this.activity.update({ "spell.uuid": null });
  }
}

const { BooleanField: BooleanField$J, DocumentUUIDField: DocumentUUIDField$b, NumberField: NumberField$J, SchemaField: SchemaField$T, SetField: SetField$B, StringField: StringField$1b } = foundry.data.fields;

/**
 * @import { CastActivityData } from "./_types.mjs";
 */

/**
 * Data model for a Cast activity.
 * @extends {BaseActivityData<CastActivityData>}
 * @mixes CastActivityData
 */
class BaseCastActivityData extends BaseActivityData {
  /** @inheritDoc */
  static defineSchema() {
    const schema = super.defineSchema();
    delete schema.effects;
    return {
      ...schema,
      spell: new SchemaField$T({
        ability: new StringField$1b(),
        challenge: new SchemaField$T({
          attack: new NumberField$J(),
          save: new NumberField$J(),
          override: new BooleanField$J()
        }),
        level: new NumberField$J(),
        properties: new SetField$B(new StringField$1b(), { initial: ["vocal", "somatic", "material"] }),
        spellbook: new BooleanField$J({ initial: true }),
        uuid: new DocumentUUIDField$b({ type: "Item" })
      })
    };
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareFinalData(rollData) {
    const spell = fromUuidSync(this.spell.uuid) ?? this.cachedSpell;
    if ( spell ) {
      this.name = this._source.name || spell.name || this.name;
      this.img = this._source.img || spell.img || this.img;
    }

    this.visibility.requireMagic = true;
    super.prepareFinalData(rollData);

    for ( const field of ["activation", "duration", "range", "target"] ) {
      Object.defineProperty(this[field], "canOverride", {
        value: true,
        configurable: true,
        enumerable: false
      });
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareSheetContext() {
    const context = super.prepareSheetContext();
    const cachedSpell = this.cachedSpell;
    if ( cachedSpell ) {
      const spellLabels = { ...(cachedSpell.labels ?? {}) };
      delete spellLabels.recovery;
      context.labels = foundry.utils.mergeObject(context.labels, spellLabels, { inplace: false });
      context.save = { ...cachedSpell.system.activities?.getByType("save")[0]?.save };
    }
    return context;
  }
}

/**
 * @import {
 *   ActivityDialogConfiguration, ActivityMessageConfiguration, ActivityUsageResults, ActivityUseConfiguration
 * } from "./_types.mjs";
 */

/**
 * Activity for casting a spell from another item.
 */
class CastActivity extends ActivityMixin(BaseCastActivityData) {
  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /**
   * Static ID used for the enchantment that modifies spell data.
   */
  static ENCHANTMENT_ID = staticID("dnd5espellchanges");

  /* -------------------------------------------- */

  /** @inheritDoc */
  static LOCALIZATION_PREFIXES = [...super.LOCALIZATION_PREFIXES, "DND5E.CAST"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static metadata = Object.freeze(
    foundry.utils.mergeObject(super.metadata, {
      type: "cast",
      img: "systems/dnd5e/icons/svg/activity/cast.svg",
      title: "DND5E.CAST.Title",
      hint: "DND5E.CAST.Hint",
      sheetClass: CastSheet
    }, { inplace: false })
  );

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Cached copy of the associated spell stored on the actor.
   * @type {Item5e|void}
   */
  get cachedSpell() {
    return this.actor?.sourcedItems.get(this.spell.uuid)
      ?.find(i => i.getFlag("dnd5e", "cachedFor") === this.relativeUUID);
  }

  /* -------------------------------------------- */

  /**
   * Should this spell be listed in the actor's spellbook?
   * @type {boolean}
   */
  get displayInSpellbook() {
    return this.canUse && (this.item.system.magicAvailable !== false) && this.spell.spellbook;
  }

  /* -------------------------------------------- */
  /*  Activation                                  */
  /* -------------------------------------------- */

  /** @override */
  async use(usage={}, dialog={}, message={}) {
    if ( !this.item.isEmbedded || this.item.pack ) return;
    if ( !this.item.isOwner ) {
      ui.notifications.error("DND5E.DocumentUseWarn", { localize: true });
      return;
    }

    /**
     * A hook event that fires before a linked spell is used by a Cast activity.
     * @function dnd5e.preUseLinkedSpell
     * @memberof hookEvents
     * @param {CastActivity} activity                                Cast activity being used.
     * @param {Partial<ActivityUseConfiguration>} usageConfig        Configuration info for the activation.
     * @param {Partial<ActivityDialogConfiguration>} dialogConfig    Configuration info for the usage dialog.
     * @param {Partial<ActivityMessageConfiguration>} messageConfig  Configuration info for the created chat message.
     * @returns {boolean}  Explicitly return `false` to prevent activity from being used.
     */
    if ( Hooks.call("dnd5e.preUseLinkedSpell", this, usage, dialog, message) === false ) return;

    let spell = this.cachedSpell;
    if ( !spell ) {
      [spell] = await this.actor.createEmbeddedDocuments("Item", [await this.getCachedSpellData()]);
    }

    const results = await spell.use({ ...usage, legacy: false }, dialog, message);

    /**
     * A hook event that fires after a linked spell is used by a Cast activity.
     * @function dnd5e.postUseLinkedSpell
     * @memberof hookEvents
     * @param {CastActivity} activity                          Activity being activated.
     * @param {Partial<ActivityUseConfiguration>} usageConfig  Configuration data for the activation.
     * @param {ActivityUsageResults} results                   Final details on the activation.
     */
    if ( results ) Hooks.callAll("dnd5e.postUseLinkedSpell", this, usage, results);

    return results;
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Prepare the data for the cached spell to store on the actor.
   * @returns {Promise<object|void>}
   */
  async getCachedSpellData() {
    const originalSpell = await fromUuid(this.spell.uuid);
    if ( !originalSpell ) return;
    return originalSpell.clone({
      effects: [
        ...originalSpell.effects.map(e => e.toObject()),
        {
          _id: this.constructor.ENCHANTMENT_ID,
          type: "enchantment",
          name: game.i18n.localize("DND5E.CAST.Enchantment.Name"),
          img: "systems/dnd5e/icons/svg/activity/cast.svg",
          origin: this.uuid,
          changes: this.getSpellChanges()
        }
      ],
      flags: {
        dnd5e: {
          cachedFor: this.relativeUUID
        }
      },
      _stats: { compendiumSource: this.spell.uuid }
    }).toObject();
  }

  /* -------------------------------------------- */

  /**
   * Create spell changes based on the activity's configuration.
   * @returns {object[]}
   */
  getSpellChanges() {
    const changes = [];
    const source = this.toObject();

    // Override spell details
    for ( const type of ["activation", "duration", "range", "target"] ) {
      if ( !this[type].override ) continue;
      const data = source[type];
      delete data.override;
      changes.push({ key: `system.${type}`, mode: CONST.ACTIVE_EFFECT_MODES.OVERRIDE, value: JSON.stringify(data) });
    }

    // Set the casting ability
    if ( this.spell.ability ) changes.push({
      key: "system.ability", mode: CONST.ACTIVE_EFFECT_MODES.OVERRIDE, value: this.spell.ability
    });

    // Remove ignored properties
    for ( const property of this.spell.properties ) {
      changes.push({ key: "system.properties", mode: CONST.ACTIVE_EFFECT_MODES.ADD, value: `-${property}` });
    }

    // Set challenge overrides
    const challenge = this.spell.challenge;
    if ( challenge.override && challenge.attack ) changes.push(
      { key: "activities[attack].attack.bonus", mode: CONST.ACTIVE_EFFECT_MODES.OVERRIDE, value: challenge.attack },
      { key: "activities[attack].attack.flat", mode: CONST.ACTIVE_EFFECT_MODES.OVERRIDE, value: true },
      { key: "activities[summon].flat.attack", mode: CONST.ACTIVE_EFFECT_MODES.OVERRIDE, value: challenge.attack }
    );
    if ( challenge.override && challenge.save ) changes.push(
      { key: "activities[save].save.dc.calculation", mode: CONST.ACTIVE_EFFECT_MODES.OVERRIDE, value: "" },
      { key: "activities[save].save.dc.formula", mode: CONST.ACTIVE_EFFECT_MODES.OVERRIDE, value: challenge.save },
      { key: "activities[summon].flat.save", mode: CONST.ACTIVE_EFFECT_MODES.OVERRIDE, value: challenge.save }
    );

    return changes;
  }

  /* -------------------------------------------- */
  /*  Importing and Exporting                     */
  /* -------------------------------------------- */

  /** @override */
  static availableForItem(item) {
    return item.type !== "spell";
  }
}

/**
 * @import { SelectChoicesEntry } from "../_types.mjs";
 */

/**
 * Object with a number of methods for performing actions on a nested set of choices.
 *
 * @param {Object<string, SelectChoicesEntry>} [choices={}]  Initial choices for the object.
 */
class SelectChoices {
  constructor(choices={}) {
    const clone = foundry.utils.deepClone(choices);
    for ( const value of Object.values(clone) ) {
      if ( !value.children || (value.children instanceof SelectChoices) ) continue;
      value.category = true;
      value.children = new this.constructor(value.children);
    }
    Object.assign(this, clone);
  }

  /* -------------------------------------------- */

  /**
   * Are there no entries in this choices object.
   * @type {boolean}
   */
  get isEmpty() {
    return Object.keys(this).length === 0;
  }

  /* -------------------------------------------- */

  /**
   * Create a set of available choice keys.
   * @param {Set<string>} [set]  Existing set to which the values will be added.
   * @returns {Set<string>}
   */
  asSet(set) {
    set ??= new Set();
    for ( const [key, choice] of Object.entries(this) ) {
      if ( choice.children ) choice.children.asSet(set);
      else set.add(key);
    }
    return set;
  }

  /* -------------------------------------------- */

  /**
   * Create a clone of this object.
   * @returns {SelectChoices}
   */
  clone() {
    const newData = {};
    for ( const [key, value] of Object.entries(this) ) {
      newData[key] = foundry.utils.deepClone(value);
      if ( value.children ) newData[key].children = value.children.clone();
    }
    const clone = new this.constructor(newData);
    return clone;
  }

  /* -------------------------------------------- */

  /**
   * Find key and value for the provided key or key suffix.
   * @param {string} key  Full prefixed key (e.g. `tool:art:alchemist`) or just the suffix (e.g. `alchemist`).
   * @returns {[string, SelectChoicesEntry]|null}  An array with the first value being the matched key,
   *                                               and the second being the value.
   */
  find(key) {
    for ( const [k, v] of Object.entries(this) ) {
      if ( (k === key) || k.endsWith(`:${key}`) ) {
        return [k, v];
      } else if ( v.children ) {
        const result = v.children.find(key);
        if ( result ) return result;
      }
    }
    return null;
  }

  /* -------------------------------------------- */

  /**
   * Execute the provided function for each entry in the object.
   * @param {Function} func  Function to execute on each entry. Receives the trait key and value.
   */
  forEach(func) {
    for ( const [key, value] of Object.entries(this) ) {
      func(key, value);
      if ( value.children ) value.children.forEach(func);
    }
  }

  /* -------------------------------------------- */

  /**
   * Merge another SelectChoices object into this one.
   * @param {SelectChoices} other
   * @param {object} [options={}]
   * @param {boolean} [options.inplace=true]  Should this SelectChoices be mutated or a new one returned?
   * @returns {SelectChoices}
   */
  merge(other, { inplace=true }={}) {
    if ( !inplace ) return this.clone().merge(other);
    return foundry.utils.mergeObject(this, other);
  }

  /* -------------------------------------------- */

  /**
   * Internal sorting method.
   * @param {object} lhs
   * @param {object} rhs
   * @returns {number}
   * @protected
   */
  _sort(lhs, rhs) {
    if ( (lhs.sorting === false) && (rhs.sorting === false) ) return 0;
    if ( lhs.sorting === false ) return -1;
    if ( rhs.sorting === false ) return 1;
    return lhs.label.localeCompare(rhs.label, game.i18n.lang);
  }

  /* -------------------------------------------- */

  /**
   * Sort the entries using the label.
   * @param {object} [options={}]
   * @param {boolean} [options.inplace=true]  Should this SelectChoices be mutated or a new one returned?
   * @returns {SelectChoices}
   */
  sort({ inplace=true }={}) {
    const sorted = new SelectChoices(sortObjectEntries(this, this._sort));

    if ( inplace ) {
      for ( const key of Object.keys(this) ) delete this[key];
      this.merge(sorted);
      for ( const entry of Object.values(this) ) {
        if ( entry.children ) entry.children.sort();
      }
      return this;
    }

    else {
      for ( const entry of Object.values(sorted) ) {
        if ( entry.children ) entry.children = entry.children.sort({ inplace });
      }
      return sorted;
    }
  }

  /* -------------------------------------------- */

  /**
   * Filters choices in place to only include the provided keys.
   * @param {Set<string>|SelectChoices} filter   Keys of traits to retain or another SelectChoices object.
   * @param {object} [options={}]
   * @param {boolean} [options.inplace=true]     Should this SelectChoices be mutated or a new one returned?
   * @returns {SelectChoices}                    This SelectChoices with filter applied.
   *
   * @example
   * const choices = new SelectChoices({
   *   categoryOne: { label: "One" },
   *   categoryTwo: { label: "Two", children: {
   *     childOne: { label: "Child One" },
   *     childTwo: { label: "Child Two" }
   *   } }
   * });
   *
   * // Results in only categoryOne
   * choices.filter(new Set(["categoryOne"]));
   *
   * // Results in only categoryTwo, but none if its children
   * choices.filter(new Set(["categoryTwo"]));
   *
   * // Results in categoryTwo and all of its children
   * choices.filter(new Set(["categoryTwo:*"]));
   *
   * // Results in categoryTwo with only childOne
   * choices.filter(new Set(["categoryTwo:childOne"]));
   *
   * // Results in categoryOne, plus categoryTwo with only childOne
   * choices.filter(new Set(["categoryOne", "categoryTwo:childOne"]));
   *
   * @example
   * const choices = new SelectChoices({
   *   "type:categoryOne": { label: "One" },
   *   "type:categoryTwo": { label: "Two", children: {
   *     "type:categoryOne:childOne": { label: "Child One" },
   *     "type:categoryOne:childTwo": { label: "Child Two" }
   *   } }
   * });
   *
   * // Results in no changes
   * choices.filter(new Set(["type:*"]));
   *
   * // Results in only categoryOne
   * choices.filter(new Set(["type:categoryOne"]));
   *
   * // Results in categoryTwo and all of its children
   * choices.filter(new Set(["type:categoryTwo:*"]));
   *
   * // Results in categoryTwo with only childOne
   * choices.filter(new Set(["type:categoryTwo:childOne"]));
   */
  filter(filter, { inplace=true }={}) {
    if ( !inplace ) return this.clone().filter(filter);
    if ( filter instanceof SelectChoices ) filter = filter.asSet();

    for ( const [key, trait] of Object.entries(this) ) {
      // Remove children if direct match and no wildcard for this category present
      const wildcardKey = key.replace(/(:|^)(\w+)$/, "$1*");
      if ( filter.has(key) && !filter.has(wildcardKey) ) {
        if ( trait.children ) delete trait.children;
      }

      // Check children, remove entry if no children match filter
      else if ( !filter.has(wildcardKey) && !filter.has(`${key}:*`) ) {
        if ( trait.children ) trait.children.filter(filter);
        if ( !trait.children || trait.children.isEmpty ) delete this[key];
      }

      // Remove ALL entries if wildcard is used
      else if ( filter.has(wildcardKey) && key.endsWith(":ALL") ) delete this[key];
    }

    return this;
  }

  /* -------------------------------------------- */

  /**
   * Removes in place any traits or categories the keys of which are included in the exclusion set.
   * Note: Wildcard keys are not supported with this method.
   * @param {Set<string>} keys                Set of keys to remove from the choices.
   * @param {object} [options={}]
   * @param {boolean} [options.inplace=true]  Should this SelectChoices be mutated or a new one returned?
   * @returns {SelectChoices}                 This SelectChoices with excluded keys removed.
   *
   * @example
   * const choices = new SelectChoices({
   *   categoryOne: { label: "One" },
   *   categoryTwo: { label: "Two", children: {
   *     childOne: { label: "Child One" },
   *     childTwo: { label: "Child Two" }
   *   } }
   * });
   *
   * // Results in categoryOne being removed
   * choices.exclude(new Set(["categoryOne"]));
   *
   * // Results in categoryOne and childOne being removed, but categoryTwo and childTwo remaining
   * choices.exclude(new Set(["categoryOne", "categoryTwo:childOne"]));
   */
  exclude(keys, { inplace=true }={}) {
    if ( !inplace ) return this.clone().exclude(keys);
    for ( const [key, trait] of Object.entries(this) ) {
      if ( keys.has(key) ) delete this[key];
      else if ( trait.children ) trait.children = trait.children.exclude(keys);
    }
    return this;
  }
}

/**
 * Cached version of the base items compendia indices with the needed subtype fields.
 * @type {object}
 * @private
 */
const _cachedIndices = {};

/**
 * Determine the appropriate label to use for a trait category.
 * @param {object|string} data  Category for which to fetch the label.
 * @param {object} config       Trait configuration data.
 * @returns {string}
 * @private
 */
function _innerLabel(data, config) {
  return foundry.utils.getType(data) === "Object"
    ? foundry.utils.getProperty(data, config.labelKeyPath ?? "label") : data;
}

/* -------------------------------------------- */
/*  Application                                 */
/* -------------------------------------------- */

/**
 * Get the schema fields for this trait on the actor.
 * @param {Actor5e} actor  Actor for which to get the fields.
 * @param {string} trait   Trait as defined in `CONFIG.DND5E.traits`.
 * @returns {object|void}
 */
function actorFields(actor, trait) {
  const keyPath = actorKeyPath(trait);
  return (keyPath.startsWith("system.")
    ? actor.system.schema.getField(keyPath.slice(7))
    : actor.schema.getField(keyPath))?.fields;
}

/* -------------------------------------------- */

/**
 * 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 `system.traits.${trait}`;
}

/* -------------------------------------------- */

/**
 * Get the current trait values for the provided actor.
 * @param {Actor5e} actor  Actor from which to retrieve the values.
 * @param {string} trait   Trait as defined in `CONFIG.DND5E.traits`.
 * @returns {Object<number>}
 */
async function actorValues(actor, trait) {
  const keyPath = actorKeyPath(trait);
  const data = foundry.utils.getProperty(actor._source, keyPath);
  if ( !data ) return {};
  const values = {};
  const traitChoices = await choices(trait, {prefixed: true});

  const setValue = (k, v) => {
    const result = traitChoices.find(k);
    if ( result ) values[result[0]] = v;
  };

  if ( ["skills", "tool"].includes(trait) ) {
    Object.entries(data).forEach(([k, d]) => setValue(k, d.value));
  } else if ( trait === "saves" ) {
    Object.entries(data).forEach(([k, d]) => setValue(k, d.proficient));
  } else if ( trait === "dm" ) {
    Object.entries(data.amount).forEach(([k, d]) => setValue(k, d));
  } else {
    data.value?.forEach(v => setValue(v, 1));
  }

  if ( trait === "weapon" ) data.mastery?.value?.forEach(v => setValue(v, 2));

  return values;
}

/* -------------------------------------------- */

/**
 * Calculate the change key path for a provided trait key.
 * @param {string} key      Key for a trait to set.
 * @param {string} [trait]  Trait as defined in `CONFIG.DND5E.traits`, only needed if key isn't prefixed.
 * @returns {string|void}
 */
function changeKeyPath(key, trait) {
  const split = key.split(":");
  if ( !trait ) trait = split.shift();

  const traitConfig = CONFIG.DND5E.traits[trait];
  if ( !traitConfig ) return;

  let keyPath = actorKeyPath(trait);

  if ( trait === "saves" ) {
    return `${keyPath}.${split.pop()}.proficient`;
  } else if ( ["skills", "tool"].includes(trait) ) {
    return `${keyPath}.${split.pop()}.value`;
  } else {
    return `${keyPath}.value`;
  }
}

/* -------------------------------------------- */
/*  Trait Lists                                 */
/* -------------------------------------------- */

/**
 * Build up a trait structure containing all of the children gathered from config & base items.
 * @param {string} trait       Trait as defined in `CONFIG.DND5E.traits`.
 * @returns {Promise<object>}  Object with trait categories and children.
 */
async function categories(trait) {
  const traitConfig = CONFIG.DND5E.traits[trait];
  const config = foundry.utils.deepClone(CONFIG.DND5E[traitConfig.configKey ?? trait]);

  for ( const key of Object.keys(config) ) {
    if ( foundry.utils.getType(config[key]) !== "Object" ) config[key] = { label: config[key] };
    if ( traitConfig.children?.[key] ) {
      const children = config[key].children ??= {};
      for ( const [childKey, value] of Object.entries(CONFIG.DND5E[traitConfig.children[key]]) ) {
        if ( foundry.utils.getType(value) !== "Object" ) children[childKey] = { label: value };
        else children[childKey] = { ...value };
      }
    }
  }

  if ( traitConfig.subtypes ) {
    const map = CONFIG.DND5E[`${trait}ProficienciesMap`];

    // Merge all ID lists together
    const ids = traitConfig.subtypes.ids.reduce((obj, key) => {
      foundry.utils.mergeObject(obj, CONFIG.DND5E[key] ?? {});
      return obj;
    }, {});

    // Fetch base items for all IDs
    const baseItems = await Promise.all(Object.entries(ids).map(async ([key, id]) => {
      if ( foundry.utils.getType(id) === "Object" ) id = id.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 = index.system.type.value;
      if ( map?.[type] ) type = map[type];

      // No category for this type, add at top level
      if ( !config[type] ) config[key] = { label: index.name };

      // Add as child of appropriate category
      else {
        config[type].children ??= {};
        config[type].children[key] = { label: index.name };
      }
    }
  }

  return config;
}

/* -------------------------------------------- */

/**
 * Get a list of choices for a specific trait.
 * @param {string} trait                      Trait as defined in `CONFIG.DND5E.traits`.
 * @param {object} [options={}]
 * @param {Set<string>} [options.chosen=[]]   Optional list of keys to be marked as chosen.
 * @param {boolean} [options.prefixed=false]  Should keys be prefixed with trait type?
 * @param {boolean} [options.any=false]       Should the "Any" option be added to each category?
 * @returns {Promise<SelectChoices>}          Object mapping proficiency ids to choice objects.
 */
async function choices(trait, { chosen=new Set(), prefixed=false, any=false }={}) {
  const traitConfig = CONFIG.DND5E.traits[trait];
  if ( !traitConfig ) return new SelectChoices();
  if ( foundry.utils.getType(chosen) === "Array" ) chosen = new Set(chosen);
  const categoryData = await categories(trait);

  let result = {};

  if ( traitConfig.labels?.all && !any ) {
    const key = prefixed ? `${trait}:ALL` : "ALL";
    result[key] = { label: traitConfig.labels.all, chosen: chosen.has(key), sorting: false };
  }

  if ( prefixed && any ) {
    const key = `${trait}:*`;
    result[key] = {
      label: keyLabel(key).titleCase(),
      chosen: chosen.has(key), sorting: false, wildcard: true
    };
  }

  const prepareCategory = (key, data, result, prefix, topLevel=false) => {
    let label = _innerLabel(data, traitConfig);
    if ( !label ) label = key;
    if ( prefixed ) key = `${prefix}:${key}`;
    result[key] = {
      label,
      chosen: data.selectable !== false ? chosen.has(key) : false,
      selectable: data.selectable !== false,
      sorting: topLevel ? traitConfig.sortCategories === true : true
    };
    if ( data.children ) {
      const children = result[key].children = {};
      if ( prefixed && any ) {
        const anyKey = `${key}:*`;
        children[anyKey] = {
          label: keyLabel(anyKey).titleCase(),
          chosen: chosen.has(anyKey), sorting: false, wildcard: true
        };
      }
      Object.entries(data.children).forEach(([k, v]) => prepareCategory(k, v, children, key));
    }
  };

  Object.entries(categoryData).forEach(([k, v]) => prepareCategory(k, v, result, trait, true));

  return new SelectChoices(result).sort();
}

/* -------------------------------------------- */

/**
 * Prepare an object with all possible choices from a set of keys. These choices will be grouped by
 * trait type if more than one type is present.
 * @param {Set<string>} keys  Prefixed trait keys.
 * @returns {Promise<SelectChoices>}
 */
async function mixedChoices(keys) {
  if ( !keys.size ) return new SelectChoices();
  const types = {};
  for ( const key of keys ) {
    const split = key.split(":");
    const trait = split.shift();
    const selectChoices = (await choices(trait, { prefixed: true })).filter(new Set([key]));
    types[trait] ??= { label: traitLabel(trait), children: new SelectChoices() };
    types[trait].children.merge(selectChoices);
  }
  if ( Object.keys(types).length > 1 ) return new SelectChoices(types);
  return Object.values(types)[0].children;
}

/* -------------------------------------------- */

/**
 * 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 }={}) {
  const uuid = getBaseItemUUID(identifier);
  const { collection, documentId: id } = foundry.utils.parseUuid(uuid);
  const pack = collection?.metadata.id;

  // Full Item5e document required, always async.
  if ( fullItem && !indexOnly ) return collection?.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 = collection?.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 ( !collection ) return;

  // Build the extended index and return a promise for the data
  const fields = traitIndexFields();
  const promise = collection.getIndex({ fields }).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;
}

/* -------------------------------------------- */

/**
 * Construct a proper UUID for the provided base item ID.
 * @param {string} identifier  Simple ID, compendium name and ID separated by a dot, or proper UUID.
 * @returns {string}
 */
function getBaseItemUUID(identifier) {
  if ( identifier.startsWith("Compendium.") ) return identifier;
  let pack = CONFIG.DND5E.sourcePacks.ITEMS;
  let [scope, collection, id] = identifier.split(".");
  if ( scope && collection ) pack = `${scope}.${collection}`;
  if ( !id ) id = identifier;
  return `Compendium.${pack}.Item.${id}`;
}

/* -------------------------------------------- */

/**
 * 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 = ["system.type.value"];
  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) {
  const traitConfig = CONFIG.DND5E.traits[trait];
  const pluralRule = (count !== undefined) ? new Intl.PluralRules(game.i18n.lang).select(count) : "other";
  if ( !traitConfig ) return game.i18n.localize(`DND5E.TraitGenericPlural.${pluralRule}`);
  return game.i18n.localize(`${traitConfig.labels.localization}.${pluralRule}`);
}

/* -------------------------------------------- */

/**
 * Retrieve the proper display label for the provided key. Will return a promise unless a categories
 * object is provided in config.
 * @param {string} key              Key for which to generate the label.
 * @param {object} [config={}]
 * @param {number} [config.count]   Number to display, only if a wildcard is used as final part of key.
 * @param {string} [config.trait]   Trait as defined in `CONFIG.DND5E.traits` if not using a prefixed key.
 * @param {boolean} [config.final]  Is this the final in a list?
 * @returns {string}                Retrieved label.
 *
 * @example
 * // Returns "Tool Proficiency"
 * keyLabel("tool");
 *
 * @example
 * // Returns "Artisan's Tools"
 * keyLabel("tool:art");
 *
 * @example
 * // Returns "any Artisan's Tools"
 * keyLabel("tool:art:*");
 *
 * @example
 * // Returns "any 2 Artisan's Tools"
 * keyLabel("tool:art:*", { count: 2 });
 *
 * @example
 * // Returns "2 other Artisan's Tools"
 * keyLabel("tool:art:*", { count: 2, final: true });
 *
 * @example
 * // Returns "Gaming Sets"
 * keyLabel("tool:game");
 *
 * @example
 * // Returns "Land Vehicle"
 * keyLabel("tool:vehicle:land");
 *
 * @example
 * // Returns "Shortsword"
 * keyLabel("weapon:shortsword");
 * keyLabel("weapon:simple:shortsword");
 * keyLabel("shortsword", { trait: "weapon" });
 */
function keyLabel(key, config={}) {
  let { count, trait, final } = config;

  let parts = key.split(":");
  const pluralRules = new Intl.PluralRules(game.i18n.lang);

  if ( !trait ) trait = parts.shift();
  const traitConfig = CONFIG.DND5E.traits[trait];
  if ( !traitConfig ) return key;
  const traitData = CONFIG.DND5E[traitConfig.configKey ?? trait] ?? {};
  let categoryLabel = game.i18n.localize(`${traitConfig.labels.localization}.${
    pluralRules.select(count ?? 1)}`);

  // Trait (e.g. "Tool Proficiency")
  const lastKey = parts.pop();
  if ( !lastKey ) return categoryLabel;

  // All (e.g. "All Languages")
  if ( lastKey === "ALL" ) return traitConfig.labels?.all ?? key;

  // Wildcards (e.g. "Artisan's Tools", "any Artisan's Tools", "any 2 Artisan's Tools", or "2 other Artisan's Tools")
  else if ( lastKey === "*" ) {
    let type;
    if ( parts.length ) {
      let category = traitData;
      do {
        category = (category.children ?? category)[parts.shift()];
        if ( !category ) return key;
      } while ( parts.length );
      type = _innerLabel(category, traitConfig);
    } else type = categoryLabel.toLowerCase();
    const localization = `DND5E.TraitConfigChoose${final ? "Other" : `Any${count ? "Counted" : "Uncounted"}`}`;
    return game.i18n.format(localization, { count: count ?? 1, type });
  }

  else {
    // Category (e.g. "Gaming Sets")
    const category = traitData[lastKey];
    if ( category ) return _innerLabel(category, traitConfig);

    // Child (e.g. "Land Vehicle")
    for ( const childrenKey of Object.values(traitConfig.children ?? {}) ) {
      const childLabel = CONFIG.DND5E[childrenKey]?.[lastKey];
      if ( childLabel ) return childLabel;
    }

    // Base item (e.g. "Shortsword")
    for ( const idsKey of traitConfig.subtypes?.ids ?? [] ) {
      let baseItemId = CONFIG.DND5E[idsKey]?.[lastKey];
      if ( !baseItemId ) continue;
      if ( foundry.utils.getType(baseItemId) === "Object" ) baseItemId = baseItemId.id;
      const index = getBaseItem(baseItemId, { indexOnly: true });
      if ( index ) return index.name;
      break;
    }

    // Explicit categories (e.g. languages)
    const searchCategory = (data, key) => {
      for ( const [k, v] of Object.entries(data) ) {
        if ( k === key ) return v;
        if ( v.children ) {
          const result = searchCategory(v.children, key);
          if ( result ) return result;
        }
      }
    };
    const config = searchCategory(traitData, lastKey);
    return config ? _innerLabel(config, traitConfig) : key;
  }
}

/* -------------------------------------------- */

/**
 * Create a human readable description of the provided choice.
 * @param {TraitChoice} choice             Data for a specific choice.
 * @param {object} [options={}]
 * @param {boolean} [options.only=false]   Is this choice on its own, or part of a larger list?
 * @param {boolean} [options.final=false]  If this choice is part of a list of other grants or choices,
 *                                         is it in the final position?
 * @returns {string}
 *
 * @example
 * // Returns "any three skill proficiencies"
 * choiceLabel({ count: 3, pool: new Set(["skills:*"]) });
 *
 * @example
 * // Returns "three other skill proficiencies"
 * choiceLabel({ count: 3, pool: new Set(["skills:*"]) }, { final: true });
 *
 * @example
 * // Returns "any skill proficiency"
 * choiceLabel({ count: 1, pool: new Set(["skills:*"]) }, { only: true });
 *
 * @example
 * // Returns "Thieves Tools or any skill"
 * choiceLabel({ count: 1, pool: new Set(["tool:thief", "skills:*"]) }, { only: true });
 *
 * @example
 * // Returns "Thieves' Tools or any artisan tool"
 * choiceLabel({ count: 1, pool: new Set(["tool:thief", "tool:art:*"]) }, { only: true });
 *
 * @example
 * // Returns "2 from Thieves' Tools or any skill proficiency"
 * choiceLabel({ count: 2, pool: new Set(["tool:thief", "skills:*"]) });
 *
 */
function choiceLabel(choice, { only=false, final=false }={}) {
  if ( !choice.pool.size ) return "";

  // Single entry in pool (e.g. "any three skill proficiencies" or "three other skill proficiencies")
  if ( choice.pool.size === 1 ) {
    return keyLabel(choice.pool.first(), {
      count: (choice.count > 1 || !only) ? choice.count : null, final: final && !only
    });
  }

  const listFormatter = new Intl.ListFormat(game.i18n.lang, { type: "disjunction" });

  // Singular count (e.g. "any skill", "Thieves Tools or any skill", or "Thieves' Tools or any artisan tool")
  if ( (choice.count === 1) && only ) {
    return listFormatter.format(Array.from(choice.pool).map(key => keyLabel(key)).filter(_ => _));
  }

  // Select from a list of options (e.g. "2 from Thieves' Tools or any skill proficiency")
  const choices = Array.from(choice.pool).map(key => keyLabel(key)).filter(_ => _);
  return game.i18n.format("DND5E.TraitConfigChooseList", {
    count: choice.count,
    list: listFormatter.format(choices)
  });
}

/* -------------------------------------------- */

/**
 * Create a human readable description of trait grants & choices.
 * @param {object} config
 * @param {Set<string>} [config.grants]        Guaranteed trait grants.
 * @param {TraitChoice[]} [config.choices=[]]  Trait choices.
 * @returns {string}
 *
 * @example
 * // Returns "Acrobatics and Athletics"
 * localizedList({ grants: new Set(["skills:acr", "skills:ath"]) });
 *
 * @example
 * // Returns "Acrobatics and one other skill proficiency"
 * localizedList({ grants: new Set(["skills:acr"]), choices: [{ count: 1, pool: new Set(["skills:*"])}] });
 *
 * @example
 * // Returns "Choose any skill proficiency"
 * localizedList({ choices: [{ count: 1, pool: new Set(["skills:*"])}] });
 */
function localizedList({ grants=new Set(), choices=[] }) {
  const sections = Array.from(grants).map(g => keyLabel(g));

  for ( const [index, choice] of choices.entries() ) {
    const final = index === choices.length - 1;
    sections.push(choiceLabel(choice, { final, only: !grants.size && choices.length === 1 }));
  }

  const listFormatter = new Intl.ListFormat(game.i18n.lang, { style: "long", type: "conjunction" });
  if ( !sections.length || grants.size ) return listFormatter.format(sections.filter(_ => _));
  return game.i18n.format("DND5E.TraitConfigChooseWrapper", {
    choices: listFormatter.format(sections)
  });
}

var trait = /*#__PURE__*/Object.freeze({
  __proto__: null,
  actorFields: actorFields,
  actorKeyPath: actorKeyPath,
  actorValues: actorValues,
  categories: categories,
  changeKeyPath: changeKeyPath,
  choiceLabel: choiceLabel,
  choices: choices,
  getBaseItem: getBaseItem,
  getBaseItemUUID: getBaseItemUUID,
  keyLabel: keyLabel,
  localizedList: localizedList,
  mixedChoices: mixedChoices,
  traitIndexFields: traitIndexFields,
  traitLabel: traitLabel
});

/**
 * Sheet for the check activity.
 */
class CheckSheet extends ActivitySheet {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["check-activity"]
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  static PARTS = {
    ...super.PARTS,
    effect: {
      template: "systems/dnd5e/templates/activity/check-effect.hbs",
      templates: [
        ...super.PARTS.effect.templates,
        "systems/dnd5e/templates/activity/parts/check-details.hbs"
      ]
    }
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareEffectContext(context, options) {
    context = await super._prepareEffectContext(context, options);

    const group = game.i18n.localize("DND5E.Abilities");
    context.abilityOptions = [
      { value: "", label: "" },
      { rule: true },
      { value: "spellcasting", label: game.i18n.localize("DND5E.SpellAbility") },
      ...Object.entries(CONFIG.DND5E.abilities).map(([value, config]) => ({ value, label: config.label, group }))
    ];
    let ability;
    const associated = this.activity.check.associated;
    if ( (this.item.type === "tool") && !associated.size ) {
      ability = CONFIG.DND5E.abilities[this.item.system.ability]?.label?.toLowerCase();
    } else if ( (associated.size === 1) && (associated.first() in CONFIG.DND5E.skills) ) {
      ability = CONFIG.DND5E.abilities[CONFIG.DND5E.skills[associated.first()].ability]?.label?.toLowerCase();
    }
    if ( ability ) context.abilityOptions[0].label = game.i18n.format("DND5E.DefaultSpecific", { default: ability });

    context.associatedOptions = [
      ...Object.entries(CONFIG.DND5E.skills).map(([value, { label }]) => ({
        value, label, group: game.i18n.localize("DND5E.Skills")
      })),
      ...Object.keys(CONFIG.DND5E.tools).map(value => ({
        value, label: keyLabel(value, { trait: "tool" }), group: game.i18n.localize("TYPES.Item.toolPl")
      })).sort((lhs, rhs) => lhs.label.localeCompare(rhs.label, game.i18n.lang))
    ];

    context.calculationOptions = [
      { value: "", label: game.i18n.localize("DND5E.SAVE.FIELDS.save.dc.CustomFormula") },
      { rule: true },
      { value: "spellcasting", label: game.i18n.localize("DND5E.SpellAbility") },
      ...Object.entries(CONFIG.DND5E.abilities).map(([value, config]) => ({ value, label: config.label, group }))
    ];

    return context;
  }
}

const { SchemaField: SchemaField$S, SetField: SetField$A, StringField: StringField$1a } = foundry.data.fields;

/**
 * @import { CheckActivityData } from "./_types.mjs";
 */

/**
 * Data model for a check activity.
 * @extends {BaseActivityData<CheckActivityData>}
 * @mixes CheckActivityData
 */
class BaseCheckActivityData extends BaseActivityData {
  /** @inheritDoc */
  static defineSchema() {
    return {
      ...super.defineSchema(),
      check: new SchemaField$S({
        ability: new StringField$1a(),
        associated: new SetField$A(new StringField$1a()),
        dc: new SchemaField$S({
          calculation: new StringField$1a(),
          formula: new FormulaField({ deterministic: true })
        })
      })
    };
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  get ability() {
    if ( this.check.dc.calculation in CONFIG.DND5E.abilities ) return this.check.dc.calculation;
    if ( this.check.dc.calculation === "spellcasting" ) return this.spellcastingAbility;
    return this.check.ability;
  }

  /* -------------------------------------------- */
  /*  Data Migration                              */
  /* -------------------------------------------- */

  /** @override */
  static transformTypeData(source, activityData, options) {
    return foundry.utils.mergeObject(activityData, {
      check: {
        ability: source.system.ability ?? Object.keys(CONFIG.DND5E.abilities)[0]
      }
    });
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareFinalData(rollData) {
    rollData ??= this.getRollData({ deterministic: true });
    super.prepareFinalData(rollData);

    if ( this.check.ability === "spellcasting" ) this.check.ability = this.spellcastingAbility;

    let ability;
    if ( this.check.dc.calculation ) ability = this.ability;
    else this.check.dc.value = simplifyBonus(this.check.dc.formula, rollData);
    if ( ability ) this.check.dc.value = this.actor?.system.abilities?.[ability]?.dc
      ?? 8 + (this.actor?.system.attributes?.prof ?? 0);

    if ( !this.check.dc.value ) this.check.dc.value = null;
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Get the ability to use with an associated value.
   * @param {string} associated  Skill or tool ID.
   * @returns {string|null}      Ability to use.
   */
  getAbility(associated) {
    if ( this.check.ability ) return this.check.ability;
    if ( associated in CONFIG.DND5E.skills ) return CONFIG.DND5E.skills[associated]?.ability ?? null;
    else if ( associated in CONFIG.DND5E.tools ) {
      if ( (this.item.type === "tool") && this.item.system.ability ) return this.item.system.ability;
      return CONFIG.DND5E.tools[associated]?.ability ?? null;
    }
    return null;
  }
}

/**
 * Activity for making ability checks.
 */
class CheckActivity extends ActivityMixin(BaseCheckActivityData) {
  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static LOCALIZATION_PREFIXES = [...super.LOCALIZATION_PREFIXES, "DND5E.CHECK"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static metadata = Object.freeze(
    foundry.utils.mergeObject(super.metadata, {
      type: "check",
      img: "systems/dnd5e/icons/svg/activity/check.svg",
      title: "DND5E.CHECK.Title",
      hint: "DND5E.CHECK.Hint",
      sheetClass: CheckSheet,
      usage: {
        actions: {
          rollCheck: CheckActivity.#rollCheck
        }
      }
    }, { inplace: false })
  );

  /* -------------------------------------------- */
  /*  Activation                                  */
  /* -------------------------------------------- */

  /** @override */
  _usageChatButtons(message) {
    const buttons = [];
    const dc = this.check.dc.value;

    const createButton = (abilityKey, associated) => {
      const ability = CONFIG.DND5E.abilities[abilityKey]?.label;
      const checkType = (associated in CONFIG.DND5E.skills) ? "skill"
        : (associated in CONFIG.DND5E.tools) ? "tool": "ability";
      const dataset = { ability: abilityKey, action: "rollCheck", visibility: "all" };
      if ( dc ) dataset.dc = dc;
      if ( checkType !== "ability" ) dataset[checkType] = associated;

      let label = ability;
      let type;
      if ( checkType === "skill" ) type = CONFIG.DND5E.skills[associated]?.label;
      else if ( checkType === "tool" ) type = keyLabel(associated, { trait: "tool" });
      if ( type ) label = game.i18n.format("EDITOR.DND5E.Inline.SpecificCheck", { ability, type });
      else label = ability;

      buttons.push({
        label: dc ? `
          <span class="visible-dc">${game.i18n.format("EDITOR.DND5E.Inline.DC", { dc, check: wrap(label) })}</span>
          <span class="hidden-dc">${wrap(label)}</span>
        ` : wrap(label),
        icon: checkType === "tool" ? '<i class="fa-solid fa-hammer" inert></i>'
          : '<i class="dnd5e-icon" data-src="systems/dnd5e/icons/svg/ability-score-improvement.svg" inert></i>',
        dataset
      });
    };
    const wrap = check => game.i18n.format("EDITOR.DND5E.Inline.CheckShort", { check });

    const associated = Array.from(this.check.associated);
    if ( !associated.length && (this.item.type === "tool") ) associated.push(this.item.system.type.baseItem);
    if ( associated.length ) associated.forEach(a => {
      const ability = this.getAbility(a);
      if ( ability ) createButton(ability, a);
    });
    else if ( this.check.ability ) createButton(this.check.ability);

    return buttons.concat(super._usageChatButtons(message));
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle performing an ability check.
   * @this {CheckActivity}
   * @param {PointerEvent} event     Triggering click event.
   * @param {HTMLElement} target     The capturing HTML element which defined a [data-action].
   * @param {ChatMessage5e} message  Message associated with the activation.
   */
  static async #rollCheck(event, target, message) {
    const targets = getSceneTargets();
    if ( !targets.length && game.user.character ) targets.push(game.user.character);
    if ( !targets.length ) ui.notifications.warn("DND5E.ActionWarningNoToken", { localize: true });
    let { ability, dc, skill, tool } = target.dataset;
    dc = parseInt(dc);
    const rollData = { event, target: Number.isFinite(dc) ? dc : this.check.dc.value };
    if ( ability in CONFIG.DND5E.abilities ) rollData.ability = ability;

    for ( const token of targets ) {
      const actor = token instanceof Actor ? token : token.actor;
      const speaker = ChatMessage.getSpeaker({ actor, scene: canvas.scene, token: token.document });
      const messageData = { data: { speaker } };
      if ( skill ) await actor.rollSkill({ ...rollData, skill }, {}, messageData);
      else if ( tool ) {
        rollData.tool = tool;
        if ( (this.item.type === "tool")
          && (!this.item.system.type.baseItem || (tool === this.item.system.type.baseItem)) ) {
          rollData.bonus = this.item.system.bonus;
          rollData.prof = this.item.system.prof;
          rollData.item = this.item;
        }
        await actor.rollToolCheck(rollData, {}, messageData);
      }
      else await actor.rollAbilityCheck(rollData, {}, messageData);
    }
  }
}

/**
 * Sheet for the damage activity.
 */
class DamageSheet extends ActivitySheet {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["damage-activity"]
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  static PARTS = {
    ...super.PARTS,
    effect: {
      template: "systems/dnd5e/templates/activity/damage-effect.hbs",
      templates: [
        ...super.PARTS.effect.templates,
        "systems/dnd5e/templates/activity/parts/damage-damage.hbs",
        "systems/dnd5e/templates/activity/parts/damage-part.hbs",
        "systems/dnd5e/templates/activity/parts/damage-parts.hbs"
      ]
    }
  };
}

const { ArrayField: ArrayField$l, BooleanField: BooleanField$I, SchemaField: SchemaField$R } = foundry.data.fields;

/**
 * @import { DamageActivityData } from "./_types.mjs";
 */

/**
 * Data model for an damage activity.
 * @extends {BaseActivityData<DamageActivityData>}
 * @mixes DamageActivityData
 */
class BaseDamageActivityData extends BaseActivityData {
  /** @inheritDoc */
  static defineSchema() {
    return {
      ...super.defineSchema(),
      damage: new SchemaField$R({
        critical: new SchemaField$R({
          allow: new BooleanField$I(),
          bonus: new FormulaField()
        }),
        parts: new ArrayField$l(new DamageField())
      })
    };
  }

  /* -------------------------------------------- */
  /*  Data Migration                              */
  /* -------------------------------------------- */

  /** @override */
  static transformTypeData(source, activityData, options) {
    return foundry.utils.mergeObject(activityData, {
      damage: {
        critical: {
          allow: false,
          bonus: source.system.critical?.damage ?? ""
        },
        parts: options.versatile
          ? [this.transformDamagePartData(source, [source.system.damage?.versatile, ""])]
          : (source.system.damage?.parts?.map(part => this.transformDamagePartData(source, part)) ?? [])
      }
    });
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareFinalData(rollData) {
    rollData ??= this.getRollData({ deterministic: true });
    super.prepareFinalData(rollData);
    this.prepareDamageLabel(rollData);
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  getDamageConfig(config={}) {
    const rollConfig = super.getDamageConfig(config);

    rollConfig.critical ??= {};
    rollConfig.critical.allow ??= this.damage.critical.allow;
    rollConfig.critical.bonusDamage ??= this.damage.critical.bonus;

    return rollConfig;
  }
}

/**
 * Activity for rolling damage.
 */
class DamageActivity extends ActivityMixin(BaseDamageActivityData) {
  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static LOCALIZATION_PREFIXES = [...super.LOCALIZATION_PREFIXES, "DND5E.DAMAGE"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static metadata = Object.freeze(
    foundry.utils.mergeObject(super.metadata, {
      type: "damage",
      img: "systems/dnd5e/icons/svg/activity/damage.svg",
      title: "DND5E.DAMAGE.Title",
      hint: "DND5E.DAMAGE.Hint",
      sheetClass: DamageSheet,
      usage: {
        actions: {
          rollDamage: DamageActivity.#rollDamage
        }
      }
    }, { inplace: false })
  );

  /* -------------------------------------------- */
  /*  Activation                                  */
  /* -------------------------------------------- */

  /** @override */
  _usageChatButtons(message) {
    if ( !this.damage.parts.length ) return super._usageChatButtons(message);
    return [{
      label: game.i18n.localize("DND5E.Damage"),
      icon: '<i class="fa-solid fa-burst" inert></i>',
      dataset: {
        action: "rollDamage"
      }
    }].concat(super._usageChatButtons(message));
  }

  /* -------------------------------------------- */

  /** @override */
  async _triggerSubsequentActions(config, results) {
    this.rollDamage({ event: config.event }, {}, { data: { "flags.dnd5e.originatingMessage": results.message?.id } });
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle performing a damage roll.
   * @this {DamageActivity}
   * @param {PointerEvent} event     Triggering click event.
   * @param {HTMLElement} target     The capturing HTML element which defined a [data-action].
   * @param {ChatMessage5e} message  Message associated with the activation.
   */
  static #rollDamage(event, target, message) {
    this.rollDamage({ event });
  }
}

/**
 * Sheet for the enchant activity.
 */
class EnchantSheet extends ActivitySheet {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["enchant-activity"]
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  static PARTS = {
    ...super.PARTS,
    effect: {
      template: "systems/dnd5e/templates/activity/enchant-effect.hbs",
      templates: [
        "systems/dnd5e/templates/activity/parts/enchant-enchantments.hbs",
        "systems/dnd5e/templates/activity/parts/enchant-restrictions.hbs"
      ]
    }
  };

  /* -------------------------------------------- */

  /** @override */
  tabGroups = {
    sheet: "identity",
    activation: "time",
    effect: "enchantments"
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @override */
  _prepareAppliedEffectContext(context, effect) {
    effect.activityOptions = this.item.system.activities
      .filter(a => a.id !== this.activity.id)
      .map(a => ({ value: a.id, label: a.name, selected: effect.data.riders.activity.has(a.id) }));
    effect.effectOptions = context.allEffects.map(e => ({
      ...e, selected: effect.data.riders.effect.has(e.value)
    }));
    return effect;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareEffectContext(context, options) {
    context = await super._prepareEffectContext(context, options);

    const appliedEnchantments = new Set(context.activity.effects?.map(e => e._id) ?? []);
    context.allEnchantments = this.item.effects
      .filter(e => e.type === "enchantment")
      .map(effect => ({
        value: effect.id, label: effect.name, selected: appliedEnchantments.has(effect.id)
      }));

    const enchantableTypes = this.activity.enchantableTypes;
    context.typeOptions = [
      { value: "", label: game.i18n.localize("DND5E.ENCHANT.FIELDS.restrictions.type.Any"), rule: true },
      ...Object.keys(CONFIG.Item.dataModels)
        .filter(t => enchantableTypes.has(t))
        .map(value => ({ value, label: game.i18n.localize(CONFIG.Item.typeLabels[value]) }))
    ];
    context.isTypePhysical = !context.source.restrictions.type
      || !!CONFIG.Item.dataModels[context.source.restrictions.type]?.schema.fields.quantity;

    const type = context.source.restrictions.type;
    const typeDataModel = CONFIG.Item.dataModels[type];
    if ( typeDataModel ) context.categoryOptions = Object.entries(typeDataModel.itemCategories ?? {})
      .map(([value, config]) => ({ value, label: foundry.utils.getType(config) === "string" ? config : config.label }));

    context.propertyOptions = (CONFIG.DND5E.validProperties[type] ?? [])
      .map(value => ({ value, label: CONFIG.DND5E.itemProperties[value]?.label ?? value }));

    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareIdentityContext(context, options) {
    context = await super._prepareIdentityContext(context, options);
    context.behaviorFields.unshift({
      field: context.fields.enchant.fields.self,
      value: context.source.enchant.self,
      input: context.inputs.createCheckboxInput
    });
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _getTabs() {
    const tabs = super._getTabs();
    tabs.effect.label = "DND5E.ENCHANT.SECTIONS.Enchanting";
    tabs.effect.icon = "fa-solid fa-wand-sparkles";
    tabs.effect.tabs = this._markTabs({
      enchantments: {
        id: "enchantments", group: "effect", icon: "fa-solid fa-star",
        label: "DND5E.ENCHANT.SECTIONS.Enchantments"
      },
      restrictions: {
        id: "restrictions", group: "effect", icon: "fa-solid fa-ban",
        label: "DND5E.ENCHANT.SECTIONS.Restrictions"
      }
    });
    return tabs;
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @override */
  _addEffectData() {
    return {
      type: "enchantment",
      name: this.item.name,
      img: this.item.img,
      disabled: true
    };
  }
}

const { StringField: StringField$19 } = foundry.data.fields;

/**
 * Dialog for configuring the usage of an activity.
 */
class EnchantUsageDialog extends ActivityUsageDialog {

  /** @inheritDoc */
  static PARTS = {
    ...super.PARTS,
    creation: {
      template: "systems/dnd5e/templates/activity/enchant-usage-creation.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareCreationContext(context, options) {
    context = await super._prepareCreationContext(context, options);

    const enchantments = this.activity.availableEnchantments;
    if ( (enchantments.length > 1) && this._shouldDisplay("create.enchantment") ) {
      const existingProfile = this.activity.existingEnchantment?.flags.dnd5e?.enchantmentProfile;
      context.hasCreation = true;
      context.enchantment = {
        field: new StringField$19({ required: true, blank: false, label: game.i18n.localize("DND5E.ENCHANTMENT.Label") }),
        name: "enchantmentProfile",
        value: this.config.enchantmentProfile,
        options: enchantments.map(e => ({
          value: e._id,
          label: e._id === existingProfile
            ? game.i18n.format("DND5E.ENCHANT.Enchantment.Active", { name: e.effect.name })
            : e.effect.name
        }))
      };
    } else if ( enchantments.length ) {
      context.enchantment = enchantments[0]?._id ?? false;
    }

    return context;
  }
}

const {
  ArrayField: ArrayField$k, BooleanField: BooleanField$H, DocumentIdField: DocumentIdField$b, DocumentUUIDField: DocumentUUIDField$a, NumberField: NumberField$I, SchemaField: SchemaField$Q, SetField: SetField$z, StringField: StringField$18
} = foundry.data.fields;

/**
 * @import { EnchantActivityData, EnchantEffectApplicationData } from "./_types.mjs";
 */

/**
 * Data model for a enchant activity.
 * @extends {BaseActivityData<EnchantActivityData>}
 * @mixes EnchantActivityData
 */
class BaseEnchantActivityData extends BaseActivityData {
  /** @inheritDoc */
  static defineSchema() {
    return {
      ...super.defineSchema(),
      effects: new ArrayField$k(new AppliedEffectField({
        riders: new SchemaField$Q({
          activity: new SetField$z(new DocumentIdField$b()),
          effect: new SetField$z(new DocumentIdField$b()),
          item: new SetField$z(new DocumentUUIDField$a({ type: "Item" }))
        })
      })),
      enchant: new SchemaField$Q({
        self: new BooleanField$H()
      }),
      restrictions: new SchemaField$Q({
        allowMagical: new BooleanField$H(),
        categories: new SetField$z(new StringField$18()),
        properties: new SetField$z(new StringField$18()),
        type: new StringField$18()
      })
    };
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  get actionType() {
    return "ench";
  }

  /* -------------------------------------------- */

  /** @override */
  get applicableEffects() {
    return null;
  }

  /* -------------------------------------------- */

  /**
   * Enchantments that have been applied by this activity.
   * @type {ActiveEffect5e[]}
   */
  get appliedEnchantments() {
    return dnd5e.registry.enchantments.applied(this.uuid);
  }

  /* -------------------------------------------- */

  /**
   * Enchantments that can be applied based on spell/character/class level.
   * @type {EnchantEffectApplicationData[]}
   */
  get availableEnchantments() {
    const level = this.relevantLevel;
    return this.effects
      .filter(e => e.effect && ((e.level.min ?? -Infinity) <= level) && (level <= (e.level.max ?? Infinity)));
  }

  /* -------------------------------------------- */

  /**
   * List of item types that are enchantable.
   * @type {Set<string>}
   */
  static get enchantableTypes() {
    return Object.entries(CONFIG.Item.dataModels).reduce((set, [k, v]) => {
      if ( v.metadata?.enchantable ) set.add(k);
      return set;
    }, new Set());
  }

  /* -------------------------------------------- */
  /*  Data Migration                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static migrateData(source) {
    super.migrateData(source);
    if ( source.enchant?.identifier ) {
      foundry.utils.setProperty(source, "visibility.identifier", source.enchant.identifier);
      delete source.enchant.identifier;
    }
    return source;
  }

  /* -------------------------------------------- */

  /** @override */
  static transformEffectsData(source, options) {
    const effects = [];
    for ( const effect of source.effects ) {
      if ( (effect.type !== "enchantment") && (effect.flags?.dnd5e?.type !== "enchantment") ) continue;
      effects.push({ _id: effect._id, ...(effect.flags?.dnd5e?.enchantment ?? {}) });
      delete effect.flags?.dnd5e?.enchantment;
    }
    return effects;
  }

  /* -------------------------------------------- */

  /** @override */
  static transformTypeData(source, activityData) {
    return foundry.utils.mergeObject(activityData, {
      restrictions: source.system.enchantment?.restrictions ?? [],
      visibility: {
        identifier: source.system.enchantment?.classIdentifier ?? ""
      }
    });
  }
}

/**
 * @import { ActivityChoiceDialogContext } from "./_types.mjs";
 */

/**
 * Dialog for choosing an activity to use on an Item.
 * @param {Item5e} item                         The Item whose activities are being chosen.
 * @param {ApplicationConfiguration} [options]  Application configuration options.
 */
class ActivityChoiceDialog extends Application5e {
  constructor(item, options={}) {
    super(options);
    this.#item = item;
  }

  /* -------------------------------------------- */

  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["activity-choice"],
    actions: {
      choose: ActivityChoiceDialog.#onChooseActivity
    },
    position: {
      width: 350
    }
  };

  /* -------------------------------------------- */

  static PARTS = {
    activities: {
      template: "systems/dnd5e/templates/activity/activity-choices.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The chosen activity.
   * @type {Activity|null}
   */
  get activity() {
    return this.#activity ?? null;
  }

  #activity;

  /* -------------------------------------------- */

  /**
   * The Item whose activities are being chosen.
   * @type {Item5e}
   */
  get item() {
    return this.#item;
  }

  #item;

  /* -------------------------------------------- */

  /** @override */
  get title() {
    return this.#item.name;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _configureRenderOptions(options) {
    super._configureRenderOptions(options);
    if ( options.isFirstRender ) options.window.icon ||= this.#item.img;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    let controlHint;
    if ( game.settings.get("dnd5e", "controlHints") ) {
      controlHint = game.i18n.localize("DND5E.Controls.Activity.FastForwardHint");
      controlHint = controlHint.replace(
        "<left-click>",
        `<img src="systems/dnd5e/icons/svg/mouse-left.svg" alt="${game.i18n.localize("DND5E.Controls.LeftClick")}">`
      );
    }
    const activities = this.#item.system.activities
      .filter(a => a.canUse)
      .map(this._prepareActivityContext.bind(this))
      .sort((a, b) => a.sort - b.sort);
    return {
      ...await super._prepareContext(options),
      controlHint, activities
    };
  }

  /* -------------------------------------------- */

  /**
   * Prepare rendering context for a given activity.
   * @param {Activity} activity  The activity.
   * @returns {ActivityChoiceDialogContext}
   * @protected
   */
  _prepareActivityContext(activity) {
    const { id, name, img, sort } = activity;
    return {
      id, name, sort,
      icon: {
        src: img,
        svg: img.endsWith(".svg")
      }
    };
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /**
   * Handle choosing an activity.
   * @this {ActivityChoiceDialog}
   * @param {PointerEvent} event  The triggering click event.
   * @param {HTMLElement} target  The activity button that was clicked.
   */
  static async #onChooseActivity(event, target) {
    const { activityId } = target.dataset;
    this.#activity = this.#item.system.activities.get(activityId);
    this.close();
  }

  /* -------------------------------------------- */
  /*  Factory Methods                             */
  /* -------------------------------------------- */

  /**
   * Display the activity choice dialog.
   * @param {Item5e} item                         The Item whose activities are being chosen.
   * @param {ApplicationConfiguration} [options]  Application configuration options.
   * @returns {Promise<Activity|null>}            The chosen activity, or null if the dialog was dismissed.
   */
  static create(item, options) {
    return new Promise(resolve => {
      const dialog = new this(item, options);
      dialog.addEventListener("close", () => resolve(dialog.activity), { once: true });
      dialog.render({ force: true });
    });
  }
}

/**
 * Base configuration application for advancements that can be extended by other types to implement custom
 * editing interfaces.
 */
let AdvancementConfig$1 = class AdvancementConfig extends PseudoDocumentSheet {
  constructor(advancement={}, options={}) {
    if ( advancement instanceof dnd5e.documents.advancement.Advancement ) {
      foundry.utils.logCompatibilityWarning(
        "`AdvancementConfig` should be constructed by passing the Advancement as `options.document`, not as separate parameter.",
        { since: "DnD5e 5.1", until: "DnD5e 5.3" }
      );
      options.document = advancement;
    } else options = { ...advancement, ...options };
    super(options);
  }

  /* -------------------------------------------- */

  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["advancement", "grid-columns"],
    window: {
      icon: "fa-solid fa-person-rays"
    },
    actions: {
      deleteItem: AdvancementConfig.#deleteDroppedItem
    },
    dropKeyPath: null,
    position: {
      width: 400
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    config: {
      template: "systems/dnd5e/templates/advancement/advancement-controls-section.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The advancement being created or edited.
   * @type {Advancement}
   */
  get advancement() {
    return this.document;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  get title() {
    return this.advancement.constructor.metadata.title;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const levels = Array.fromRange(CONFIG.DND5E.maxLevel + 1).map(l => ({ value: l, label: l }));
    if ( ["class", "subclass"].includes(this.item.type) ) delete levels[0];
    else levels[0].label = game.i18n.localize("DND5E.ADVANCEMENT.Config.AnyLevel");
    const context = {
      ...(await super._prepareContext(options)),
      advancement: this.advancement,
      configuration: {
        data: this.advancement.configuration,
        fields: this.advancement.configuration?.schema?.fields
      },
      fields: this.advancement.schema.fields,
      source: this.advancement._source,
      default: {
        title: this.advancement._defaultTitle,
        icon: this.advancement._defaultIcon,
        hint: ""
      },
      levels,
      classRestrictionOptions: [
        { value: "", label: game.i18n.localize("DND5E.AdvancementClassRestrictionNone") },
        { value: "primary", label: game.i18n.localize("DND5E.AdvancementClassRestrictionPrimary") },
        { value: "secondary", label: game.i18n.localize("DND5E.AdvancementClassRestrictionSecondary") }
      ],
      showClassRestrictions: this.item.type === "class",
      showLevelSelector: !this.advancement.constructor.metadata.multiLevel
    };
    return context;
  }

  /* -------------------------------------------- */
  /*  Life-Cycle Handlers                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    new CONFIG.ux.DragDrop({
      dragSelector: ".draggable",
      dropSelector: null,
      callbacks: {
        dragstart: this._onDragStart.bind(this),
        drop: this._onDrop.bind(this)
      }
    }).bind(this.element);
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle deleting an existing Item entry from the Advancement.
   * @this {AdvancementConfig}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static async #deleteDroppedItem(event, target) {
    const uuidToDelete = target.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(i => i.uuid !== uuidToDelete)
    }) };
    await this.advancement.update(updates);
  }

  /* -------------------------------------------- */
  /*  Form Handling                               */
  /* -------------------------------------------- */

  /**
   * 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 */
  async _processSubmitData(event, submitData) {
    submitData.configuration ??= {};
    submitData.configuration = await this.prepareConfigurationUpdate(submitData.configuration);
    await this.advancement.update(submitData);
  }

  /* -------------------------------------------- */

  /**
   * 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]) => {
      let keep = false;
      if ( foundry.utils.getType(value) === "Object" ) keep = Object.values(value).some(v => v);
      else if ( value ) keep = true;
      if ( keep ) obj[key] = value;
      else obj[`-=${key}`] = null;
      return obj;
    }, {});
  }

  /* -------------------------------------------- */
  /*  Drag & Drop                                 */
  /* -------------------------------------------- */

  /**
   * Handle beginning drag events on the sheet.
   * @param {DragEvent} event  The initiating drag start event.
   * @protected
   */
  async _onDragStart(event) {}

  /* -------------------------------------------- */

  /**
   * Handle dropping items onto the sheet.
   * @param {DragEvent} event  The concluding drag event.
   * @protected
   */
  async _onDrop(event) {
    if ( !this.options.dropKeyPath ) return;

    // Try to extract the data
    const data = foundry.applications.ux.TextEditor.implementation.getDragEventData(event);

    if ( data?.type !== "Item" ) return;
    const item = await Item.implementation.fromDropData(data);

    try {
      this._validateDroppedItem(event, item);
    } catch(err) {
      ui.notifications.error(err.message);
      return;
    }

    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 ) {
      ui.notifications.error("DND5E.ADVANCEMENT.ItemGrant.Warning.Recursive", {localize: true});
      return;
    }

    // Abort if this uuid exists already
    if ( existingItems.find(i => i.uuid === item.uuid) ) {
      ui.notifications.warn("DND5E.ADVANCEMENT.ItemGrant.Warning.Duplicate", {localize: true});
      return;
    }

    await this.advancement.update({[`configuration.${this.options.dropKeyPath}`]: [
      ...existingItems, { uuid: 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;
  }

  /* -------------------------------------------- */

  /** @override */
  static _warnedAppV1 = true;

  /* --------------------------------------------- */

  /** @inheritDoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      template: "systems/dnd5e/templates/advancement/advancement-flow.hbs",
      popOut: false
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static _customElements = super._customElements.concat(["dnd5e-checkbox"]);

  /* -------------------------------------------- */

  /** @inheritDoc */
  get id() {
    return `actor-${this.advancement.item.id}-advancement-${this.advancement.id}-${this.level}`;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  get title() {
    return this.advancement.title;
  }

  /* -------------------------------------------- */

  /**
   * The Advancement object this flow modifies.
   * @type {Advancement|null}
   */
  get advancement() {
    return this.item.advancement?.byId[this._advancementId] ?? null;
  }

  /* -------------------------------------------- */

  /**
   * Set the retained data for this flow. This method gives the flow a chance to do any additional prep
   * work required for the retained data before the application is rendered.
   * @param {object} data  Retained data associated with this flow.
   */
  async retainData(data) {
    this.retainedData = data;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  getData() {
    return {
      appId: this.id,
      advancement: this.advancement,
      type: this.advancement.constructor.typeName,
      title: this.title,
      hint: this.advancement.hint,
      summary: this.advancement.summaryForLevel(this.level),
      level: this.level
    };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _render(...args) {
    await super._render(...args);

    // Call setPosition on manager to adjust for size changes
    this.options.manager?.setPosition();
  }

  /* -------------------------------------------- */

  /**
   * Retrieve automatic application data from the advancement, if supported.
   * @returns {object|false}  Data to pass to the apply method, or `false` if advancement requirers user intervention.
   */
  getAutomaticApplicationValue() {
    return this.advancement.automaticApplicationValue(this.level);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _updateObject(event, formData) {
    await this.advancement.apply(this.level, formData);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _canDragDrop(selector) {
    return true;
  }

}

/**
 * Data Model variant that does not export fields with an `undefined` value during `toObject(true)`.
 */
let SparseDataModel$1 = class SparseDataModel extends foundry.abstract.DataModel {
  /** @inheritDoc */
  toObject(source=true) {
    if ( !source ) return super.toObject(source);
    const clone = foundry.utils.flattenObject(this._source);
    // Remove any undefined keys from the source data
    Object.keys(clone).filter(k => clone[k] === undefined).forEach(k => delete clone[k]);
    return foundry.utils.expandObject(clone);
  }
};

/**
 * Data field that 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);
  }

  /* -------------------------------------------- */

  /**
   * Migrate this field's candidate source data.
   * @param {object} sourceData   Candidate source data of the root model
   * @param {any} fieldData       The value of this field within the source data
   */
  migrateSource(sourceData, fieldData) {
    const cls = this.getModel();
    if ( cls ) cls.migrateDataSafe(fieldData);
  }
}

const { DocumentIdField: DocumentIdField$a, FilePathField: FilePathField$2, NumberField: NumberField$H, StringField: StringField$17 } = foundry.data.fields;

/**
 * @import { BaseAdvancementData } from "./_types.mjs";
 */

/**
 * Base data model for advancement.
 * @extends {SparseDataModel<AdvancementData>}
 * @mixes AdvancementData
 */
class BaseAdvancementData extends SparseDataModel$1 {

  /**
   * 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$/, "");
  }

  /* -------------------------------------------- */

  /** @override */
  static defineSchema() {
    return {
      _id: new DocumentIdField$a({initial: () => foundry.utils.randomID()}),
      type: new StringField$17({
        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 NumberField$H({
        integer: true, initial: this.metadata?.multiLevel ? undefined : 0, min: 0, label: "DND5E.Level"
      }),
      title: new StringField$17({initial: undefined, label: "DND5E.AdvancementCustomTitle"}),
      hint: new StringField$17({label: "DND5E.AdvancementHint"}),
      icon: new FilePathField$2({
        initial: undefined, categories: ["IMAGE"], label: "DND5E.AdvancementCustomIcon", base64: true
      }),
      classRestriction: new StringField$17({
        initial: undefined, choices: ["primary", "secondary"], label: "DND5E.AdvancementClassRestriction"
      })
    };
  }

  /* -------------------------------------------- */
  /*  Data Migration                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static migrateData(source) {
    super.migrateData(source);
    if ( source.configuration?.hint ) source.hint = source.configuration.hint;
    return source;
  }
}

/**
 * @import { AdvancementMetadata } from "./_types.mjs";
 */

/**
 * 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 PseudoDocumentMixin(BaseAdvancementData) {
  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
    });
  }

  /* -------------------------------------------- */

  static ERROR = AdvancementError;

  /* -------------------------------------------- */

  /**
   * Configuration information for this advancement type.
   * @type {AdvancementMetadata}
   */
  static get metadata() {
    return {
      name: "Advancement",
      label: "DOCUMENT.DND5E.Advancement",
      order: 100,
      icon: "icons/svg/upgrade.svg",
      typeIcon: "icons/svg/upgrade.svg",
      title: game.i18n.localize("DND5E.AdvancementTitle"),
      hint: "",
      multiLevel: false,
      validItemTypes: new Set(["background", "class", "race", "subclass"]),
      apps: {
        config: AdvancementConfig$1,
        flow: AdvancementFlow
      }
    };
  }

  /* -------------------------------------------- */

  /**
   * Perform the pre-localization of this data model.
   */
  static localize() {
    foundry.helpers.Localization.localizeDataModel(this);
    if ( this.metadata.dataModels?.configuration ) {
      foundry.helpers.Localization.localizeDataModel(this.metadata.dataModels.configuration);
    }
    if ( this.metadata.dataModels?.value ) {
      foundry.helpers.Localization.localizeDataModel(this.metadata.dataModels.value);
    }
  }

  /* -------------------------------------------- */
  /*  Instance Properties                         */
  /* -------------------------------------------- */

  /**
   * 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 !this.classRestriction
      || (this.classRestriction === "primary" && [true, null].includes(originalClass))
      || (this.classRestriction === "secondary" && !originalClass);
  }

  /* -------------------------------------------- */

  /**
   * The default icon that will be used if one isn't specified.
   * @type {string}
   * @protected
   */
  get _defaultIcon() {
    return this.constructor.metadata.icon;
  }

  /* -------------------------------------------- */

  /**
   * The default title that will be used if one isn't specified.
   * @type {string}
   * @protected
   */
  get _defaultTitle() {
    return this.constructor.metadata.title;
  }

  /* -------------------------------------------- */

  /**
   * 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] : [];
  }

  /* -------------------------------------------- */
  /*  Preparation Methods                         */
  /* -------------------------------------------- */

  /**
   * Prepare data for the Advancement.
   */
  prepareData() {
    this.title = this.title || this._defaultTitle;
    this.icon = this.icon || this._defaultIcon;
  }

  /* -------------------------------------------- */

  /**
   * Perform preliminary operations before an Advancement is created.
   * @param {object} data      The initial data object provided to the document creation request.
   * @returns {boolean|void}   A return value of false indicates the creation operation should be cancelled.
   * @protected
   */
  _preCreate(data) {
    if ( !["class", "subclass"].includes(this.item.type)
      || foundry.utils.hasProperty(data, "level")
      || this.constructor.metadata.multiLevel ) return;
    this.updateSource({level: 1});
  }

  /* -------------------------------------------- */
  /*  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 {boolean} [options.legacyDisplay=false]  Use legacy formatting?
   * @param {boolean} [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, options={}) {
    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 {boolean} [options.legacyDisplay=false]  Use legacy formatting?
   * @param {boolean} [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, options={}) {
    return "";
  }

  /* -------------------------------------------- */
  /*  Editing Methods                             */
  /* -------------------------------------------- */

  /**
   * 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 when creating an advancement.
   */
  static availableForItem(item) {
    return true;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async delete(options={}) {
    if ( this.item.actor?.system.metadata?.supportsAdvancement
        && !game.settings.get("dnd5e", "disableAdvancements") ) {
      const manager = dnd5e.applications.advancement.AdvancementManager
        .forDeletedAdvancement(this.item.actor, this.item.id, this.id);
      if ( manager.steps.length ) return manager.render(true);
    }
    return super.delete(options);
  }

  /* -------------------------------------------- */
  /*  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) { }


  /* -------------------------------------------- */

  /**
   * Retrieves the data to pass to the apply method in order to apply this advancement automatically, if possible.
   * @param {number} level    Level being advanced.
   * @returns {object|false}  Data to pass to the apply method, or `false` if advancement requirers user intervention.
   */
  automaticApplicationValue(level) {
    return false;
  }

  /* -------------------------------------------- */

  /**
   * 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) { }

  /* -------------------------------------------- */

  /**
   * Fetch an item and create a clone with the proper flags.
   * @param {string} uuid  UUID of the item to fetch.
   * @param {string} [id]  Optional ID to use instead of a random one.
   * @returns {object|null}
   */
  async createItemData(uuid, id) {
    const source = await fromUuid(uuid);
    if ( !source ) return null;
    const { _stats } = game.items.fromCompendium(source);
    const advancementOrigin = `${this.item.id}.${this.id}`;
    return source.clone({
      _stats,
      _id: id ?? foundry.utils.randomID(),
      "flags.dnd5e.sourceId": uuid,
      "flags.dnd5e.advancementOrigin": advancementOrigin,
      "flags.dnd5e.advancementRoot": this.item.getFlag("dnd5e", "advancementRoot") ?? advancementOrigin
    }, { keepId: true }).toObject();
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Construct context menu options for this Activity.
   * @returns {ContextMenuEntry[]}
   */
  getContextMenuOptions() {
    if ( this.item.isOwner && !this.item.collection?.locked ) return [
      {
        name: "DND5E.ADVANCEMENT.Action.Edit",
        icon: "<i class='fas fa-edit fa-fw'></i>",
        callback: () => this.sheet?.render(true)
      },
      {
        name: "DND5E.ADVANCEMENT.Action.Duplicate",
        icon: "<i class='fas fa-copy fa-fw'></i>",
        condition: li => this?.constructor.availableForItem(this.item),
        callback: () => {
          const createData = this.toObject();
          delete createData._id;
          this.item.createAdvancement(createData.type, createData, { renderSheet: false });
        }
      },
      {
        name: "DND5E.ADVANCEMENT.Action.Delete",
        icon: "<i class='fas fa-trash fa-fw'></i>",
        callback: () => this.deleteDialog()
      }
    ];

    return [{
      name: "DND5E.ADVANCEMENT.Action.View",
      icon: "<i class='fas fa-eye fa-fw'></i>",
      callback: () => this.sheet?.render(true)
    }];
  }

  /* -------------------------------------------- */

  /**
   * Handle context menu events on activities.
   * @param {Item5e} item         The Item the Activity belongs to.
   * @param {HTMLElement} target  The element the menu was triggered on.
   */
  static onContextMenu(item, target) {
    const { id } = target.closest("[data-id]")?.dataset ?? {};
    const advancement = item.advancement?.byId[id];
    if ( !advancement ) return;
    const menuItems = advancement.getContextMenuOptions();

    /**
     * A hook even that fires when the context menu for an Advancement is opened.
     * @function dnd5e.getItemAdvancementContext
     * @memberof hookEvents
     * @param {Advancement} advancement       The Advancement.
     * @param {HTMLElement} target            The element that menu was triggered on.
     * @param {ContextMenuEntry[]} menuItems  The context menu entries.
     */
    Hooks.callAll("dnd5e.getItemAdvancementContext", advancement, target, menuItems);
    ui.context.menuItems = menuItems;
  }

  /* -------------------------------------------- */
  /*  Importing and Exporting                     */
  /* -------------------------------------------- */

  /** @override */
  static _createDialogData(type, parent) {
    const Advancement = CONFIG.DND5E.advancementTypes[type].documentClass;
    return {
      type,
      disabled: !Advancement.availableForItem(parent),
      label: Advancement.metadata?.title,
      hint: Advancement.metadata?.hint,
      icon: Advancement.metadata?.typeIcon ?? Advancement.metadata?.icon
    };
  }

  /* -------------------------------------------- */

  /** @override */
  static _createDialogTypes(parent) {
    return Object.entries(CONFIG.DND5E.advancementTypes)
      .filter(([, { hidden, validItemTypes }]) => !hidden && validItemTypes?.has(parent.type))
      .map(([k]) => k);
  }
}

/**
 * @import { _AdvancementManagerOptions, AdvancementStep } from "./_types.mjs";
 */

/**
 * @typedef {ApplicationConfiguration & _AdvancementManagerOptions} AdvancementManagerOptions
 */

/**
 * Application for controlling the advancement workflow and displaying the interface.
 * @extends {Application5e<AdvancementManagerOptions>}
 */
class AdvancementManager extends Application5e {
  /**
   * @param {Actor5e} actor        Actor on which this advancement is being performed.
   * @param {DeepPartial<AdvancementManagerOptions>} [options={}]  Additional application options.
   */
  constructor(actor, options={}) {
    super(options);
    this.actor = actor;
    this.clone = actor.clone({}, { keepId: true });
    if ( this.options.showVisualizer ) this.#visualizer = new AdvancementVisualizer({ manager: this });
  }

  /* -------------------------------------------- */

  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["advancement", "manager", "themed", "theme-light"], // TODO: Remove when flows converted to App V2.
    window: {
      icon: "fa-solid fa-forward",
      title: "DND5E.ADVANCEMENT.Manager.Title.Default"
    },
    actions: {
      complete: AdvancementManager.#process,
      next: AdvancementManager.#process,
      previous: AdvancementManager.#process,
      restart: AdvancementManager.#process
    },
    position: {
      width: 460
    },
    automaticApplication: false,
    showVisualizer: false
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    manager: {
      template: "systems/dnd5e/templates/advancement/advancement-manager.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The original actor to which changes will be applied when the process is complete.
   * @type {Actor5e}
   */
  actor;

  /* -------------------------------------------- */

  /**
   * Is the prompt currently advancing through un-rendered steps?
   * @type {boolean}
   */
  #advancing = false;

  /* -------------------------------------------- */

  /**
   * A clone of the original actor to which the changes can be applied during the advancement process.
   * @type {Actor5e}
   */
  clone;

  /* -------------------------------------------- */

  /** @inheritDoc */
  get subtitle() {
    const parts = [];

    // Item Name
    const item = this.step.flow.item;
    parts.push(item.name);

    // Class/Subclass level
    let level = this.step.flow.level;
    if ( this.step.class && ["class", "subclass"].includes(item.type) ) level = this.step.class.level;
    if ( level ) parts.push(game.i18n.format("DND5E.AdvancementLevelHeader", { level }));

    // Step Count
    const visibleSteps = this.steps.filter(s => !s.automatic);
    const visibleIndex = visibleSteps.indexOf(this.step);
    if ( visibleIndex >= 0 ) parts.push(game.i18n.format("DND5E.ADVANCEMENT.Manager.Steps", {
      current: visibleIndex + 1,
      total: visibleSteps.length
    }));

    return parts.join(" • ");
  }

  /* -------------------------------------------- */

  /** @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;
  }

  /* -------------------------------------------- */

  /**
   * Step being currently displayed.
   * @type {number|null}
   */
  #stepIndex = null;

  /* -------------------------------------------- */

  /**
   * Individual steps that will be applied in order.
   * @type {AdvancementStep[]}
   */
  steps = [];

  /* -------------------------------------------- */

  /**
   * 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;
  }

  /* -------------------------------------------- */

  /**
   * Side application for debugging advancement steps.
   * @type {AdvancementVisualizer}
   */
  #visualizer;

  /* -------------------------------------------- */
  /*  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)
    Array.fromRange(this.currentLevel(clonedItem, manager.clone) + 1)
      .flatMap(l => this.flowsForLevel(clonedItem, l))
      .forEach(flow => manager.steps.push({ type: "forward", flow }));

    // Ensure the final level is indicated to ensure any synthetic steps end up at correct level
    if ( actor.system.details.level > 0 ) manager.steps.push({
      type: "forward", automatic: true, level: actor.system.details.level
    });

    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(this.currentLevel(clonedItem, manager.clone) + 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.
   */
  createLevelChangeSteps(classItem, levelDelta) {
    const raceItem = this.clone.system?.details?.race instanceof Item ? this.clone.system.details.race : null;
    const pushSteps = (flows, data) => this.steps.push(...flows.map(flow => ({ flow, ...data })));
    const getItemFlows = (characterLevel, classLevel) => this.clone.items.contents.flatMap(i => {
      if ( ["class", "subclass", "race"].includes(i.type) ) return [];
      if ( ["class", "subclass"].includes(i.system.advancementRootItem?.type) && i.system.advancementClassLinked ) {
        const rootClass = i.system.advancementRootItem.class ?? i.system.advancementRootItem;
        if ( rootClass !== classItem ) return [];
        return this.constructor.flowsForLevel(i, classLevel);
      }
      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 ?? 0) + offset;
      const stepData = {
        type: "forward", class: { item: classItem, level: classLevel }, level: characterLevel
      };
      pushSteps(this.constructor.flowsForLevel(raceItem, characterLevel), stepData);
      pushSteps(this.constructor.flowsForLevel(classItem, classLevel), stepData);
      pushSteps(this.constructor.flowsForLevel(classItem.subclass, classLevel), stepData);
      pushSteps(getItemFlows(characterLevel, classLevel), stepData);
    }

    // Level decreased
    for ( let offset = 0; offset > levelDelta; offset-- ) {
      const classLevel = classItem.system.levels + offset;
      const characterLevel = (this.actor.system.details.level ?? 0) + offset;
      const stepData = {
        type: "reverse", class: {item: classItem, level: classLevel}, automatic: true, level: characterLevel
      };
      pushSteps(getItemFlows(characterLevel, classLevel).reverse(), stepData);
      pushSteps(this.constructor.flowsForLevel(classItem.subclass, classLevel).reverse(), stepData);
      pushSteps(this.constructor.flowsForLevel(classItem, classLevel).reverse(), stepData);
      pushSteps(this.constructor.flowsForLevel(raceItem, characterLevel).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 },
      level: (this.actor.system.details.level ?? 0) + 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.
   * @param {object} [options={}]
   * @param {AdvancementStep[]} [options.findExisting]  Find if an existing matching flow exists.
   * @returns {AdvancementFlow[]}                       Created or matched flow applications.
   */
  static flowsForLevel(item, level, { findExisting }={}) {
    const match = (advancement, step) => (step.flow?.item.id === item.id)
      && (step.flow?.advancement.id === advancement.id)
      && (step.flow?.level === level);
    return (item?.advancement.byLevel[level] ?? [])
      .filter(a => a.appliesToClass)
      .map(a => {
        const existing = findExisting?.find(s => match(a, s))?.flow;
        if ( !existing ) return new a.constructor.metadata.apps.flow(item, a.id, level);
        existing.item = item;
        return existing;
      });
  }

  /* -------------------------------------------- */

  /**
   * 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.advancementLevel ?? actor.system.details.level ?? 0;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _configureRenderOptions(options) {
    super._configureRenderOptions(options);
    options.window ??= {};
    options.window.subtitle ??= this.subtitle;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    if ( !this.step ) return context;

    const visibleSteps = this.steps.filter(s => !s.automatic);
    const visibleIndex = visibleSteps.indexOf(this.step);

    return {
      ...context,
      actor: this.clone,
      // Keep styles from non-converted flow applications functioning
      // Should be removed when V1 of `AdvancementFlow` is deprecated
      flowClasses: this.step.flow instanceof Application ? "dnd5e advancement flow" : "",
      flowId: this.step.flow.id,
      steps: {
        current: visibleIndex + 1,
        total: visibleSteps.length,
        hasPrevious: visibleIndex > 0,
        hasNext: visibleIndex < visibleSteps.length - 1
      }
    };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  render(forced=false, options={}) {
    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;

    const automaticData = (this.options.automaticApplication && (options.direction !== "backward"))
      ? this.step?.flow?.getAutomaticApplicationValue() : false;

    if ( this.step?.automatic || (automaticData !== false) ) {
      if ( this.#advancing ) return this;
      this.#forward({ automaticData });
      return this;
    }

    return super.render(forced, options);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    super._onRender(context, options);
    if ( !this.rendered || !this.step ) return;
    this.#visualizer?.render({ force: true });

    // Render the step
    this.step.flow._element = null;
    this.step.flow.options.manager ??= this;
    await this.step.flow._render(true, options);
    this.setPosition();
  }

  /* -------------------------------------------- */
  /*  Life-Cycle Handlers                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async close(options={}) {
    if ( !options.skipConfirmation ) {
      return new foundry.applications.api.Dialog({
        window: {
          title: `${game.i18n.localize("DND5E.ADVANCEMENT.Manager.ClosePrompt.Title")}: ${this.actor.name}`
        },
        position: { width: 400 },
        content: game.i18n.localize("DND5E.ADVANCEMENT.Manager.ClosePrompt.Message"),
        buttons: [
          {
            action: "stop",
            icon: "fas fa-times",
            label: game.i18n.localize("DND5E.ADVANCEMENT.Manager.ClosePrompt.Action.Stop"),
            default: true,
            callback: () => {
              this.#visualizer?.close();
              super.close(options);
            }
          },
          {
            action: "continue",
            icon: "fas fa-chevron-right",
            label: game.i18n.localize("DND5E.ADVANCEMENT.Manager.ClosePrompt.Action.Continue")
          }
        ]
      }).render({ force: true });
    }
    this.#visualizer?.close();
    await super.close(options);
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle one of the buttons for moving through the process.
   * @this {AdvancementManager}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static async #process(event, target) {
    target.disabled = true;
    this.element.querySelector(".error")?.classList.remove("error");
    try {
      switch ( target.dataset.action ) {
        case "restart":
          if ( this.previousStep ) await this.#restart(event);
          break;
        case "previous":
          if ( this.previousStep ) await this.#backward(event);
          break;
        case "next":
        case "complete":
          await this.#forward(event);
          break;
      }
    } finally {
      target.disabled = false;
    }
  }

  /* -------------------------------------------- */
  /*  Process                                     */
  /* -------------------------------------------- */

  /**
   * Advance through the steps until one requiring user interaction is encountered.
   * @param {object} config
   * @param {object} [config.automaticData]  Data provided to handle automatic application.
   * @param {Event} [config.event]           Triggering click event if one occurred.
   * @returns {Promise}
   */
  async #forward({ automaticData, event }) {
    this.#advancing = true;
    try {
      do {
        const flow = this.step.flow;
        const type = this.step.type;
        const preEmbeddedItems = Array.from(this.clone.items);

        // Apply changes based on step type
        if ( (type === "delete") && this.step.item ) {
          if ( this.step.flow?.retainedData?.retainedItems ) {
            this.step.flow.retainedData.retainedItems[this.step.item.flags.dnd5e?.sourceId] = this.step.item.toObject();
          }
          this.clone.items.delete(this.step.item.id);
        } else if ( (type === "delete") && this.step.advancement ) {
          this.step.advancement.item.deleteAdvancement(this.step.advancement.id, { source: true });
        }
        else if ( type === "restore" ) await flow.advancement.restore(flow.level, flow.retainedData);
        else if ( type === "reverse" ) await flow.retainData(await flow.advancement.reverse(flow.level));
        else if ( automaticData && flow ) await flow.advancement.apply(flow.level, automaticData);
        else if ( flow ) await flow._updateObject(event, flow._getSubmitData());

        this.#synthesizeSteps(preEmbeddedItems);
        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({ force: true, direction: "forward" });
    else this.#complete();
  }

  /* -------------------------------------------- */

  /**
   * Add synthetic steps for any added or removed items with advancement.
   * @param {Item5e[]} preEmbeddedItems  Items present before the current step was applied.
   */
  #synthesizeSteps(preEmbeddedItems) {
    // Build a set of item IDs for non-synthetic steps
    const initialIds = this.steps.reduce((ids, step) => {
      if ( step.synthetic || !step.flow?.item ) return ids;
      ids.add(step.flow.item.id);
      return ids;
    }, new Set());

    const preIds = new Set(preEmbeddedItems.map(i => i.id));
    const postIds = new Set(this.clone.items.map(i => i.id));
    const addedIds = postIds.difference(preIds).difference(initialIds);
    const deletedIds = preIds.difference(postIds).difference(initialIds);

    for ( const addedId of addedIds ) {
      const item = this.clone.items.get(addedId);
      if ( !item.hasAdvancement ) continue;

      let handledLevel = 0;
      for ( let idx = this.#stepIndex; idx < this.steps.length; idx++ ) {
        // Find spots where the level increases
        const getLevel = step => (item.system.advancementClassLinked ? undefined : step?.level)
          ?? step?.flow?.level ?? step?.class?.level ?? step?.level;
        const thisLevel = getLevel(this.steps[idx]);
        const nextLevel = getLevel(this.steps[idx + 1]);
        if ( (thisLevel < handledLevel) || (thisLevel >= nextLevel) ) continue;

        // Determine if there is any advancement to be done for the added item to this level
        // from the previously handled level
        const steps = Array.fromRange(thisLevel - handledLevel + 1, handledLevel)
          .flatMap(l => this.constructor.flowsForLevel(item, l, { findExisting: this.steps }))
          .map(flow => ({ type: "forward", flow, synthetic: true }));

        // Add new steps at the end of the level group
        this.steps.splice(idx + 1, 0, ...steps);
        idx += steps.length;

        handledLevel = nextLevel ?? handledLevel;
      }
    }

    if ( (this.step.type === "delete") && this.step.synthetic ) return;
    for ( const deletedId of deletedIds ) {
      let item = preEmbeddedItems.find(i => i.id === deletedId);
      if ( !item?.hasAdvancement ) continue;

      // Temporarily add the item back
      this.clone.updateSource({items: [item.toObject()]});
      item = this.clone.items.get(item.id);

      // Check for advancement from the maximum level handled by this manager to zero
      let steps = [];
      Array.fromRange(this.clone.system.details.level + 1)
        .flatMap(l => this.constructor.flowsForLevel(item, l))
        .reverse()
        .forEach(flow => steps.push({ type: "reverse", flow, automatic: true, synthetic: true }));

      // Add a new remove item step to the end of the synthetic steps to finally get rid of this item
      steps.push({ type: "delete", flow: this.step.flow, item, automatic: true, synthetic: true });

      // Add new steps after the current step
      this.steps.splice(this.#stepIndex + 1, 0, ...steps);
    }
  }

  /* -------------------------------------------- */

  /**
   * 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}
   */
  async #backward(event, { render=true }={}) {
    this.#advancing = true;
    try {
      do {
        this.#stepIndex--;
        if ( !this.step ) break;
        const flow = this.step.flow;
        const type = this.step.type;
        const preEmbeddedItems = Array.from(this.clone.items);

        // Reverse step based on step type
        if ( (type === "delete") && this.step.item ) this.clone.updateSource({items: [this.step.item]});
        else if ( (type === "delete") && this.step.advancement ) this.advancement.item.createAdvancement(
          this.advancement.typeName, this.advancement._source, { source: true }
        );
        else if ( type === "reverse" ) await flow.advancement.restore(flow.level, flow.retainedData);
        else if ( flow ) await flow.retainData(await flow.advancement.reverse(flow.level));

        this.#clearSyntheticSteps(preEmbeddedItems);
        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, { direction: "backward" });
    else this.close({ skipConfirmation: true });
  }

  /* -------------------------------------------- */

  /**
   * Remove synthetic steps for any added or removed items.
   * @param {Item5e[]} preEmbeddedItems  Items present before the current step was applied.
   */
  #clearSyntheticSteps(preEmbeddedItems) {
    // Create a disjoint union of the before and after items
    const preIds = new Set(preEmbeddedItems.map(i => i.id));
    const postIds = new Set(this.clone.items.map(i => i.id));
    const modifiedIds = postIds.symmetricDifference(preIds);

    // Remove any synthetic steps after the current step if their item has been modified
    for ( const [idx, element] of Array.from(this.steps.entries()).reverse() ) {
      if ( idx <= this.#stepIndex ) break;
      if ( element.synthetic && modifiedIds.has(element.flow?.item?.id) ) this.steps.splice(idx, 1);
    }
  }

  /* -------------------------------------------- */

  /**
   * Reset back to the manager's initial state.
   * @param {MouseEvent} [event]  The triggering click event if one occurred.
   * @returns {Promise}
   */
  async #restart(event) {
    const restart = await foundry.applications.api.Dialog.confirm({
      window: { title: game.i18n.localize("DND5E.ADVANCEMENT.Manager.RestartPrompt.Title") },
      content: `<p>${game.i18n.localize("DND5E.ADVANCEMENT.Manager.RestartPrompt.Message")}</p>`
    });
    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}
   */
  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 ) {
      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 });
  }
}

/* -------------------------------------------- */

/**
 * Debug application for visualizing advancement steps.
 * Note: Intentionally not localized due to its nature as a debug application.
 */
class AdvancementVisualizer extends Application5e {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["advancement-visualizer"],
    window: {
      title: "Advancement Steps"
    },
    position: {
      top: 50,
      left: 50,
      width: 440
    },
    manager: null
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    steps: {
      template: "systems/dnd5e/templates/advancement/advancement-visualizer.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The advancement manager that this is visualizing.
   * @type {AdvancementManager}
   */
  get manager() {
    return this.options.manager;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    context.steps = this.manager.steps.map(step => ({
      ...step,
      current: step === this.manager.step
    }));
    return context;
  }
}

/**
 * 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);
    });
  }

}

const { NumberField: NumberField$G, StringField: StringField$16 } = foundry.data.fields;

/**
 * @import { SpellScrollConfiguration } from "../../documents/_types.mjs";
 */

/**
 * Application for configuration spell scroll creation.
 */
class CreateScrollDialog extends Dialog5e {
  constructor(options={}) {
    super(options);
    this.#config = options.config;
    this.#spell = options.spell;
  }

  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["create-scroll"],
    window: {
      title: "DND5E.Scroll.CreateScroll",
      icon: "fa-solid fa-scroll"
    },
    form: {
      handler: CreateScrollDialog.#handleFormSubmission
    },
    position: {
      width: 420
    },
    buttons: [{
      action: "create",
      label: "DND5E.Scroll.CreateScroll",
      icon: "fa-solid fa-check",
      default: true
    }],
    config: null,
    spell: null
  };

  /** @inheritDoc */
  static PARTS = {
    ...super.PARTS,
    content: {
      template: "systems/dnd5e/templates/apps/spell-scroll-dialog.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Configuration options for scroll creation.
   * @type {SpellScrollConfiguration}
   */
  #config;

  get config() {
    return this.#config;
  }

  /* -------------------------------------------- */

  /**
   * Spell from which the scroll will be created.
   * @type {Item5e|object}
   */
  #spell;

  get spell() {
    return this.#spell;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /**
   * Prepare rendering context for the content section.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {Promise<ApplicationRenderContext>}
   * @protected
   */
  async _prepareContentContext(context, options) {
    context.anchor = this.spell instanceof Item ? this.spell.toAnchor().outerHTML : `<span>${this.spell.name}</span>`;
    context.config = this.config;
    context.fields = [{
      field: new StringField$16({
        required: true, blank: false,
        label: game.i18n.localize("DND5E.Scroll.Explanation.Label"),
        hint: game.i18n.localize("DND5E.Scroll.Explanation.Hint")
      }),
      name: "explanation",
      options: [
        { value: "full", label: game.i18n.localize("DND5E.Scroll.Explanation.Complete") },
        { value: "reference", label: game.i18n.localize("DND5E.Scroll.Explanation.Reference") },
        { value: "none", label: game.i18n.localize("DND5E.None") }
      ],
      value: this.config.explanation ?? "reference"
    }, {
      field: new NumberField$G({ label: game.i18n.localize("DND5E.SpellLevel") }),
      name: "level",
      options: Object.entries(CONFIG.DND5E.spellLevels)
        .map(([value, label]) => ({ value, label }))
        .filter(l => Number(l.value) >= this.spell.system.level),
      value: this.config.level ?? this.spell.system.level
    }];
    context.values = {
      bonus: new NumberField$G({ label: game.i18n.localize("DND5E.BonusAttack") }),
      dc: new NumberField$G({ label: game.i18n.localize("DND5E.Scroll.SaveDC") })
    };
    context.valuePlaceholders = {};
    for ( const level of Array.fromRange(this.config.level + 1).reverse() ) {
      context.valuePlaceholders = CONFIG.DND5E.spellScrollValues[level];
      if ( context.valuePlaceholders ) break;
    }
    return context;
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle submission of the dialog using the form buttons.
   * @this {CreateScrollDialog}
   * @param {Event|SubmitEvent} event    The form submission event.
   * @param {HTMLFormElement} form       The submitted form.
   * @param {FormDataExtended} formData  Data from the dialog.
   */
  static async #handleFormSubmission(event, form, formData) {
    foundry.utils.mergeObject(this.#config, formData.object);
    this.#config.level = Number(this.#config.level);
    await this.close({ dnd5e: { submitted: true } });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onChangeForm(formConfig, event) {
    super._onChangeForm(formConfig, event);
    const formData = new foundry.applications.ux.FormDataExtended(this.form);
    foundry.utils.mergeObject(this.#config, formData.object);
    this.#config.level = Number(this.#config.level);
    this.render({ parts: ["content"] });
  }

  /* -------------------------------------------- */

  /** @override */
  _onClose(options={}) {
    if ( !options.dnd5e?.submitted ) this.#config = null;
  }

  /* -------------------------------------------- */
  /*  Factory Methods                             */
  /* -------------------------------------------- */

  /**
   * Display the create spell scroll dialog.
   * @param {Item5e|object} spell              The spell or item data to be made into a scroll.
   * @param {SpellScrollConfiguration} config  Configuration options for scroll creation.
   * @param {object} [options={}]              Additional options for the application.
   * @returns {Promise<object|null>}           Form data object with results of the dialog.
   */
  static async create(spell, config, options={}) {
    return new Promise(resolve => {
      const dialog = new this({ spell, config, ...options });
      dialog.addEventListener("close", event => resolve(dialog.config), { once: true });
      dialog.render({ force: true });
    });
  }
}

const { BooleanField: BooleanField$G, StringField: StringField$15 } = foundry.data.fields;

/**
 * Configuration application for traits.
 */
class TraitConfig extends AdvancementConfig$1 {
  constructor(...args) {
    super(...args);
    this.selected = (this.config.choices.length && !this.config.grants.size) ? 0 : -1;
    this.trait = this.types.first() ?? "skills";
  }

  /* -------------------------------------------- */

  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["trait", "trait-selector"],
    actions: {
      addChoice: TraitConfig.#addChoice,
      removeChoice: TraitConfig.#removeChoice
    },
    position: {
      width: 680
    }
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  static PARTS = {
    config: {
      container: { classes: ["column-container"], id: "column-left" },
      template: "systems/dnd5e/templates/advancement/advancement-controls-section.hbs"
    },
    details: {
      container: { classes: ["column-container"], id: "column-left" },
      template: "systems/dnd5e/templates/advancement/trait-config-details.hbs"
    },
    guaranteed: {
      container: { classes: ["column-container"], id: "column-left" },
      template: "systems/dnd5e/templates/advancement/trait-config-guaranteed.hbs"
    },
    choices: {
      container: { classes: ["column-container"], id: "column-left" },
      template: "systems/dnd5e/templates/advancement/trait-config-choices.hbs"
    },
    traits: {
      container: { classes: ["column-container"], id: "column-right" },
      template: "systems/dnd5e/templates/advancement/trait-config-traits.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Shortcut to the configuration data on the advancement.
   * @type {object}
   */
  get config() {
    return this.advancement.configuration;
  }

  /* -------------------------------------------- */

  /**
   * Index of the selected configuration, `-1` means `grants` array, any other number is equal
   * to an index in `choices` array.
   * @type {number}
   */
  selected;

  /* -------------------------------------------- */

  /**
   * Trait type to display in the selector interface.
   * @type {string}
   */
  trait;

  /* -------------------------------------------- */

  /**
   * List of trait types for the current selected configuration.
   * @type {Set<string>}
   */
  get types() {
    const pool = this.selected === -1 ? this.config.grants : this.config.choices[this.selected].pool;
    return this.advancement.representedTraits([pool]);
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);

    context.grants = {
      label: localizedList({ grants: this.config.grants }) || "—",
      data: this.config.grants,
      selected: this.selected === -1
    };
    context.choices = this.config.choices.map((choice, index) => ({
      label: choiceLabel(choice, { only: true }).capitalize() || "—",
      data: choice,
      selected: this.selected === index
    }));
    const chosen = (this.selected === -1) ? context.grants.data : context.choices[this.selected].data.pool;
    if ( this.selected !== -1 ) context.count = {
      field: context.configuration.fields.choices.element.fields.count,
      value: context.choices[this.selected]?.data.count
    };
    context.selectedIndex = this.selected;

    const rep = this.advancement.representedTraits();
    context.disableAllowReplacements = rep.size > 1;
    const traitConfig = rep.size === 1 ? CONFIG.DND5E.traits[rep.first()] : null;
    if ( traitConfig ) {
      context.default.title = traitConfig.labels.title;
      context.default.icon = traitConfig.icon;
    } else {
      context.default.title = game.i18n.localize("DND5E.TraitGenericPlural.other");
      context.default.icon = this.advancement.constructor.metadata.icon;
    }
    context.default.hint = localizedList({ grants: this.config.grants, choices: this.config.choices });

    context.trait = {
      field: new BooleanField$G(),
      input: context.inputs.createCheckboxInput,
      options: await choices(this.trait, { chosen, prefixed: true, any: this.selected !== -1 }),
      selected: this.trait,
      selectedHeader: `${CONFIG.DND5E.traits[this.trait].labels.localization}.other`,
      typeField: new StringField$15({
        required: true, blank: false, label: game.i18n.localize("DND5E.ADVANCEMENT.Trait.TraitType")
      }),
      typeOptions: Object.entries(CONFIG.DND5E.traits)
        .filter(([, config]) => ((this.config.mode === "default") || (this.config.mode === "mastery"
          ? config.mastery : config.expertise)) && (config.dataType !== Number))
        .map(([value, config]) => ({ value, label: config.labels.title }))
    };

    // Disable selecting categories in mastery mode
    if ( this.advancement.configuration.mode === "mastery" ) {
      context.trait.options.forEach((key, value) => value.disabled = !!value.children);
    }

    context.mode = {
      hint: CONFIG.DND5E.traitModes[this.advancement.configuration.mode].hint,
      options: Object.entries(CONFIG.DND5E.traitModes).map(([value, { label }]) => ({ value, label }))
    };

    return context;
  }

  /* -------------------------------------------- */
  /*  Life-Cycle Handlers                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onRender(context, options) {
    super._onRender(context, options);
    // Handle selecting & disabling category children when a category is selected
    for ( const checkbox of this.element.querySelectorAll(".trait-list dnd5e-checkbox[checked]") ) {
      const toCheck = (checkbox.name.endsWith("*") || checkbox.name.endsWith("ALL"))
        ? checkbox.closest("ol").querySelectorAll(`dnd5e-checkbox:not([name="${checkbox.name}"])`)
        : checkbox.closest("li").querySelector("ol")?.querySelectorAll("dnd5e-checkbox");
      toCheck?.forEach(i => i.checked = i.disabled = true);
    }
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle adding a new choice.
   * @this {TraitConfig}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static async #addChoice(event, target) {
    this.config.choices.push({ count: 1 });
    this.selected = this.config.choices.length - 1;
    await this.advancement.update({ configuration: await this.prepareConfigurationUpdate() });
  }

  /* -------------------------------------------- */

  /**
   * Handle removing a choice.
   * @this {TraitConfig}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static async #removeChoice(event, target) {
    const input = target.closest("li").querySelector("[name='selectedIndex']");
    const selectedIndex = Number(input.value);
    this.config.choices.splice(selectedIndex, 1);
    if ( selectedIndex <= this.selected ) this.selected -= 1;
    await this.advancement.update({ configuration: await this.prepareConfigurationUpdate() });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onChangeForm(formConfig, event) {
    // Display new set of trait choices
    if ( event.target.name === "selectedTrait" ) {
      this.trait = event.target.value;
      return this.render();
    }

    // Change selected configuration set
    else if ( event.target.name === "selectedIndex" ) {
      this.selected = Number(event.target.value ?? -1);
      const types = this.types;
      if ( types.size && !types.has(this.trait) ) this.trait = types.first();
      return this.render();
    }

    // If mode is changed from default to one of the others, change selected type if current type is not valid
    if ( (event.target.name === "configuration.mode")
      && (event.target.value !== "default")
      && (event.target.value !== this.config.mode) ) {
      const checkKey = event.target.value === "mastery" ? "mastery" : "expertise";
      const validTraitTypes = filteredKeys(CONFIG.DND5E.traits, c => c[checkKey]);
      if ( !validTraitTypes.includes(this.trait) ) this.trait = validTraitTypes[0];
    }

    super._onChangeForm(formConfig, event);
  }

  /* -------------------------------------------- */
  /*  Form Handling                               */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async prepareConfigurationUpdate(configuration={}) {
    const choicesCollection = foundry.utils.deepClone(this.config.choices);

    if ( configuration.checked ) {
      const prefix = `${this.trait}:`;
      const filteredSelected = filteredKeys(configuration.checked);

      // Update grants
      if ( this.selected === -1 ) {
        const filteredPrevious = this.config.grants.filter(k => !k.startsWith(prefix));
        configuration.grants = [...filteredPrevious, ...filteredSelected];
      }

      // Update current choice pool
      else {
        const current = choicesCollection[this.selected];
        const filteredPrevious = current.pool.filter(k => !k.startsWith(prefix));
        current.pool = [...filteredPrevious, ...filteredSelected];
      }
      delete configuration.checked;
    }

    if ( configuration.count ) {
      choicesCollection[this.selected].count = configuration.count;
      delete configuration.count;
    }

    configuration.choices = choicesCollection;
    configuration.grants ??= Array.from(this.config.grants);

    // If one of the expertise modes is selected, filter out any traits that are not of a valid type
    if ( (configuration.mode ?? this.config.mode) !== "default" ) {
      const checkKey = (configuration.mode ?? this.config.mode) === "mastery" ? "mastery" : "expertise";
      const validTraitTypes = filteredKeys(CONFIG.DND5E.traits, c => c[checkKey]);
      configuration.grants = configuration.grants.filter(k => validTraitTypes.some(t => k.startsWith(t)));
      configuration.choices.forEach(c => c.pool = c.pool?.filter(k => validTraitTypes.some(t => k.startsWith(t))));
    }

    return configuration;
  }
}

/**
 * @import { TraitConfiguration } from "../../_types.mjs";
 */

/**
 * Inline application that presents the player with a trait choices.
 */
class TraitFlow extends AdvancementFlow {

  /**
   * Array of trait keys currently chosen.
   * @type {Set<string>}
   */
  chosen;

  /* -------------------------------------------- */

  /** @inheritDoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      template: "systems/dnd5e/templates/advancement/trait-flow.hbs"
    });
  }

  /* -------------------------------------------- */

  /**
   * Trait configuration from `CONFIG.TRAITS` for this advancement's trait type.
   * @type {TraitConfiguration}
   */
  get traitConfig() {
    return CONFIG.DND5E.traits[this.advancement.configuration.type];
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getData() {
    this.chosen ??= await this.prepareInitialValue();
    const available = await this.advancement.availableChoices(this.chosen);
    return foundry.utils.mergeObject(super.getData(), {
      hint: this.advancement.hint ? this.advancement.hint : localizedList({
        grants: this.advancement.configuration.grants, choices: this.advancement.configuration.choices
      }),
      slots: this.prepareTraitSlots(available),
      available
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  activateListeners(html) {
    this.form.querySelectorAll("select").forEach(s => s.addEventListener("change", this._onSelectTrait.bind(this)));
    this.form.querySelectorAll(".remove").forEach(s => s.addEventListener("click", this._onRemoveTrait.bind(this)));
  }

  /* -------------------------------------------- */

  /**
   * Add a trait to the value when one is selected.
   * @param {Event} event  Triggering change to a select input.
   */
  _onSelectTrait(event) {
    const addedTrait = event.target.value;
    if ( addedTrait === "" ) return;
    this.chosen.add(addedTrait);
    this.render();
  }

  /* -------------------------------------------- */

  /**
   * Remove a trait for the value when the remove button is clicked.
   * @param {Event} event  Triggering click.
   */
  _onRemoveTrait(event) {
    const tag = event.target.closest(".trait-slot");
    this.chosen.delete(tag.dataset.key);
    this.render();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _updateObject(event, formData) {
    if ( formData.chosen && !Array.isArray(formData.chosen) ) formData.chosen = [formData.chosen];
    super._updateObject(event, formData);
  }

  /* -------------------------------------------- */
  /*  Data Preparation Methods                    */
  /* -------------------------------------------- */

  /**
   * When only a single choice is available, automatically select that choice provided value is empty.
   * @returns {Set<string>}
   */
  async prepareInitialValue() {
    const existingChosen = this.retainedData?.chosen ?? this.advancement.value.chosen;
    if ( existingChosen?.size ) return new Set(existingChosen);
    const { available } = await this.advancement.unfulfilledChoices();
    const chosen = new Set();
    for ( const { choices } of available ) {
      const set = choices.asSet();
      if ( set.size === 1 ) chosen.add(set.first());
    }
    return chosen;
  }

  /* -------------------------------------------- */

  /**
   * Prepare the list of slots to be populated by traits.
   * @param {object} available  Trait availability returned by `prepareAvailableTraits`.
   * @returns {object[]}
   */
  prepareTraitSlots(available) {
    const config = this.advancement.configuration;
    const count = config.choices.reduce((count, c) => count + c.count, config.grants.size);
    const chosen = Array.from(this.chosen);
    let selectorShown = false;
    const slots = [];
    for ( let i = 1; i <= count; i++ ) {
      const key = chosen.shift();
      if ( selectorShown || (!key && !available) ) continue;
      selectorShown = !key;
      slots.push({
        key,
        label: key ? keyLabel(key, { type: config.type }) : null,
        showDelete: !this.advancement.configuration.grants.has(key),
        showSelector: !key
      });
    }
    return slots;
  }
}

const { ArrayField: ArrayField$j, BooleanField: BooleanField$F, NumberField: NumberField$F, SetField: SetField$y, SchemaField: SchemaField$P, StringField: StringField$14 } = foundry.data.fields;

/**
 * @import { TraitAdvancementConfigurationData } from "./_types.mjs";
 */

/**
 * Map language category changes.
 * @type {Record<string, string>}
 */
const _MAP = {
  "languages:exotic:draconic": "languages:standard:draconic",
  "languages:cant": "languages:exotic:cant",
  "languages:druidic": "languages:exotic:druidic"
};

const LANGUAGE_MAP = { modern: _MAP, legacy: foundry.utils.invertObject(_MAP) };

/**
 * Configuration data for the TraitAdvancement.
 * @extends {foundry.abstract.DataModel<TraitAdvancementConfigurationData>}
 * @mixes TraitAdvancementConfigurationData
 */
class TraitConfigurationData extends foundry.abstract.DataModel {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.ADVANCEMENT.Trait"];

  /* -------------------------------------------- */

  /** @override */
  static defineSchema() {
    return {
      allowReplacements: new BooleanField$F({ required: true }),
      choices: new ArrayField$j(new SchemaField$P({
        count: new NumberField$F({ required: true, positive: true, integer: true, initial: 1 }),
        pool: new SetField$y(new StringField$14())
      })),
      grants: new SetField$y(new StringField$14(), { required: true }),
      mode: new StringField$14({ required: true, blank: false, initial: "default" })
    };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static migrateData(source) {
    super.migrateData(source);
    const version = game.settings.get("dnd5e", "rulesVersion");
    const languageMap = LANGUAGE_MAP[version] ?? {};
    if ( source.grants?.length ) source.grants = source.grants.map(t => languageMap[t] ?? t);
    if ( source.choices?.length ) source.choices.forEach(c => c.pool = c.pool.map(t => languageMap[t] ?? t));
    return source;
  }
}

/**
 * Value data for the TraitAdvancement.
 * @extends {foundry.abstract.DataModel<TraitAdvancementValueData>}
 * @mixes TraitAdvancementValueData
 */
class TraitValueData extends foundry.abstract.DataModel {
  /** @override */
  static defineSchema() {
    return {
      chosen: new SetField$y(new StringField$14(), { required: false })
    };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static migrateData(source) {
    super.migrateData(source);
    const version = game.settings.get("dnd5e", "rulesVersion");
    const languageMap = LANGUAGE_MAP[version] ?? {};
    if ( source.chosen?.length ) source.chosen = source.chosen.map(t => languageMap[t] ?? t);
    return source;
  }
}

/**
 * @import { TraitChoices } from "./_types.mjs";
 */

/**
 * Advancement that grants the player with certain traits or presents them with a list of traits from which
 * to choose.
 */
class TraitAdvancement extends Advancement {

  /** @inheritDoc */
  static get metadata() {
    return foundry.utils.mergeObject(super.metadata, {
      dataModels: {
        configuration: TraitConfigurationData,
        value: TraitValueData
      },
      order: 30,
      icon: "icons/sundries/scrolls/scroll-yellow-teal.webp",
      typeIcon: "systems/dnd5e/icons/svg/trait.svg",
      title: game.i18n.localize("DND5E.ADVANCEMENT.Trait.Title"),
      hint: game.i18n.localize("DND5E.ADVANCEMENT.Trait.Hint"),
      apps: {
        config: TraitConfig,
        flow: TraitFlow
      }
    });
  }

  /* -------------------------------------------- */

  /**
   * Perform the pre-localization of this data model.
   */
  static localize() {
    super.localize();
    localizeSchema(
      this.metadata.dataModels.configuration.schema.fields.choices.element,
      ["DND5E.ADVANCEMENT.Trait.FIELDS.choices"]
    );
  }

  /* -------------------------------------------- */

  /**
   * The maximum number of traits granted by this advancement. The number of traits actually granted may be lower if
   * actor already has some traits.
   * @type {number}
   */
  get maxTraits() {
    const { grants, choices } = this.configuration;
    return grants.size + choices.reduce((acc, choice) => acc + choice.count, 0);
  }

  /* -------------------------------------------- */
  /*  Preparation Methods                         */
  /* -------------------------------------------- */

  /**
   * Prepare data for the Advancement.
   */
  prepareData() {
    const rep = this.representedTraits();
    const traitConfig = rep.size === 1 ? CONFIG.DND5E.traits[rep.first()] : null;
    this.title = this.title || traitConfig?.labels.title || this._defaultTitle;
    this.icon = this.icon || traitConfig?.icon || this._defaultIcon;
  }

  /* -------------------------------------------- */
  /*  Display Methods                             */
  /* -------------------------------------------- */

  /** @inheritDoc */
  configuredForLevel(level) {
    return !!this.value.chosen?.size;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  sortingValueForLevel(levels) {
    const traitOrder = Object.keys(CONFIG.DND5E.traits).findIndex(k => k === this.representedTraits().first());
    const modeOrder = Object.keys(CONFIG.DND5E.traitModes).findIndex(k => k === this.configuration.mode);
    const order = traitOrder + (modeOrder * 100);
    return `${this.constructor.metadata.order.paddedString(4)} ${order.paddedString(4)} ${this.titleForLevel(levels)}`;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  summaryForLevel(level, { configMode=false }={}) {
    if ( configMode ) {
      if ( this.hint ) return `<p>${this.hint}</p>`;
      return `<p>${localizedList({
        grants: this.configuration.grants, choices: this.configuration.choices
      })}</p>`;
    } else {
      return Array.from(this.value?.chosen ?? []).map(k => `<span class="tag">${keyLabel(k)}</span>`).join(" ");
    }
  }

  /* -------------------------------------------- */
  /*  Application Methods                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async apply(level, data) {
    const updates = {};
    if ( !data.chosen ) return;

    for ( const key of data.chosen ) {
      const keyPath = this.configuration.mode === "mastery" ? "system.traits.weaponProf.mastery.value"
        : changeKeyPath(key);
      let existingValue = updates[keyPath] ?? foundry.utils.getProperty(this.actor, keyPath);

      if ( ["Array", "Set"].includes(foundry.utils.getType(existingValue)) ) {
        existingValue = new Set(existingValue);
        existingValue.add(key.split(":").pop());
        updates[keyPath] = Array.from(existingValue);
      } else if ( (this.configuration.mode !== "expertise") || (existingValue !== 0) ) {
        updates[keyPath] = (this.configuration.mode === "default")
          || ((this.configuration.mode === "upgrade") && (existingValue === 0)) ? 1 : 2;
      }

      if ( key.startsWith("tool") ) {
        const toolId = key.split(":").pop();
        const ability = CONFIG.DND5E.tools[toolId]?.ability;
        const kp = `system.tools.${toolId}.ability`;
        if ( ability && !foundry.utils.hasProperty(this.actor, kp) ) updates[kp] = ability;
      }
    }

    this.actor.updateSource(updates);
    this.updateSource({ "value.chosen": Array.from(data.chosen) });
  }

  /* -------------------------------------------- */

  /** @override */
  automaticApplicationValue(level) {
    // TODO: Ideally this would be able to detect situations where choices are automatically fulfilled because
    // they only have one valid option, but that is an async process and cannot be called from within `render`
    if ( this.configuration.choices.length || this.configuration.allowReplacements ) return false;
    return { chosen: Array.from(this.configuration.grants) };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async restore(level, data) {
    this.apply(level, data);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async reverse(level) {
    const updates = {};
    if ( !this.value.chosen ) return;

    for ( const key of this.value.chosen ) {
      const keyPath = this.configuration.mode === "mastery" ? "system.traits.weaponProf.mastery.value"
        : changeKeyPath(key);
      let existingValue = updates[keyPath] ?? foundry.utils.getProperty(this.actor, keyPath);

      if ( ["Array", "Set"].includes(foundry.utils.getType(existingValue)) ) {
        existingValue = new Set(existingValue);
        existingValue.delete(key.split(":").pop());
        updates[keyPath] = Array.from(existingValue);
      }

      else if ( this.configuration.mode === "expertise" ) updates[keyPath] = 1;
      else if ( this.configuration.mode === "upgrade" ) updates[keyPath] = existingValue === 1 ? 0 : 1;
      else updates[keyPath] = 0;
      // NOTE: When using forced expertise mode, this will not return to original value
      // if the value before being applied is 1.
    }

    const retainedData = foundry.utils.deepClone(this.value);
    this.actor.updateSource(updates);
    this.updateSource({ "value.chosen": [] });
    return retainedData;
  }

  /* -------------------------------------------- */
  /*  Helper Methods                              */
  /* -------------------------------------------- */

  /**
   * Two sets of keys based on actor data, one that is considered "selected" and thus unavailable to be chosen
   * and another that is "available". This is based off configured advancement mode.
   * @returns {{selected: Set<string>, available: Set<string>}}
   */
  async actorSelected() {
    const selected = new Set();
    const available = new Set();

    // If "default" mode is selected, return all traits
    // If any other mode is selected, only return traits that support expertise or mastery
    const traitTypes = this.configuration.mode === "default" ? Object.keys(CONFIG.DND5E.traits).filter(k => k !== "dm")
      : filteredKeys(CONFIG.DND5E.traits, t => t[this.configuration.mode === "mastery" ? "mastery" : "expertise"]);

    for ( const trait$1 of traitTypes ) {
      const actorValues$1 = await actorValues(this.actor, trait$1);
      const choices$1 = await choices(trait$1, { prefixed: true });
      for ( const key of choices$1.asSet() ) {
        const value = actorValues$1[key] ?? 0;
        if ( this.configuration.mode === "default" ) {
          if ( value >= 1 ) selected.add(key);
          else available.add(key);
        } else if ( this.configuration.mode === "mastery" ) {
          const split = key.split(":");
          split.pop();
          const category = split.join(":");
          if ( value === 2 ) selected.add(key);
          if ( (value === 1) || (actorValues$1[category] === 1) ) available.add(key);
        } else {
          if ( value === 2 ) selected.add(key);
          if ( (this.configuration.mode === "expertise") && (value === 1) ) available.add(key);
          else if ( (this.configuration.mode !== "expertise") && (value < 2) ) available.add(key);
        }
      }
    }

    return { selected, available };
  }

  /* -------------------------------------------- */

  /**
   * Guess the trait type from the grants & choices on this advancement.
   * @param {Set<string>[]} [pools]  Trait pools to use when figuring out the type.
   * @returns {Set<string>}
   */
  representedTraits(pools) {
    const set = new Set();
    pools ??= [this.configuration.grants, ...this.configuration.choices.map(c => c.pool)];
    for ( const pool of pools ) {
      for ( const key of pool ) {
        const [type] = key.split(":");
        set.add(type);
      }
    }
    return set;
  }

  /* -------------------------------------------- */

  /**
   * Prepare the list of available traits from which the player can choose.
   * @param {Set<string>} [chosen]  Traits already chosen on the advancement. If not set then it will
   *                                be retrieved from advancement's value.
   * @returns {{choices: SelectChoices, label: string}|null}
   */
  async availableChoices(chosen) {
    // TODO: Still shows "Choose 1 x" even if not possible due to mode restriction
    let { available, choices } = await this.unfulfilledChoices(chosen);

    // If all traits of this type are already assigned, then nothing new can be selected
    if ( foundry.utils.isEmpty(choices) ) return null;

    // Remove any grants that have no choices remaining
    let unfilteredLength = available.length;
    available = available.filter(a => a.choices.asSet().size > 0);

    // If replacements are allowed and there are grants with zero choices from their limited set,
    // display all remaining choices as an option
    if ( this.configuration.allowReplacements && (unfilteredLength > available.length) ) {
      const rep = this.representedTraits();
      if ( rep.size === 1 ) return {
        choices: choices.filter(this.representedTraits().map(t => `${t}:*`), { inplace: false }),
        label: game.i18n.format("DND5E.ADVANCEMENT.Trait.ChoicesRemaining", {
          count: unfilteredLength,
          type: traitLabel(rep.first(), unfilteredLength)
        })
      };
      // TODO: This works well for types without categories like skills where it is primarily intended,
      // but perhaps there could be some improvements elsewhere. For example, if I have an advancement
      // that grants proficiency in the Bagpipes and allows replacements, but the character already has
      // Bagpipe proficiency. In this example this just lets them choose from any other tool proficiency
      // as their replacement, but it might make sense to only show other musical instruments unless
      // they already have proficiency in all musical instruments. Might not be worth the effort.
    }

    if ( !available.length ) return null;

    // Create a choices object featuring a union of choices from all remaining grants
    const remainingSet = new Set(available.flatMap(a => Array.from(a.choices.asSet())));
    choices.filter(remainingSet);

    const rep = this.representedTraits(available.map(a => a.choices.asSet()));
    return {
      choices,
      label: game.i18n.format("DND5E.ADVANCEMENT.Trait.ChoicesRemaining", {
        count: available.length,
        type: traitLabel(rep.size === 1 ? rep.first() : null, available.length)
      })
    };
  }

  /* -------------------------------------------- */

  /**
   * Determine which of the provided grants, if any, still needs to be fulfilled.
   * @param {Set<string>} [chosen]  Traits already chosen on the advancement. If not set then it will
   *                                be retrieved from advancement's value.
   * @returns {{ available: TraitChoices[], choices: SelectChoices }}
   */
  async unfulfilledChoices(chosen) {
    const actorData = await this.actorSelected();
    const selected = {
      actor: actorData.selected,
      item: chosen ?? this.value.selected ?? new Set()
    };

    // If everything has already been selected, no need to go further
    if ( this.maxTraits <= selected.item.size ) {
      return { available: [], choices: new SelectChoices() };
    }

    const available = await Promise.all([
      ...this.configuration.grants.map(async g => ({
        type: "grant",
        choices: await mixedChoices(new Set([g]))
      })),
      ...this.configuration.choices.reduce((arr, choice, index) => {
        return arr.concat(Array.fromRange(choice.count).map(async () => ({
          type: "choice",
          choiceIdx: index,
          choices: await mixedChoices(choice.pool)
        })));
      }, [])
    ]);

    available.sort((lhs, rhs) => lhs.choices.asSet().size - rhs.choices.asSet().size);

    // Remove any fulfilled grants
    for ( const key of selected.item ) available.findSplice(grant => grant.choices.asSet().has(key));

    // Merge all possible choices into a single SelectChoices
    const allChoices = await mixedChoices(actorData.available);
    allChoices.exclude(new Set([...(selected.actor ?? []), ...selected.item]));
    available.forEach(a => a.choices = allChoices.filter(a.choices, { inplace: false }));

    return { available, choices: allChoices };
  }
}

const TextEditor$d = foundry.applications.ux.TextEditor.implementation;

/**
 * @import { CompendiumBrowserFilterDefinition } from "../../applications/compendium-browser.mjs";
 * @import { SystemDataModelMetadata } from "./_types.mjs";
 */

/**
 * 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.
 */
let SystemDataModel$1 = class SystemDataModel extends foundry.abstract.TypeDataModel {

  /** @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 = [];

  /* -------------------------------------------- */

  /**
   * The field names of the base templates used for construction.
   * @type {Set<string>}
   * @private
   */
  static get _schemaTemplateFields() {
    const fieldNames = Object.freeze(new Set(this._schemaTemplates.map(t => t.schema.keys()).flat()));
    Object.defineProperty(this, "_schemaTemplateFields", {
      value: fieldNames,
      writable: false,
      configurable: false
    });
    return fieldNames;
  }

  /* -------------------------------------------- */

  /**
   * 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", "cleanData", "_cleanData",
    "_initializationOrder", "validateJoint", "_validateJoint", "migrateData", "_migrateData",
    "shimData", "_shimData", "defineSchema"]);

  /* -------------------------------------------- */

  /**
   * Metadata that describes this DataModel.
   * @type {SystemDataModelMetadata}
   */
  static metadata = Object.freeze({
    systemFlagsModel: null
  });

  get metadata() {
    return this.constructor.metadata;
  }

  /* -------------------------------------------- */

  /**
   * Filters available for this item type when using the compendium browser.
   * @returns {CompendiumBrowserFilterDefinition}
   */
  static get compendiumBrowserFilters() {
    return new Map();
  }

  /* -------------------------------------------- */

  /**
   * Key path to the description used for default embeds.
   * @type {string|null}
   */
  get embeddedDescriptionKeyPath() {
    return null;
  }

  /* -------------------------------------------- */

  /** @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;
  }

  /* -------------------------------------------- */
  /*  Data Cleaning                               */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static cleanData(source, options) {
    this._cleanData(source, options);
    return super.cleanData(source, options);
  }

  /* -------------------------------------------- */

  /**
   * Performs cleaning without calling DataModel.cleanData.
   * @param {object} [source]         The source data
   * @param {object} [options={}]     Additional options (see DataModel.cleanData)
   * @protected
   */
  static _cleanData(source, options) {
    for ( const template of this._schemaTemplates ) {
      template._cleanData(source, options);
    }
  }

  /* -------------------------------------------- */
  /*  Data Initialization                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static *_initializationOrder() {
    for ( const template of this._schemaTemplates ) {
      for ( const entry of template._initializationOrder() ) {
        entry[1] = this.schema.get(entry[0]);
        yield entry;
      }
    }
    for ( const entry of this.schema.entries() ) {
      if ( this._schemaTemplateFields.has(entry[0]) ) continue;
      yield entry;
    }
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /**
   * Pre-creation logic for this system data.
   * @param {object} data               The initial data object provided to the document creation request.
   * @param {object} options            Additional options which modify the creation request.
   * @param {User} user                 The User requesting the document creation.
   * @returns {Promise<boolean|void>}   A return value of false indicates the creation operation should be cancelled.
   * @see {Document#_preCreate}
   * @protected
   */
  async _preCreate(data, options, user) {
    const actor = this.parent.actor;
    if ( (actor?.type !== "character") || !this.metadata?.singleton ) return;
    if ( actor.itemTypes[data.type]?.length ) {
      ui.notifications.error("DND5E.ACTOR.Warning.Singleton", {
        format: {
          itemType: game.i18n.localize(CONFIG.Item.typeLabels[data.type]),
          actorType: game.i18n.localize(CONFIG.Actor.typeLabels[actor.type])
        }
      });
      return false;
    }
  }

  /* -------------------------------------------- */
  /*  Data Validation                             */
  /* -------------------------------------------- */

  /** @inheritDoc */
  validate(options={}) {
    if ( this.constructor._enableV10Validation === false ) return true;
    return super.validate(options);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static validateJoint(data) {
    this._validateJoint(data);
    return super.validateJoint(data);
  }

  /* -------------------------------------------- */

  /**
   * Performs joint validation without calling DataModel.validateJoint.
   * @param {object} data     The source data
   * @throws                  An error if a validation failure is detected
   * @protected
   */
  static _validateJoint(data) {
    for ( const template of this._schemaTemplates ) {
      template._validateJoint(data);
    }
  }

  /* -------------------------------------------- */
  /*  Data Migration                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static migrateData(source) {
    this._migrateData(source);
    return super.migrateData(source);
  }

  /* -------------------------------------------- */

  /**
   * Performs migration without calling DataModel.migrateData.
   * @param {object} source     The source data
   * @protected
   */
  static _migrateData(source) {
    for ( const template of this._schemaTemplates ) {
      template._migrateData(source);
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static shimData(data, options) {
    this._shimData(data, options);
    return super.shimData(data, options);
  }

  /* -------------------------------------------- */

  /**
   * Performs shimming without calling DataModel.shimData.
   * @param {object} data         The source data
   * @param {object} [options]    Additional options (see DataModel.shimData)
   * @protected
   */
  static _shimData(data, options) {
    for ( const template of this._schemaTemplates ) {
      template._shimData(data, options);
    }
  }

  /* -------------------------------------------- */
  /*  Mixins                                      */
  /* -------------------------------------------- */

  /**
   * Mix multiple templates with the base type.
   * @param {...*} templates            Template classes to mix.
   * @returns {typeof SystemDataModel}  Final prepared type.
   */
  static mixin(...templates) {
    for ( const template of templates ) {
      if ( !(template.prototype instanceof SystemDataModel) ) {
        throw new Error(`${template.name} is not a subclass of SystemDataModel`);
      }
    }

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

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /** @override */
  async toEmbed(config, options={}) {
    const keyPath = this.embeddedDescriptionKeyPath;
    if ( !keyPath || !foundry.utils.hasProperty(this, keyPath) ) return null;
    const enriched = await TextEditor$d.enrichHTML(foundry.utils.getProperty(this, keyPath), {
      ...options,
      relativeTo: this.parent
    });
    const container = document.createElement("div");
    container.innerHTML = enriched;
    return container.children;
  }
};

const TextEditor$c = foundry.applications.ux.TextEditor.implementation;

/**
 * @import { FavoriteData5e, ItemDataModelMetadata } from "./_types.mjs";
 */

/**
 * Variant of the SystemDataModel with support for rich item tooltips.
 */
let ItemDataModel$1 = class ItemDataModel extends SystemDataModel$1 {

  /** @type {ItemDataModelMetadata} */
  static metadata = Object.freeze(foundry.utils.mergeObject(super.metadata, {
    enchantable: false,
    hasEffects: false,
    singleton: false
  }, { inplace: false }));

  /**
   * The handlebars template for rendering item tooltips.
   * @type {string}
   */
  static ITEM_TOOLTIP_TEMPLATE = "systems/dnd5e/templates/items/parts/item-tooltip.hbs";

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Can this item's advancement level be taken from an associated class?
   * @type {boolean}
   */
  get advancementClassLinked() {
    return true;
  }

  /* -------------------------------------------- */

  /**
   * The level at which this item's advancement is applied.
   * @type {number}
   */
  get advancementLevel() {
    let item = this.parent;
    if ( ["class", "subclass"].includes(this.advancementRootItem?.type)
      && this.advancementClassLinked ) item = this.advancementRootItem;
    return item.system.levels ?? item.class?.system.levels ?? item.actor?.system.details.level ?? 0;
  }

  /* -------------------------------------------- */

  /**
   * The item that is ultimately responsible for adding this item through the advancement system.
   * @type {Item5e|void}
   */
  get advancementRootItem() {
    return this.parent?.actor?.items.get(this.parent.getFlag("dnd5e", "advancementRoot")?.split(".")?.[0]);
  }

  /* -------------------------------------------- */

  /**
   * Whether this item's activities can have scaling configured for their consumption.
   * @type {boolean}
   */
  get canConfigureScaling() {
    return false;
  }

  /* -------------------------------------------- */

  /**
   * Whether this item's activities should prompt for scaling when used.
   * @type {boolean}
   */
  get canScale() {
    return false;
  }

  /* -------------------------------------------- */

  /**
   * Whether this item's activities can have scaling configured for their damage.
   * @type {boolean}
   */
  get canScaleDamage() {
    return false;
  }

  /* -------------------------------------------- */

  /**
   * Modes that can be used when making an attack with this item.
   * @type {FormSelectOption[]}
   */
  get attackModes() {
    return [];
  }

  /* -------------------------------------------- */

  /**
   * Set of abilities that can automatically be associated with this item.
   * @type {Set<string>|null}
   */
  get availableAbilities() {
    return null;
  }

  /* -------------------------------------------- */

  /** @override */
  get embeddedDescriptionKeyPath() {
    return game.user.isGM || (this.identified !== false) ? "description.value" : "unidentified.description";
  }

  /* -------------------------------------------- */

  /**
   * Scaling increase for this item type.
   * @type {number|null}
   */
  get scalingIncrease() {
    return null;
  }

  /* -------------------------------------------- */

  /**
   * Parts making up the subtitle on the item's tooltip.
   * @type {string[]}
   */
  get tooltipSubtitle() {
    return [this.type?.label ?? game.i18n.localize(CONFIG.Item.typeLabels[this.parent.type])];
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareBaseData() {
    if ( this.parent.isEmbedded && this.parent.actor?.items.has(this.parent.id) ) {
      this.parent.actor.identifiedItems?.set(this.parent.identifier, this.parent);
      const sourceId = this.parent._stats.compendiumSource ?? this.parent.flags.dnd5e?.sourceId;
      if ( sourceId ) this.parent.actor.sourcedItems?.set(sourceId, this.parent);
    }
  }

  /* -------------------------------------------- */
  /*  Drag & Drop                                 */
  /* -------------------------------------------- */

  /**
   * Handle any specific item changes when an item is dropped onto an actor.
   * @param {DragEvent} event  The concluding DragEvent which provided the drop data.
   * @param {Actor5e} actor    Actor onto which the item was dropped.
   * @param {object} itemData  The item data requested for creation. **Will be mutated.**
   */
  static onDropCreate(event, actor, itemData) {}

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Render a rich tooltip for this item.
   * @param {EnrichmentOptions} [enrichmentOptions={}]  Options for text enrichment.
   * @returns {{content: string, classes: string[]}}
   */
  async richTooltip(enrichmentOptions={}) {
    return {
      content: await foundry.applications.handlebars.renderTemplate(
        this.constructor.ITEM_TOOLTIP_TEMPLATE, await this.getCardData(enrichmentOptions)
      ),
      classes: ["dnd5e2", "dnd5e-tooltip", "item-tooltip", "themed", "theme-light"]
    };
  }

  /* -------------------------------------------- */

  /**
   * Prepare item card template data.
   * @param {EnrichmentOptions} [enrichmentOptions={}]  Options for text enrichment.
   * @param {Activity} [enrichmentOptions.activity]     Specific activity on item to use for customizing the data.
   * @returns {Promise<object>}
   */
  async getCardData({ activity, ...enrichmentOptions }={}) {
    const { name, type, img } = this.parent;
    let {
      price, weight, uses, identified, unidentified, description, school, materials
    } = this;
    const rollData = (activity ?? this.parent).getRollData();
    const isIdentified = identified !== false;
    const chat = isIdentified ? description.chat || description.value : unidentified?.description;
    description = game.user.isGM || isIdentified ? description.value : unidentified?.description;
    uses = this.hasLimitedUses && (game.user.isGM || identified) ? uses : null;
    price = game.user.isGM || identified ? price : null;

    const context = {
      name, type, img, price, weight, uses, school, materials,
      config: CONFIG.DND5E,
      controlHints: game.settings.get("dnd5e", "controlHints"),
      labels: foundry.utils.deepClone((activity ?? this.parent).labels),
      tags: this.parent.labels?.components?.tags,
      subtitle: this.tooltipSubtitle.filterJoin(" • "),
      description: {
        value: await TextEditor$c.enrichHTML(description ?? "", {
          rollData, relativeTo: this.parent, ...enrichmentOptions
        }),
        chat: await TextEditor$c.enrichHTML(chat ?? "", {
          rollData, relativeTo: this.parent, ...enrichmentOptions
        }),
        concealed: game.user.isGM && game.settings.get("dnd5e", "concealItemDescriptions") && !description.chat
      }
    };

    context.properties = [];

    if ( game.user.isGM || isIdentified ) {
      context.properties.push(
        ...this.cardProperties ?? [],
        ...Object.values((activity ? activity?.activationLabels : this.parent.labels.activations?.[0]) ?? {}),
        ...this.equippableItemCardProperties ?? []
      );
    }

    context.properties = context.properties.filter(_ => _);
    context.hasProperties = context.tags?.length || context.properties.length;
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Determine the cost to craft this Item.
   * @param {object} [options]
   * @param {"buy"|"craft"|"none"} [options.baseItem="craft"]  Ignore base item if "none". Include full base item gold
   *                                                           price if "buy". Include base item craft costs if "craft".
   * @returns {Promise<{ days: number, gold: number }>}
   */
  async getCraftCost({ baseItem="craft" }={}) {
    let days = 0;
    let gold = 0;
    if ( !("price" in this) ) return { days, gold };
    const { price, type, rarity } = this;

    // Mundane Items
    if ( !this.properties.has("mgc") || !rarity ) {
      const { mundane } = CONFIG.DND5E.crafting;
      const valueInGP = price.valueInGP ?? 0;
      return { days: Math.ceil(valueInGP * mundane.days), gold: Math.floor(valueInGP * mundane.gold) };
    }

    const base = await getBaseItem(type?.identifier ?? "", { fullItem: true });
    if ( base && (baseItem !== "none") ) {
      if ( baseItem === "buy" ) gold += base.system.price.valueInGP ?? 0;
      else {
        const costs = await base.system.getCraftCost();
        days += costs.days;
        gold += costs.gold;
      }
    }

    const { magic } = CONFIG.DND5E.crafting;
    if ( !(rarity in magic) ) return { days, gold };
    const costs = magic[rarity];
    return { days: days + costs.days, gold: gold + costs.gold };
  }

  /* -------------------------------------------- */

  /**
   * Prepare item favorite data.
   * @returns {Promise<FavoriteData5e>}
   */
  async getFavoriteData() {
    return {
      img: this.parent.img,
      title: this.parent.name,
      subtitle: game.i18n.localize(CONFIG.Item.typeLabels[this.parent.type])
    };
  }

  /* -------------------------------------------- */

  /**
   * Prepare type-specific data for the Item sheet.
   * @param {ApplicationRenderContext} context  Sheet context data.
   * @returns {Promise<void>}
   */
  async getSheetData(context) {}

  /* -------------------------------------------- */

  /**
   * Prepare a data object which defines the data schema used by dice roll commands against this Item.
   * @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.
   * @returns {object}
   */
  getRollData({ deterministic=false }={}) {
    const actorRollData = this.parent.actor?.getRollData({ deterministic }) ?? {};
    const data = { ...actorRollData, item: { ...this } };
    return data;
  }
};

const { SchemaField: SchemaField$O, StringField: StringField$13 } = foundry.data.fields;

/**
 * Data field for class & subclass spellcasting information.
 */
class SpellcastingField extends SchemaField$O {
  constructor(fields={}, options={}) {
    fields = {
      progression: new StringField$13({
        required: true,
        initial: "none",
        blank: false,
        label: "DND5E.SpellProgression"
      }),
      ability: new StringField$13({ label: "DND5E.SpellAbility" }),
      preparation: new SchemaField$O({
        formula: new FormulaField({ label: "DND5E.SpellPreparation.Formula" })
      }),
      ...fields
    };
    Object.entries(fields).forEach(([k, v]) => !v ? delete fields[k] : null);
    super(fields, { label: "DND5E.Spellcasting", ...options });
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /**
   * Prepare data for this field. Should be called during the `prepareFinalData` stage.
   * @this {ItemDataModel}
   * @param {object} rollData  Roll data used for formula replacements.
   */
  static prepareData(rollData) {
    this.spellcasting.preparation.max = simplifyBonus(this.spellcasting.preparation.formula, rollData);

    // Temp method for determining spellcasting type until this data is available directly using advancement
    this.spellcasting.type = CONFIG.DND5E.spellProgression[this.spellcasting.progression]?.type;
    this.spellcasting.slots = CONFIG.DND5E.spellcasting[this.spellcasting.type]?.slots;

    const actor = this.parent.actor;
    if ( !actor ) return;
    this.spellcasting.levels = this.levels ?? this.parent.class?.system.levels;

    // Prepare attack bonus and save DC
    const ability = actor.system.abilities?.[this.spellcasting.ability];
    const mod = ability?.mod ?? 0;
    const modProf = mod + (actor.system.attributes?.prof ?? 0);
    const msak = simplifyBonus(actor.system.bonuses?.msak?.attack, rollData);
    const rsak = simplifyBonus(actor.system.bonuses?.rsak?.attack, rollData);
    this.spellcasting.attack = modProf + (msak === rsak ? msak : 0);
    this.spellcasting.save = ability?.dc ?? (8 + modProf);
  }
}

/**
 * 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]?.documentClass ?? 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);
  }

  /* -------------------------------------------- */

  /**
   * Migrate this field's candidate source data.
   * @param {object} sourceData   Candidate source data of the root model
   * @param {any} fieldData       The value of this field within the source data
   */
  migrateSource(sourceData, fieldData) {
    const cls = this.getModelForType(fieldData.type);
    if ( cls ) cls.migrateDataSafe(fieldData);
  }
}

const { ArrayField: ArrayField$i } = foundry.data.fields;

/**
 * @import { AdvancementTemplateData } from "./_types.mjs";
 */

/**
 * Data model template for items with advancement.
 * @extends {SystemDataModel<AdvancementTemplateData>}
 * @mixin
 */
class AdvancementTemplate extends SystemDataModel$1 {
  /** @inheritDoc */
  static defineSchema() {
    return {
      advancement: new ArrayField$i(new AdvancementField(), { label: "DND5E.AdvancementTitle" })
    };
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /**
   * If no advancement data exists on the item, create some default advancement.
   * @param {object} data     The initial data object provided to the document creation request.
   * @param {object} options  Additional options which modify the creation request.
   */
  async preCreateAdvancement(data, options) {
    if ( data._id || foundry.utils.hasProperty(data, "system.advancement") ) return;
    const toCreate = this._advancementToCreate(options);
    if ( toCreate.length ) this.parent.updateSource({
      "system.advancement": toCreate.map(c => {
        const baseData = foundry.utils.deepClone(c);
        const config = CONFIG.DND5E.advancementTypes[c.type];
        const cls = config.documentClass ?? config;
        const advancement = new cls(c, { parent: this.parent });
        if ( advancement._preCreate(baseData) === false ) return null;
        return advancement.toObject();
      }).filter(_ => _)
    });
  }

  /* -------------------------------------------- */

  /**
   * Create a list of advancement data to be created on new items of this type.
   * @param {object} options  Additional options which modify the creation request.
   * @returns {object[]}
   * @protected
   */
  _advancementToCreate(options) {
    return [];
  }
}

const { NumberField: NumberField$E, SchemaField: SchemaField$N, StringField: StringField$12 } = foundry.data.fields;

/**
 * @import { SourceData } from "./_types.mjs";
 */

/**
 * Data fields that stores information on the adventure or sourcebook where this document originated.
 */
class SourceField extends SchemaField$N {
  constructor(fields={}, options={}) {
    fields = {
      book: new StringField$12(),
      page: new StringField$12(),
      custom: new StringField$12(),
      license: new StringField$12(),
      revision: new NumberField$E({ initial: 1 }),
      rules: new StringField$12({
        initial: () => game.settings.get("dnd5e", "rulesVersion") === "modern" ? "2024" : "2014"
      }),
      ...fields
    };
    Object.entries(fields).forEach(([k, v]) => !v ? delete fields[k] : null);
    super(fields, { label: "DND5E.SOURCE.FIELDS.source.label", ...options });
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /**
   * Prepare the source label.
   * @this {SourceData}
   * @param {string} uuid  Compendium source or document UUID.
   */
  static prepareData(uuid) {
    const collection = foundry.utils.parseUuid(uuid)?.collection;
    const pkg = SourceField.getPackage(collection);
    this.bookPlaceholder = collection?.metadata?.flags?.dnd5e?.sourceBook ?? SourceField.getModuleBook(pkg) ?? "";
    if ( !this.book ) this.book = this.bookPlaceholder;

    if ( this.custom ) this.label = this.custom;
    else {
      const page = Number.isNumeric(this.page)
        ? game.i18n.format("DND5E.SOURCE.Display.Page", { page: this.page }) : (this.page ?? "");
      this.label = game.i18n.format("DND5E.SOURCE.Display.Full", { book: this.book, page }).trim();
    }

    this.value = this.book || (pkg?.title ?? "");
    this.slug = formatIdentifier(this.value);

    Object.defineProperty(this, "directlyEditable", {
      value: (this.custom ?? "") === this.label,
      configurable: true,
      enumerable: false
    });
  }

  /* -------------------------------------------- */

  /**
   * Check if the provided package has any source books registered in its manifest. If it has only one, then return
   * that book's key.
   * @param {ClientPackage} pkg  The package.
   * @returns {string|null}
   */
  static getModuleBook(pkg) {
    if ( !pkg ) return null;
    const sourceBooks = pkg.flags?.dnd5e?.sourceBooks;
    const keys = Object.keys(sourceBooks ?? {});
    if ( keys.length !== 1 ) return null;
    return keys[0];
  }

  /* -------------------------------------------- */

  /**
   * Get the package associated with the given UUID, if any.
   * @param {CompendiumCollection|string} uuidOrCollection  The document UUID or its collection.
   * @returns {ClientPackage|null}
   */
  static getPackage(uuidOrCollection) {
    const pack = typeof uuidOrCollection === "string" ? foundry.utils.parseUuid(uuidOrCollection)?.collection?.metadata
      : uuidOrCollection?.metadata;
    switch ( pack?.packageType ) {
      case "module": return game.modules.get(pack.packageName);
      case "system": return game.system;
      case "world": return game.world;
    }
    return null;
  }
}

const { SchemaField: SchemaField$M, HTMLField: HTMLField$8 } = foundry.data.fields;

/**
 * @import { CompendiumBrowserFilterDefinitionEntry } from "../../../applications/compendium-browser.mjs";
 * @import { ItemDescriptionTemplateData } from "./_types.mjs";
 */

/**
 * Data model template with item description & source.
 * @extends {SystemDataModel<ItemDescriptionTemplateData>}
 * @mixin
 */
class ItemDescriptionTemplate extends SystemDataModel$1 {
  /** @inheritDoc */
  static defineSchema() {
    return {
      description: new SchemaField$M({
        value: new HTMLField$8({ required: true, nullable: true, label: "DND5E.Description" }),
        chat: new HTMLField$8({ required: true, nullable: true, label: "DND5E.DescriptionChat" })
      }),
      identifier: new IdentifierField({ required: true, label: "DND5E.Identifier" }),
      source: new SourceField()
    };
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * What properties can be used for this item?
   * @returns {Set<string>}
   */
  get validProperties() {
    return new Set(CONFIG.DND5E.validProperties[this.parent.type] ?? []);
  }

  /* -------------------------------------------- */
  /*  Data Migration                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static _migrateData(source) {
    super._migrateData(source);
    ItemDescriptionTemplate.#migrateSource(source);
  }

  /* -------------------------------------------- */

  /**
   * Convert source string into custom object.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateSource(source) {
    if ( ("source" in source) && (foundry.utils.getType(source.source) !== "Object") ) {
      source.source = { custom: source.source };
    }
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /**
   * Prepare the source label.
   */
  prepareDescriptionData() {
    const uuid = this.parent.flags.dnd5e?.sourceId ?? this.parent._stats?.compendiumSource ?? this.parent.uuid;
    SourceField.prepareData.call(this.source, uuid);
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Create the properties filter configuration for a type.
   * @param {string} type  Item type.
   * @returns {CompendiumBrowserFilterDefinitionEntry}
   */
  static compendiumBrowserPropertiesFilter(type) {
    return {
      label: "DND5E.Properties",
      type: "set",
      config: {
        choices: Object.entries(CONFIG.DND5E.itemProperties).reduce((obj, [k, v]) => {
          if ( CONFIG.DND5E.validProperties[type]?.has(k) ) obj[k] = v;
          return obj;
        }, {}),
        keyPath: "system.properties",
        multiple: true
      }
    };
  }
}

/**
 * Field that stores activities on an item.
 */
class ActivitiesField extends MappingField {
  constructor(options) {
    super(new ActivityField(), options);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  initialize(value, model, options) {
    const activities = Object.values(super.initialize(value, model, options));
    activities.sort((a, b) => a.sort - b.sort);
    return new ActivityCollection(model, activities);
  }
}

/* -------------------------------------------- */

/**
 * Field that stores activity data and swaps class based on activity type.
 */
class ActivityField extends foundry.data.fields.ObjectField {

  /** @override */
  static recursive = true;

  /* -------------------------------------------- */

  /**
   * Get the document type for this activity.
   * @param {object} value            Activity data being prepared.
   * @returns {typeof Activity|null}  Activity document type.
   */
  getModel(value) {
    return CONFIG.DND5E.activityTypes[value.type]?.documentClass ?? null;
  }

  /* -------------------------------------------- */

  /** @override */
  _cleanType(value, options) {
    if ( !(typeof value === "object") ) value = {};

    const cls = this.getModel(value);
    if ( cls ) return cls.cleanData(value, options);
    return value;
  }

  /* -------------------------------------------- */

  /** @override */
  initialize(value, model, options = {}) {
    const cls = this.getModel(value);
    if ( cls ) return new cls(value, { parent: model, ...options });
    return foundry.utils.deepClone(value);
  }

  /* -------------------------------------------- */

  /**
   * Migrate this field's candidate source data.
   * @param {object} sourceData  Candidate source data of the root model.
   * @param {any} fieldData      The value of this field within the source data.
   */
  migrateSource(sourceData, fieldData) {
    const cls = this.getModel(fieldData);
    if ( cls ) cls.migrateDataSafe(fieldData);
  }
}

/* -------------------------------------------- */

/**
 * Specialized collection type for stored activities.
 * @param {DataModel} model     The parent DataModel to which this ActivityCollection belongs.
 * @param {Activity[]} entries  The activities to store.
 */
class ActivityCollection extends Collection {
  constructor(model, entries) {
    super();
    this.#model = model;
    for ( const entry of entries ) {
      if ( !(entry instanceof BaseActivityData) ) continue;
      this.set(entry._id, entry);
    }
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The parent DataModel to which this ActivityCollection belongs.
   * @type {DataModel}
   */
  #model;

  /* -------------------------------------------- */

  /**
   * Pre-organized arrays of activities by type.
   * @type {Map<string, Set<string>>}
   */
  #types = new Map();

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Fetch an array of activities of a certain type.
   * @param {string} type  Activity type.
   * @returns {Activity[]}
   */
  getByType(type) {
    return Array.from(this.#types.get(type) ?? []).map(key => this.get(key));
  }

  /* -------------------------------------------- */

  /**
   * Generator that yields activities for each of the provided types.
   * @param {string[]} types  Types to fetch.
   * @yields {Activity}
   */
  *getByTypes(...types) {
    for ( const type of types ) {
      for ( const activity of this.getByType(type) ) yield activity;
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  set(key, value) {
    if ( !this.#types.has(value.type) ) this.#types.set(value.type, new Set());
    this.#types.get(value.type).add(key);
    return super.set(key, value);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  delete(key) {
    this.#types.get(this.get(key)?.type)?.delete(key);
    return super.delete(key);
  }

  /* -------------------------------------------- */

  /**
   * Test the given predicate against every entry in the Collection.
   * @param {function(*, number, ActivityCollection): boolean} predicate  The predicate.
   * @returns {boolean}
   */
  every(predicate) {
    return this.reduce((pass, v, i) => pass && predicate(v, i, this), true);
  }

  /* -------------------------------------------- */

  /**
   * Convert the ActivityCollection to an array of simple objects.
   * @param {boolean} [source=true]  Draw data for contained Documents from the underlying data source?
   * @returns {object[]}             The extracted array of primitive objects.
   */
  toObject(source=true) {
    return this.map(doc => doc.toObject(source));
  }
}

/**
 * @import { AdvantageModeData } from "./_types.mjs";
 */

/**
 * Subclass of NumberField that tracks the number of changes made to a roll mode.
 */
class AdvantageModeField extends foundry.data.fields.NumberField {
  /** @inheritDoc */
  static get _defaults() {
    return foundry.utils.mergeObject(super._defaults, {
      choices: AdvantageModeField.#values,
      initial: 0,
      label: "DND5E.AdvantageMode"
    });
  }

  /* -------------------------------------------- */

  /**
   * Allowed advantage mode values.
   * @type {number[]}
   */
  static #values = [-1, 0, 1];

  /* -------------------------------------------- */
  /*  Active Effect Integration                   */
  /* -------------------------------------------- */

  /** @override */
  _applyChangeAdd(value, delta, model, change) {
    // Add a source of advantage or disadvantage.
    if ( (delta !== -1) && (delta !== 1) ) return value;
    const counts = this.constructor.getCounts(model, change.key);
    if ( delta === 1 ) counts.advantages.count++;
    else counts.disadvantages.count++;
    return this.constructor.resolveMode(model, change, counts);
  }

  /* -------------------------------------------- */

  /** @override */
  _applyChangeDowngrade(value, delta, model, change) {
    // Downgrade the roll so that it can no longer benefit from advantage.
    if ( (delta !== -1) && (delta !== 0) ) return value;
    const counts = this.constructor.getCounts(model, change.key);
    counts.advantages.suppressed = true;
    if ( delta === -1 ) counts.disadvantages.count++;
    return this.constructor.resolveMode(model, change, counts);
  }

  /* -------------------------------------------- */

  /** @override */
  _applyChangeMultiply(value, delta, model, change) {
    return value;
  }

  /* -------------------------------------------- */

  /** @override */
  _applyChangeOverride(value, delta, model, change) {
    // Force a given roll mode.
    if ( (delta === -1) || (delta === 0) || (delta === 1) ) {
      this.constructor.getCounts(model, change.key).override = delta;
      return delta;
    }
    return value;
  }

  /* -------------------------------------------- */

  /** @override */
  _applyChangeUpgrade(value, delta, model, change) {
    // Upgrade the roll so that it can no longer be penalised by disadvantage.
    if ( (delta !== 1) && (delta !== 0) ) return value;
    const counts = this.constructor.getCounts(model, change);
    counts.disadvantages.suppressed = true;
    if ( delta === 1 ) counts.advantages.count++;
    return this.constructor.resolveMode(model, change, counts);
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Retrieve the counts from several advantage mode fields and determine the final advantage mode.
   * @param {DataModel} model                      The model containing the fields.
   * @param {string[]} keyPaths                    Paths to the individual fields to combine within the model.
   * @param {Partial<AdvantageModeData>} [counts]  External sources of advantage/disadvantage.
   * @returns {{ advantage: boolean, disadvantage: boolean, mode: number }}
   */
  static combineFields(model, keyPaths, counts={}) {
    counts = foundry.utils.mergeObject({
      override: null,
      advantages: { count: 0, suppressed: false },
      disadvantages: { count: 0, suppressed: false }
    }, counts);
    for ( const kp of keyPaths ) {
      const c = this.getCounts(model, kp);
      const src = foundry.utils.getProperty(model._source, kp) ?? 0;
      if ( c.override !== null ) counts.override = c.override;
      if ( c.advantages.suppressed ) counts.advantages.suppressed = true;
      if ( c.disadvantages.suppressed ) counts.disadvantages.suppressed = true;
      counts.advantages.count += c.advantages.count + Number(src === 1);
      counts.disadvantages.count += c.disadvantages.count + Number(src === -1);
    }
    return {
      advantage: (((counts.advantages.count > 0) && (counts.override === null)) || (counts.override === 1))
        && !counts.advantages.suppressed,
      disadvantage: (((counts.disadvantages.count > 0) && (counts.override === null)) || (counts.override === -1))
        && !counts.disadvantages.suppressed,
      mode: this.resolveMode(model, null, counts)
    };
  }

  /* -------------------------------------------- */

  /**
   * Retrieve the advantage/disadvantage counts from the model.
   * @param {DataModel} model                  The model the change is applied to.
   * @param {string|EffectChangeData} keyPath  Path to the field or effect change being applied.
   * @returns {AdvantageModeData}
   */
  static getCounts(model, keyPath) {
    keyPath = foundry.utils.getType(keyPath) === "Object" ? keyPath.key : keyPath;
    const parentKey = keyPath.substring(0, keyPath.lastIndexOf("."));
    const roll = foundry.utils.getProperty(model, parentKey) ?? {};
    return roll.modeCounts ??= {
      override: null,
      advantages: { count: 0, suppressed: false },
      disadvantages: { count: 0, suppressed: false }
    };
  }

  /* -------------------------------------------- */

  /**
   * Resolve multiple sources of advantage and disadvantage into a single roll mode per the game rules.
   * @param {DataModel} model                  The model the change is applied to.
   * @param {string|EffectChangeData} keyPath  Path to the field or effect change being applied.
   * @param {AdvantageModeData} [counts]       The current advantage/disadvantage counts.
   * @returns {number}                         An integer in the interval [-1, 1], indicating advantage (1),
   *                                           disadvantage (-1), or neither (0).
   */
  static resolveMode(model, keyPath, counts) {
    keyPath = foundry.utils.getType(keyPath) === "Object" ? keyPath.key : keyPath;
    const { override, advantages, disadvantages } = counts ?? this.getCounts(model, keyPath);
    if ( override !== null ) return override;
    const src = foundry.utils.getProperty(model._source, keyPath) ?? 0;
    const advantageCount = advantages.suppressed ? 0 : advantages.count + Number(src === 1);
    const disadvantageCount = disadvantages.suppressed ? 0 : disadvantages.count + Number(src === -1);
    return Math.sign(advantageCount) - Math.sign(disadvantageCount);
  }

  /* -------------------------------------------- */

  /**
   * Helper for setting the advantage mode programmatically.
   * @param {DataModel} model                   The model the change is applied to.
   * @param {string} keyPath                    Path to the advantage mode field on the model.
   * @param {number} value                      An integer in the interval [-1, 1], indicating advantage (1),
   *                                            disadvantage (-1), or neither (0).
   * @param {object} [options={}]
   * @param {boolean} [options.override=false]  Override the mode rather than following the normal advantage rules.
   * @returns {number}                          Final advantage value.
   */
  static setMode(model, keyPath, value, { override=false }={}) {
    const field = keyPath.startsWith("system.") ? model.system.schema.getField(keyPath.slice(7))
      : model.schema.getField(keyPath);
    const change = { key: keyPath, value, mode: CONST.ACTIVE_EFFECT_MODES[override ? "OVERRIDE" : "ADD"] };
    const final = field.applyChange(foundry.utils.getProperty(model, keyPath), model, change);
    foundry.utils.setProperty(model, keyPath, final);
    return final;
  }
}

/**
 * @import { LocalDocumentFieldOptions } from "./_types.mjs";
 */

/**
 * A mirror of ForeignDocumentField that references a Document embedded within this Document.
 *
 * @param {typeof Document} model              The local DataModel class definition which this field should link to.
 * @param {LocalDocumentFieldOptions} options  Options which configure the behavior of the field.
 */
class LocalDocumentField extends foundry.data.fields.DocumentIdField {
  constructor(model, options={}) {
    if ( !foundry.utils.isSubclass(model, foundry.abstract.DataModel) ) {
      throw new Error("A ForeignDocumentField must specify a DataModel subclass as its type");
    }

    super(options);
    this.model = model;
  }

  /* -------------------------------------------- */

  /**
   * A reference to the model class which is stored in this field.
   * @type {typeof Document}
   */
  model;

  /* -------------------------------------------- */

  /** @inheritDoc */
  static get _defaults() {
    return foundry.utils.mergeObject(super._defaults, {
      nullable: true,
      readonly: false,
      idOnly: false,
      fallback: false
    });
  }

  /* -------------------------------------------- */

  /** @override */
  _cast(value) {
    if ( typeof value === "string" ) return value;
    if ( (value instanceof this.model) ) return value._id;
    throw new Error(`The value provided to a LocalDocumentField must be a ${this.model.name} instance.`);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _validateType(value) {
    if ( !this.options.fallback ) super._validateType(value);
  }

  /* -------------------------------------------- */

  /**
   * Step up through model's parents to find the specified collection.
   * @param {DataModel} model
   * @param {string} collection
   * @returns {EmbeddedCollection|void}
   */
  _findCollection(model, collection) {
    if ( !model.parent ) return;
    try {
      return model.parent.getEmbeddedCollection(collection);
    } catch(err) {
      return model.parent[collection] ?? this._findCollection(model.parent, collection);
    }
  }

  /* -------------------------------------------- */

  /** @override */
  initialize(value, model, options={}) {
    if ( this.idOnly ) return this.options.fallback || foundry.data.validators.isValidId(value) ? value : null;
    const collection = this._findCollection(model, this.model.metadata.collection);
    return () => {
      const document = collection?.get(value);
      if ( !document ) return this.options.fallback ? value : null;
      if ( this.options.fallback ) Object.defineProperty(document, "toString", {
        value: () => document.name,
        configurable: true,
        enumerable: false
      });
      return document;
    };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  toObject(value) {
    return value?._id ?? value;
  }
}

var _module$y = /*#__PURE__*/Object.freeze({
  __proto__: null,
  ActivitiesField: ActivitiesField,
  ActivityCollection: ActivityCollection,
  ActivityField: ActivityField,
  AdvancementDataField: AdvancementDataField,
  AdvancementField: AdvancementField,
  AdvantageModeField: AdvantageModeField,
  FormulaField: FormulaField,
  IdentifierField: IdentifierField,
  LocalDocumentField: LocalDocumentField,
  MappingField: MappingField
});

const {
  ArrayField: ArrayField$h, BooleanField: BooleanField$E, DocumentIdField: DocumentIdField$9, EmbeddedDataField: EmbeddedDataField$5, IntegerSortField: IntegerSortField$1, NumberField: NumberField$D, StringField: StringField$11
} = foundry.data.fields;

/**
 * @import { StartingEquipmentTemplate } from "./_types.mjs";
 */

/**
 * Data model template representing a background & class's starting equipment.
 * @extends {SystemDataModel<StartingEquipmentTemplateData>}
 * @mixin
 */
class StartingEquipmentTemplate extends SystemDataModel$1 {
  /** @inheritDoc */
  static defineSchema() {
    return {
      startingEquipment: new ArrayField$h(new EmbeddedDataField$5(EquipmentEntryData), {required: true}),
      wealth: new FormulaField({ label: "DND5E.StartingEquipment.Wealth.Label",
        hint: "DND5E.StartingEquipment.Wealth.Hint" })
    };
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * HTML formatted description of the starting equipment on this item.
   * @type {string}
   */
  get startingEquipmentDescription() {
    return this.getStartingEquipmentDescription();
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Create a HTML formatted description of the starting equipment on this item.
   * @param {object} [options={}]
   * @param {boolean} [options.modernStyle]       Should this be formatted according to modern rules or legacy.
   * @returns {string}
   */
  getStartingEquipmentDescription({ modernStyle }={}) {
    const topLevel = this.startingEquipment.filter(e => !e.group);
    if ( !topLevel.length ) return "";

    // If more than one entry, display as an unordered list (like for legacy classes)
    if ( topLevel.length > 1 ) {
      return `<ul>${topLevel.map(e => `<li>${e.generateLabel({ modernStyle })}</li>`).join("")}</ul>`;
    }

    // For modern classes, display as "Choose A or B"
    modernStyle ??= (this.source.rules === "2024")
      || (!this.source.rules && (game.settings.get("dnd5e", "rulesVersion") === "modern"));
    if ( modernStyle ) {
      const entries = topLevel[0].type === "OR" ? topLevel[0].children : topLevel;
      if ( this.wealth ) entries.push(new EquipmentEntryData({ type: "currency", key: "gp", count: this.wealth }));
      if ( entries.length > 1 ) {
        const usedPrefixes = [];
        const choices = EquipmentEntryData.prefixOrEntries(
          entries.map(e => e.generateLabel({ modernStyle, depth: 2 })), { modernStyle, usedPrefixes }
        );
        const formatter = game.i18n.getListFormatter({ type: "disjunction" });
        return `<p>${game.i18n.format("DND5E.StartingEquipment.ChooseList", {
          prefixes: formatter.format(usedPrefixes), choices: formatter.format(choices)
        })}</p>`;
      }
    }

    // Otherwise display as its own paragraph (like for backgrounds)
    return `<p>${game.i18n.getListFormatter().format(topLevel.map(e => e.generateLabel({ modernStyle })))}</p>`;
  }
}


/**
 * Data for a single entry in the equipment list.
 *
 * @property {string} _id                     Unique ID of this entry.
 * @property {string|null} group              Parent entry that contains this one.
 * @property {number} sort                    Sorting order of this entry.
 * @property {string} type                    Entry type as defined in `EquipmentEntryData#TYPES`.
 * @property {number} [count]                 Number of items granted. If empty, assumed to be `1`.
 * @property {string} [key]                   Category or item key unless type is "linked", in which case it is a UUID.
 * @property {boolean} [requiresProficiency]  Is this only a valid item if character already has the
 *                                            required proficiency.
 */
class EquipmentEntryData extends foundry.abstract.DataModel {

  /**
   * Types that group together child entries.
   * @enum {string}
   */
  static GROUPING_TYPES = {
    OR: "DND5E.StartingEquipment.Operator.OR",
    AND: "DND5E.StartingEquipment.Operator.AND"
  };

  /**
   * Types that contain an option for the player.
   * @enum {string}
   */
  static OPTION_TYPES = {
    // Category types
    armor: "DND5E.StartingEquipment.Choice.Armor",
    tool: "DND5E.StartingEquipment.Choice.Tool",
    weapon: "DND5E.StartingEquipment.Choice.Weapon",
    focus: "DND5E.StartingEquipment.Choice.Focus",

    // Currency
    currency: "DND5E.StartingEquipment.Currency",

    // Generic item type
    linked: "DND5E.StartingEquipment.SpecificItem"
  };

  /**
   * Equipment entry types.
   * @type {Record<string, string>}
   */
  static get TYPES() {
    return { ...this.GROUPING_TYPES, ...this.OPTION_TYPES };
  }

  /* -------------------------------------------- */

  /**
   * Where in `CONFIG.DND5E` to find the type category labels.
   * @enum {{ label: string, config: string }}
   */
  static CATEGORIES = {
    armor: {
      label: "DND5E.Armor",
      config: "armorTypes"
    },
    currency: {
      config: "currencies"
    },
    focus: {
      label: "DND5E.Focus.Label",
      config: "focusTypes"
    },
    tool: {
      label: "TYPES.Item.tool",
      config: "toolTypes"
    },
    weapon: {
      label: "TYPES.Item.weapon",
      config: "weaponProficiencies"
    }
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return {
      _id: new DocumentIdField$9({ initial: () => foundry.utils.randomID() }),
      group: new StringField$11({ nullable: true, initial: null }),
      sort: new IntegerSortField$1(),
      type: new StringField$11({ required: true, initial: "OR", choices: this.TYPES }),
      count: new NumberField$D({ initial: undefined }),
      key: new StringField$11({ initial: undefined }),
      requiresProficiency: new BooleanField$E({ label: "DND5E.StartingEquipment.Proficient.Label" })
    };
  }

  /* -------------------------------------------- */

  /**
   * Get any children represented by this entry in order.
   * @returns {EquipmentEntryData[]}
   */
  get children() {
    if ( !(this.type in this.constructor.GROUPING_TYPES) ) return [];
    return this.parent.startingEquipment
      .filter(entry => entry.group === this._id)
      .sort((lhs, rhs) => lhs.sort - rhs.sort);
  }

  /* -------------------------------------------- */

  /**
   * Transform this entry into a human readable label.
   * @type {string}
   */
  get label() {
    return this.generateLabel();
  }

  /* -------------------------------------------- */

  /**
   * Blank label if no key is specified for a choice type.
   * @type {string}
   */
  get blankLabel() {
    return game.i18n.localize(this.constructor.CATEGORIES[this.type]?.label) ?? "";
  }

  /* -------------------------------------------- */

  /**
   * Get the label for a category.
   * @type {string}
   */
  get categoryLabel() {
    const configEntry = this.keyOptions[this.key];
    let label = configEntry?.label ?? configEntry;
    if ( !label ) return this.blankLabel.toLowerCase();

    if ( this.type === "weapon" ) label = game.i18n.format("DND5E.WeaponCategory", { category: label });
    return label.toLowerCase();
  }

  /* -------------------------------------------- */

  /**
   * Build a list of possible key options for this entry's type.
   * @returns {Record<string, string>}
   */
  get keyOptions() {
    const config = foundry.utils.deepClone(CONFIG.DND5E[this.constructor.CATEGORIES[this.type]?.config]);
    if ( this.type === "weapon" ) foundry.utils.mergeObject(config, CONFIG.DND5E.weaponTypes);
    return Object.entries(config).reduce((obj, [k, v]) => {
      obj[k] = foundry.utils.getType(v) === "Object" ? v.label : v;
      return obj;
    }, {});
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Transform this entry into a human readable label.
   * @param {object} [options={}]
   * @param {number} [options.depth=1]       Current depth of label being generated.
   * @param {boolean} [options.modernStyle]  Use modern style for OR entries.
   * @type {string}
   */
  generateLabel({ depth=1, modernStyle }={}) {
    let label;
    modernStyle ??= (this.parent.source?.rules === "2024")
      || (!this.parent.source?.rules && (game.settings.get("dnd5e", "rulesVersion") === "modern"));

    switch ( this.type ) {
      // For AND/OR, use a simple conjunction/disjunction list (e.g. "first, second, and third")
      case "AND":
      case "OR":
        let entries = this.children.map(c => c.generateLabel({ depth: depth + 1, modernStyle })).filter(_ => _);
        if ( (this.type === "OR") && (entries.length > 1) ) {
          entries = EquipmentEntryData.prefixOrEntries(entries, { depth, modernStyle });
        }
        return game.i18n.getListFormatter({ type: this.type === "AND" ? "conjunction" : "disjunction", style: "long" })
          .format(entries);

      case "currency":
        const currencyConfig = CONFIG.DND5E.currencies[this.key];
        if ( this.count && currencyConfig ) label = `${this.count} ${currencyConfig.abbreviation.toUpperCase()}`;
        break;

      // For linked type, fetch the name using the index
      case "linked":
        label = linkForUuid(this.key);
        break;

      // For category types, grab category information from config
      default:
        label = this.categoryLabel;
        break;
    }

    if ( !label ) return "";
    if ( this.type === "currency" ) return label;
    if ( this.count > 1 ) label = `${formatNumber(this.count)}&times; ${label}`;
    else if ( this.type !== "linked" ) label = game.i18n.format("DND5E.TraitConfigChooseAnyUncounted", { type: label });
    if ( (this.type === "linked") && this.requiresProficiency ) {
      label += ` (${game.i18n.localize("DND5E.StartingEquipment.IfProficient").toLowerCase()})`;
    }
    return label;
  }

  /* -------------------------------------------- */

  /**
   * Prefix each OR entry at a certain level with a letter.
   * @param {string[]} entries                    Entries to prefix.
   * @param {object} [options={}]
   * @param {number} [options.depth=1]            Current depth of the OR entry (1 or 2).
   * @param {boolean} [options.modernStyle=true]  Capitalized first level markers rather than lowercased.
   * @param {string[]} [options.usedPrefixes]     Prefixes that were used.
   * @returns {string[]}
   */
  static prefixOrEntries(entries, { depth=1, modernStyle=true, usedPrefixes }={}) {
    let letters = game.i18n.localize("DND5E.StartingEquipment.Prefixes");
    if ( !letters ) return entries;
    if ( (modernStyle && (depth === 1)) || (!modernStyle && (depth === 2)) ) letters = letters.toUpperCase();
    return entries.map((e, idx) => {
      if ( usedPrefixes ) usedPrefixes.push(letters[idx]);
      return `(${letters[idx]}) ${e}`;
    });
  }
}

var startingEquipment = /*#__PURE__*/Object.freeze({
  __proto__: null,
  EquipmentEntryData: EquipmentEntryData,
  default: StartingEquipmentTemplate
});

const { BooleanField: BooleanField$D, NumberField: NumberField$C, SchemaField: SchemaField$L, SetField: SetField$x, StringField: StringField$10 } = foundry.data.fields;

/**
 * @import { ClassItemSystemData } from "./_types.mjs";
 * @import {
 *   AdvancementTemplateData, ItemDescriptionTemplateData, StartingEquipmentTemplateData
 * } from "./templates/_types.mjs";
 */

/**
 * Data definition for Class items.
 * @extends {ItemDataModel<
 *   AdvancementTemplate & ItemDescriptionTemplate & StartingEquipmentTemplate & ClassItemSystemData
 * >}
 * @mixes AdvancementTemplateData
 * @mixes ItemDescriptionTemplateData
 * @mixes StartingEquipmentTemplateData
 * @mixes ClassItemSystemData
 */
class ClassData extends ItemDataModel$1.mixin(
  AdvancementTemplate, ItemDescriptionTemplate, StartingEquipmentTemplate
) {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.CLASS", "DND5E.SOURCE"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return this.mergeSchema(super.defineSchema(), {
      hd: new SchemaField$L({
        additional: new FormulaField({ deterministic: true, required: true }),
        denomination: new StringField$10({
          required: true, initial: "d6", blank: false,
          validate: v => /d\d+/.test(v), validationError: "must be a dice value in the format d#"
        }),
        spent: new NumberField$C({ required: true, nullable: false, integer: true, initial: 0, min: 0 })
      }),
      levels: new NumberField$C({ required: true, nullable: false, integer: true, min: 0, initial: 1 }),
      primaryAbility: new SchemaField$L({
        value: new SetField$x(new StringField$10()),
        all: new BooleanField$D({ initial: true })
      }),
      properties: new SetField$x(new StringField$10()),
      spellcasting: new SpellcastingField()
    });
  }

  /* -------------------------------------------- */

  /** @override */
  static get compendiumBrowserFilters() {
    return new Map([
      ["hasSpellcasting", {
        label: "DND5E.CompendiumBrowser.Filters.HasSpellcasting",
        type: "boolean",
        createFilter: (filters, value, def) => {
          if ( value === 0 ) return;
          const filter = { k: "system.spellcasting.progression", v: "none" };
          if ( value === -1 ) filters.push(filter);
          else filters.push({ o: "NOT", v: filter });
        }
      }],
      ["properties", this.compendiumBrowserPropertiesFilter("class")]
    ]);
  }

  /* -------------------------------------------- */
  /*  Data Migration                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static _migrateData(source) {
    super._migrateData(source);
    ClassData.#migrateHitDice(source);
    ClassData.#migrateLevels(source);
    ClassData.#migrateSpellcastingData(source);
  }

  /* -------------------------------------------- */

  /**
   * Migrate the hit dice data.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateHitDice(source) {
    if ( ("hitDice" in source) && (!source.hd || !("denomination" in source.hd)) ) {
      source.hd ??= {};
      source.hd.denomination = source.hitDice;
      delete source.hitDice;
    }

    if ( ("hitDiceUsed" in source) && (!source.hd || !("spent" in source.hd)) ) {
      source.hd ??= {};
      source.hd.spent = source.hitDiceUsed ?? 0;
      delete source.hitDiceUsed;
    }
  }

  /* -------------------------------------------- */

  /**
   * 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: ""
    };
  }

  /* -------------------------------------------- */

  /**
   * Migrate the class's saves & skills into TraitAdvancements.
   * @param {object} source  The candidate source data from which the model will be constructed.
   * @protected
   */
  static _migrateTraitAdvancement(source) {
    const system = source.system;
    if ( !system?.advancement || system.advancement.find(a => a.type === "Trait") ) return;
    let needsMigration = false;

    if ( system.saves?.length ) {
      const savesData = {
        type: "Trait",
        level: 1,
        configuration: {
          grants: system.saves.map(t => `saves:${t}`)
        }
      };
      savesData.value = {
        chosen: savesData.configuration.grants
      };
      system.advancement.push(new TraitAdvancement(savesData).toObject());
      delete system.saves;
      needsMigration = true;
    }

    if ( system.skills?.choices?.length ) {
      const skillsData = {
        type: "Trait",
        level: 1,
        configuration: {
          choices: [{
            count: system.skills.number ?? 1,
            pool: system.skills.choices.map(t => `skills:${t}`)
          }]
        }
      };
      if ( system.skills.value?.length ) {
        skillsData.value = {
          chosen: system.skills.value.map(t => `skills:${t}`)
        };
      }
      system.advancement.push(new TraitAdvancement(skillsData).toObject());
      delete system.skills;
      needsMigration = true;
    }

    if ( needsMigration ) foundry.utils.setProperty(source, "flags.dnd5e.persistSourceMigration", true);
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareBaseData() {
    super.prepareBaseData();
    this.spellcasting.preparation.value = 0;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareDerivedData() {
    super.prepareDerivedData();
    this.prepareDescriptionData();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareFinalData() {
    this.isOriginalClass = this.parent.isOriginalClass;
    const rollData = this.parent.getRollData({ deterministic: true });
    SpellcastingField.prepareData.call(this, rollData);
    this.hd.additional = this.hd.additional ? Roll.create(this.hd.additional, rollData).evaluateSync().total : 0;
    this.hd.max = Math.max(this.levels + this.hd.additional, 0);
    this.hd.value = Math.max(this.hd.max - this.hd.spent, 0);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getFavoriteData() {
    const context = await super.getFavoriteData();
    if ( this.parent.subclass ) context.subtitle = this.parent.subclass.name;
    context.value = this.levels;
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getSheetData(context) {
    context.subtitles = [{ label: game.i18n.localize(CONFIG.Item.typeLabels.class) }];
    context.singleDescription = true;

    context.parts = ["dnd5e.details-class", "dnd5e.details-spellcasting", "dnd5e.details-starting-equipment"];
    context.hitDieOptions = CONFIG.DND5E.hitDieTypes.map(d => ({ value: d, label: d }));
    context.primaryAbilities = Object.entries(CONFIG.DND5E.abilities).map(([value, data]) => ({
      value, label: data.label, selected: this.primaryAbility.value.has(value)
    }));
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /** @override */
  _advancementToCreate(options) {
    return [
      { type: "HitPoints" },
      { type: "Subclass", level: 3 },
      { type: "AbilityScoreImprovement", level: 4 },
      { type: "AbilityScoreImprovement", level: 8 },
      { type: "AbilityScoreImprovement", level: 12 },
      { type: "AbilityScoreImprovement", level: 16 },
      { type: "AbilityScoreImprovement", level: 19 }
    ];
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preCreate(data, options, user) {
    if ( (await super._preCreate(data, options, user)) === false ) return false;
    await this.preCreateAdvancement(data, options);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onCreate(data, options, userId) {
    await super._onCreate(data, options, userId);
    const actor = this.parent.actor;
    if ( !actor || (userId !== game.user.id) ) return;

    if ( actor.type === "character" ) {
      const pc = actor.items.get(actor.system.details.originalClass);
      if ( !pc ) await actor._assignPrimaryClass();
    }

    if ( !actor.system.attributes?.spellcasting && this.parent.spellcasting?.ability ) {
      await actor.update({ "system.attributes.spellcasting": this.parent.spellcasting.ability });
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preUpdate(changed, options, user) {
    if ( (await super._preUpdate(changed, options, user)) === false ) return false;
    if ( !("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("DND5E.MaxClassLevelMinimumWarn", { localize: true });
      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.parent.actor?.type !== "character" ) return;

    // Check to ensure the updated character doesn't exceed level cap
    const newCharacterLevel = this.parent.actor.system.details.level + (changed.system.levels - this.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 ) return;
    if ( this.parent.id === this.parent.actor?.system.details?.originalClass ) {
      this.parent.actor._assignPrimaryClass();
    }
  }
}

/**
 * A template for currently held currencies.
 * @mixin
 */
class CurrencyTemplate extends SystemDataModel$1 {
  /** @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"})
    };
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Get the weight of all of the currency. Always returns 0 if currency weight is disabled in settings.
   * @returns {number}
   */
  get currencyWeight() {
    if ( !game.settings.get("dnd5e", "currencyWeight") ) return 0;
    const count = Object.values(this.currency).reduce((count, value) => count + value, 0);
    const currencyPerWeight = game.settings.get("dnd5e", "metricWeightUnits")
      ? CONFIG.DND5E.encumbrance.currencyPerWeight.metric
      : CONFIG.DND5E.encumbrance.currencyPerWeight.imperial;
    return count / currencyPerWeight;
  }
}

const { BooleanField: BooleanField$C, StringField: StringField$$ } = foundry.data.fields;

/**
 * @import { CompendiumBrowserFilterDefinitionEntry } from "../../../applications/compendium-browser.mjs";
 * @import { EquippableItemTemplateData } from "./_types.mjs";
 */

/**
 * Data model template with information on items that can be attuned and equipped.
 * @extends {SystemDataModel<EquippableItemTemplateData>}
 * @mixin
 */
class EquippableItemTemplate extends SystemDataModel$1 {
  /** @inheritDoc */
  static defineSchema() {
    return {
      attunement: new StringField$$({required: true, label: "DND5E.Attunement"}),
      attuned: new BooleanField$C({label: "DND5E.Attuned"}),
      equipped: new BooleanField$C({required: true, label: "DND5E.Equipped"})
    };
  }

  /* -------------------------------------------- */

  /**
   * Create attunement filter configuration.
   * @returns {CompendiumBrowserFilterDefinitionEntry}
   */
  static get compendiumBrowserAttunementFilter() {
    return {
      label: "DND5E.Attunement",
      type: "boolean",
      createFilter: (filters, value, def) => {
        if ( value === 0 ) return;
        const filter = { k: "system.attunement", o: "in", v: ["required", 1] };
        if ( value === 1 ) filters.push(filter);
        else filters.push({ o: "NOT", v: filter });
      }
    };
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * This item is capable of being attuned.
   * @type {boolean}
   */
  get canAttune() {
    return (this.attunement === "required") || (this.attunement === "optional");
  }

  /* -------------------------------------------- */

  /**
   * Chat properties for equippable items.
   * @type {string[]}
   */
  get equippableItemCardProperties() {
    return [
      this.attuned ? game.i18n.localize("DND5E.AttunementAttuned")
        : CONFIG.DND5E.attunementTypes[this.attunement] ?? null,
      game.i18n.localize(this.equipped ? "DND5E.Equipped" : "DND5E.Unequipped"),
      ("proficient" in this) ? CONFIG.DND5E.proficiencyLevels[this.prof?.multiplier || 0] : null
    ];
  }

  /* -------------------------------------------- */

  /**
   * Are the magical properties of this item, such as magical bonuses to armor & damage, available?
   * @type {boolean}
   */
  get magicAvailable() {
    const attunement = this.attuned || (this.attunement !== "required");
    return attunement && this.properties.has("mgc") && this.validProperties.has("mgc");
  }

  /* -------------------------------------------- */
  /*  Data Migration                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static _migrateData(source) {
    super._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) {
    switch ( source.attunement ) {
      case 2: source.attuned = true;
      case 1: source.attunement = "required"; break;
      case 0: source.attunement = ""; break;
    }
  }

  /* -------------------------------------------- */

  /**
   * 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;
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /**
   * Ensure items that cannot be attuned are not marked as attuned. If attuned and on an actor type that
   * tracks attunement, increase that actor's attunement count.
   */
  prepareFinalEquippableData() {
    if ( this.validProperties.has("mgc") && !this.properties.has("mgc") ) this.attunement = "";
    if ( !this.attunement ) this.attuned = false;
    if ( this.attuned && this.parent.actor?.system.attributes?.attunement ) {
      this.parent.actor.system.attributes.attunement.value += 1;
    }
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /**
   * Set as equipped for NPCs, and unequipped for PCs.
   * @param {object} data     The initial data object provided to the document creation request.
   * @param {object} options  Additional options which modify the creation request.
   * @param {User} user       The User requesting the document creation.
   */
  preCreateEquipped(data, options, user) {
    if ( ["character", "npc"].includes(this.parent.actor?.type)
      && !foundry.utils.hasProperty(data, "system.equipped") ) {
      this.updateSource({ equipped: this.parent.actor.type === "npc" });
    }
  }
}

const { BooleanField: BooleanField$B, SchemaField: SchemaField$K, StringField: StringField$_, HTMLField: HTMLField$7 } = foundry.data.fields;

/**
 * @import { IdentifiableTemplateData } from "./_types.mjs";
 */

/**
 * Data model template for items that can be identified.
 * @extends {SystemDataModel<IdentifiableTemplateData>}
 * @mixin
 */
class IdentifiableTemplate extends SystemDataModel$1 {
  /** @inheritDoc */
  static defineSchema() {
    return {
      identified: new BooleanField$B({ required: true, initial: true, label: "DND5E.Identified" }),
      unidentified: new SchemaField$K({
        name: new StringField$_({ label: "DND5E.NameUnidentified" }),
        description: new HTMLField$7({ label: "DND5E.DescriptionUnidentified" })
      })
    };
  }

  /* -------------------------------------------- */
  /*  Data Migration                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static _migrateData(source) {
    super._migrateData(source);
    IdentifiableTemplate.#migrateUnidentified(source);
  }

  /* -------------------------------------------- */

  /**
   * Move unidentified description into new location.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateUnidentified(source) {
    if ( foundry.utils.hasProperty(source, "description.unidentified")
      && !foundry.utils.getProperty(source, "unidentified.description") ) {
      source.unidentified ??= {};
      source.unidentified.description = source.description.unidentified;
    }
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /**
   * Prepare the unidentified name for the item.
   */
  prepareIdentifiable() {
    if ( !this.identified && this.unidentified.name ) {
      this.parent.name = this.unidentified.name;
    }
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /**
   * If no unidentified name or description are set when the identified checkbox is unchecked, then fetch values
   * from base item if possible.
   * @param {object} changed            The differential data that is changed relative to the document's prior values.
   * @param {object} options            Additional options which modify the update request
   * @param {documents.BaseUser} user   The User requesting the document update
   * @returns {Promise<boolean|void>}   A return value of false indicates the update operation should be cancelled.
   * @see {Document#_preUpdate}
   * @protected
   */
  async preUpdateIdentifiable(changed, options, user) {
    if ( !foundry.utils.hasProperty(changed, "system.identified") || changed.system.identified ) return;

    const fetchName = !foundry.utils.getProperty(changed, "system.unidentified.name") && !this.unidentified.name;
    const fetchDesc = !foundry.utils.getProperty(changed, "system.unidentified.description")
      && !this.unidentified.description;
    if ( !fetchName && !fetchDesc ) return;

    const baseItem = await getBaseItem(this.type?.identifier ?? "", { fullItem: fetchDesc });

    // If a base item is set, fetch that and use its name/description
    if ( baseItem ) {
      if ( fetchName ) {
        foundry.utils.setProperty(changed, "system.unidentified.name", game.i18n.format(
          "DND5E.Unidentified.DefaultName", { name: baseItem.name }
        ));
      }
      if ( fetchDesc ) {
        foundry.utils.setProperty(changed, "system.unidentified.description", baseItem.system.description.value);
      }
      return;
    }

    // Otherwise, set the name to match the item type
    if ( fetchName ) foundry.utils.setProperty(changed, "system.unidentified.name", game.i18n.format(
      "DND5E.Unidentified.DefaultName", { name: game.i18n.localize(CONFIG.Item.typeLabels[this.parent.type]) }
    ));
  }
}

const { ForeignDocumentField: ForeignDocumentField$6, NumberField: NumberField$B, SchemaField: SchemaField$J, StringField: StringField$Z } = foundry.data.fields;

/**
 * @import { CompendiumBrowserFilterDefinitionEntry } from "../../../applications/compendium-browser.mjs";
 * @import { PhysicalItemTemplateData } from "./_types.mjs";
 */

/**
 * Data model template with information on physical items.
 * @extends {SystemDataModel<PhysicalItemTemplateData>}
 * @mixin
 */
class PhysicalItemTemplate extends SystemDataModel$1 {
  /** @inheritDoc */
  static defineSchema() {
    return {
      container: new ForeignDocumentField$6(foundry.documents.BaseItem, {
        idOnly: true, label: "DND5E.Container"
      }),
      quantity: new NumberField$B({
        required: true, nullable: false, integer: true, initial: 1, min: 0, label: "DND5E.Quantity"
      }),
      weight: new SchemaField$J({
        value: new NumberField$B({
          required: true, nullable: false, initial: 0, min: 0, label: "DND5E.Weight"
        }),
        units: new StringField$Z({
          required: true, blank: false, label: "DND5E.UNITS.WEIGHT.Label", initial: () => defaultUnits("weight")
        })
      }, { label: "DND5E.Weight" }),
      price: new SchemaField$J({
        value: new NumberField$B({
          required: true, nullable: false, initial: 0, min: 0, label: "DND5E.Price"
        }),
        denomination: new StringField$Z({
          required: true, blank: false, initial: "gp", label: "DND5E.Currency"
        })
      }, { label: "DND5E.Price" }),
      rarity: new StringField$Z({ required: true, blank: true, label: "DND5E.Rarity" })
    };
  }

  /* -------------------------------------------- */

  /**
   * Maximum depth items can be nested in containers.
   * @type {number}
   */
  static MAX_DEPTH = 5;

  /* -------------------------------------------- */

  /**
   * Create filter configurations shared by all physical items.
   * @returns {[string, CompendiumBrowserFilterDefinitionEntry][]}
   */
  static get compendiumBrowserPhysicalItemFilters() {
    return [
      ["price", {
        label: "DND5E.Price",
        type: "range",
        config: {
          keyPath: "system.price.value"
        }
      }],
      ["rarity", {
        label: "DND5E.Rarity",
        type: "set",
        config: {
          blank: game.i18n.localize("DND5E.ItemRarityMundane").capitalize(),
          choices: Object.entries(CONFIG.DND5E.itemRarity).reduce((obj, [key, label]) => {
            obj[key] = { label: label.capitalize() };
            return obj;
          }, {}),
          keyPath: "system.rarity"
        }
      }]
    ];
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Get a human-readable label for the price and denomination.
   * @type {string}
   */
  get priceLabel() {
    const { value, denomination } = this.price;
    const hasPrice = value && (denomination in CONFIG.DND5E.currencies);
    return hasPrice ? `${value} ${CONFIG.DND5E.currencies[denomination].label}` : null;
  }

  /* -------------------------------------------- */

  /**
   * The weight of all of the items in an item stack.
   * @type {number}
   */
  get totalWeight() {
    return this.quantity * this.weight.value;
  }

  /* -------------------------------------------- */

  /**
   * Field specifications for physical items.
   * @type {object[]}
   */
  get physicalItemSheetFields() {
    return [{
      label: CONFIG.DND5E.itemRarity[this.rarity],
      value: this._source.rarity,
      requiresIdentification: true,
      field: this.schema.getField("rarity"),
      choices: CONFIG.DND5E.itemRarity,
      blank: "DND5E.Rarity",
      classes: "item-rarity"
    }];
  }

  /* -------------------------------------------- */
  /*  Data Migration                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static _migrateData(source) {
    super._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()
    ) ?? "";
  }

  /* -------------------------------------------- */

  /**
   * Migrate the item's weight from a single field to an object with units & 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) || (foundry.utils.getType(source.weight) === "Object") ) return;
    source.weight = {
      value: Number.isNumeric(source.weight) ? Number(source.weight) : 0,
      units: defaultUnits("weight")
    };
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /**
   * Prepare physical item properties.
   */
  preparePhysicalData() {
    if ( !("gp" in CONFIG.DND5E.currencies) ) return;
    const { value, denomination } = this.price;
    const { conversion } = CONFIG.DND5E.currencies[denomination] ?? {};
    const { gp } = CONFIG.DND5E.currencies;
    if ( conversion ) {
      const multiplier = gp.conversion / conversion;
      this.price.valueInGP = Math.floor(value * multiplier);
    }
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /**
   * Trigger a render on all sheets for items within which this item is contained.
   * @param {object} [options={}]
   * @param {object} [options.rendering]        Additional rendering options.
   * @param {string} [options.formerContainer]  UUID of the former container if this item was moved.
   * @protected
   */
  async _renderContainers({ formerContainer, ...rendering }={}) {
    // Render this item's container & any containers it is within
    const parentContainers = await this.allContainers();
    parentContainers.forEach(c => {
      if ( c.sheet?.rendered ) c.sheet?.render(false, { ...rendering });
    });
    if ( !parentContainers.length && !formerContainer ) return;

    // Render the actor sheet, compendium, or sidebar
    if ( this.parent.isEmbedded && this.parent.actor.sheet?.rendered ) {
      this.parent.actor.sheet.render(false, { ...rendering });
    }
    else if ( this.parent.pack ) game.packs.get(this.parent.pack).apps.forEach(a => a.render(false, { ...rendering }));
    else ui.items.render(false, { ...rendering });

    // Render former container if it was moved between containers
    if ( formerContainer ) {
      const former = await fromUuid(formerContainer);
      former.render(false, { ...rendering });
      former.system._renderContainers(rendering);
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preUpdate(changed, options, user) {
    if ( await super._preUpdate(changed, options, user) === false ) return false;
    if ( foundry.utils.hasProperty(changed, "system.container") ) {
      options.formerContainer = (await this.parent.container)?.uuid;
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onCreate(data, options, userId) {
    super._onCreate(data, options, userId);
    if ( options.render !== false ) this._renderContainers();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onUpdate(changed, options, userId) {
    super._onUpdate(changed, options, userId);
    if ( options.render !== false ) this._renderContainers({ formerContainer: options.formerContainer });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDelete(options, userId) {
    super._onDelete(options, userId);
    if ( options.render !== false ) this._renderContainers();
  }

  /* -------------------------------------------- */
  /*  Helper Methods                              */
  /* -------------------------------------------- */

  /**
   * All of the containers this item is within up to the parent actor or collection.
   * @returns {Promise<Item5e[]>}
   */
  async allContainers() {
    let item = this.parent;
    let container;
    let depth = 0;
    const containers = [];
    while ( (container = await item.container) && (depth < PhysicalItemTemplate.MAX_DEPTH) ) {
      containers.push(container);
      item = container;
      depth++;
    }
    return containers;
  }

  /* -------------------------------------------- */

  /**
   * Calculate the total weight and return it in specific units.
   * @param {string} units  Units in which the weight should be returned.
   * @returns {number|Promise<number>}
   */
  totalWeightIn(units) {
    const weight = this.totalWeight;
    if ( weight instanceof Promise ) return weight.then(w => convertWeight(w, this.weight.units, units));
    return convertWeight(weight, this.weight.units, units);
  }
}

const { NumberField: NumberField$A, SchemaField: SchemaField$I, SetField: SetField$w, StringField: StringField$Y } = foundry.data.fields;

/**
 * @import { InventorySectionDescriptor } from "../../applications/components/_types.mjs";
 * @import { CurrencyTemplateData } from "../shared/_types.mjs";
 * @import { ContainerItemSystemData } from "./_types.mjs";
 * @import {
 *   EquippableItemTemplateData, IdentifiableTemplateData, ItemDescriptionTemplateData, PhysicalItemTemplateData
 * } from "./templates/_types.mjs";
 */

/**
 * Data definition for Container items.
 * @extends {ItemDataModel<
 *   ItemDescriptionTemplate & IdentifiableTemplate & PhysicalItemTemplate &
 *   EquippableItemTemplate & CurrencyTemplate & ContainerItemSystemData
 * >}
 * @mixes ItemDescriptionTemplateData
 * @mixes IdentifiableTemplateData
 * @mixes PhysicalItemTemplateData
 * @mixes EquippableItemTemplateData
 * @mixes CurrencyTemplateData
 * @mixes ContainerItemSystemData
 */
class ContainerData extends ItemDataModel$1.mixin(
  ItemDescriptionTemplate, IdentifiableTemplate, PhysicalItemTemplate, EquippableItemTemplate, CurrencyTemplate
) {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.CONTAINER", "DND5E.SOURCE"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return this.mergeSchema(super.defineSchema(), {
      capacity: new SchemaField$I({
        count: new NumberField$A({ min: 0, integer: true }),
        volume: new SchemaField$I({
          value: new NumberField$A({ min: 0 }),
          units: new StringField$Y({ initial: () => defaultUnits("volume") })
        }),
        weight: new SchemaField$I({
          value: new NumberField$A({ min: 0 }),
          units: new StringField$Y({ initial: () => defaultUnits("weight") })
        })
      }),
      properties: new SetField$w(new StringField$Y()),
      quantity: new NumberField$A({ min: 1, max: 1 })
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static metadata = Object.freeze(foundry.utils.mergeObject(super.metadata, {
    enchantable: true
  }, {inplace: false}));

  /* -------------------------------------------- */

  /** @override */
  static get compendiumBrowserFilters() {
    return new Map([
      ["attunement", this.compendiumBrowserAttunementFilter],
      ...this.compendiumBrowserPhysicalItemFilters,
      ["properties", this.compendiumBrowserPropertiesFilter("container")]
    ]);
  }

  /* -------------------------------------------- */

  /**
   * Default configuration for this item type's inventory section.
   * @returns {InventorySectionDescriptor}
   */
  static get inventorySection() {
    return {
      id: "containers",
      order: 500,
      label: "TYPES.Item.containerPl",
      groups: { type: "container" },
      columns: ["capacity", "controls"]
    };
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Get all of the items contained in this container. A promise if item is within a compendium.
   * @type {Collection<Item5e>|Promise<Collection<Item5e>>}
   */
  get contents() {
    if ( !this.parent ) return new foundry.utils.Collection();

    // If in a compendium, fetch using getDocuments and return a promise
    if ( this.parent.pack && !this.parent.isEmbedded ) {
      const pack = game.packs.get(this.parent.pack);
      return pack.getDocuments({system: { container: this.parent.id }}).then(d =>
        new foundry.utils.Collection(d.map(d => [d.id, d]))
      );
    }

    // Otherwise use local document collection
    return (this.parent.isEmbedded ? this.parent.actor.items : game.items).reduce((collection, item) => {
      if ( item.system.container === this.parent.id ) collection.set(item.id, item);
      return collection;
    }, new foundry.utils.Collection());
  }

  /* -------------------------------------------- */

  /**
   * Get all of the items in this container and any sub-containers. A promise if item is within a compendium.
   * @type {Collection<Item5e>|Promise<Collection<Item5e>>}
   */
  get allContainedItems() {
    if ( !this.parent ) return new foundry.utils.Collection();
    if ( this.parent.pack ) return this.#allContainedItems();

    return this.contents.reduce((collection, item) => {
      collection.set(item.id, item);
      if ( item.type === "container" ) item.system.allContainedItems.forEach(i => collection.set(i.id, i));
      return collection;
    }, new foundry.utils.Collection());
  }

  /**
   * Asynchronous helper method for fetching all contained items from a compendium.
   * @returns {Promise<Collection<Item5e>>}
   * @private
   */
  async #allContainedItems() {
    return (await this.contents).reduce(async (promise, item) => {
      const collection = await promise;
      collection.set(item.id, item);
      if ( item.type === "container" ) (await item.system.allContainedItems).forEach(i => collection.set(i.id, i));
      return collection;
    }, new foundry.utils.Collection());
  }

  /* -------------------------------------------- */

  /**
   * Fetch a specific contained item.
   * @param {string} id                 ID of the item to fetch.
   * @returns {Item5e|Promise<Item5e>}  Item if found.
   */
  getContainedItem(id) {
    if ( this.parent?.isEmbedded ) return this.parent.actor.items.get(id);
    if ( this.parent?.pack ) return game.packs.get(this.parent.pack)?.getDocument(id);
    return game.items.get(id);
  }

  /* -------------------------------------------- */

  /**
   * Number of items contained in this container including items in sub-containers. Result is a promise if item
   * is within a compendium.
   * @type {number|Promise<number>}
   */
  get contentsCount() {
    const reducer = (count, item) => count + item.system.quantity;
    const items = this.allContainedItems;
    if ( items instanceof Promise ) return items.then(items => items.reduce(reducer, 0));
    return items.reduce(reducer, 0);
  }

  /* -------------------------------------------- */

  /**
   * Weight of the items in this container. Result is a promise if item is within a compendium.
   * @type {number|Promise<number>}
   */
  get contentsWeight() {
    if ( this.parent?.pack && !this.parent?.isEmbedded ) return this.#contentsWeight();
    return this.contents.reduce((weight, item) =>
      weight + item.system.totalWeightIn(this.weight.units), this.currencyWeight
    );
  }

  /**
   * Asynchronous helper method for calculating the weight of items in a compendium.
   * @returns {Promise<number>}
   */
  async #contentsWeight() {
    const contents = await this.contents;
    return contents.reduce(async (weight, item) =>
      await weight + await item.system.totalWeightIn(this.weight.units), this.currencyWeight
    );
  }

  /* -------------------------------------------- */

  /**
   * The weight of this container with all of its contents. Result is a promise if item is within a compendium.
   * @type {number|Promise<number>}
   */
  get totalWeight() {
    if ( this.properties.has("weightlessContents") ) return this.weight.value;
    const containedWeight = this.contentsWeight;
    if ( containedWeight instanceof Promise ) return containedWeight.then(c => this.weight.value + c);
    return this.weight.value + containedWeight;
  }

  /* -------------------------------------------- */

  /**
   * @typedef {object} Item5eCapacityDescriptor
   * @property {number} value  The current total weight or number of items in the container.
   * @property {number} max    The maximum total weight or number of items in the container.
   * @property {number} pct    The percentage of total capacity.
   * @property {string} units  The units label.
   */

  /**
   * Compute capacity information for this container.
   * @returns {Promise<Item5eCapacityDescriptor>}
   */
  async computeCapacity() {
    const context = { max: Infinity, value: 0 };
    if ( this.capacity.count ) {
      context.value = await this.contentsCount;
      context.max = this.capacity.count;
      context.units = game.i18n.localize("DND5E.Items");
    } else if ( this.capacity.weight.value ) {
      context.value = await this.contentsWeight;
      context.max = this.capacity.weight.value;
      context.units = CONFIG.DND5E.weightUnits[this.capacity.weight.units]?.label ?? "";
    }
    context.value = context.value.toNearest(0.1);
    context.pct = Math.clamp(context.max ? (context.value / context.max) * 100 : 0, 0, 100);
    return context;
  }

  /* -------------------------------------------- */
  /*  Data Migration                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static _migrateData(source) {
    super._migrateData(source);
    ContainerData.#migrateCapacity(source);
    ContainerData.#migrateQuantity(source);
  }

  /* -------------------------------------------- */

  /**
   * Migrate the weightless property into `properties`.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static _migrateWeightlessData(source) {
    if ( foundry.utils.getProperty(source, "system.capacity.weightless") === true ) {
      foundry.utils.setProperty(source, "flags.dnd5e.migratedProperties", ["weightlessContents"]);
    }
  }

  /* -------------------------------------------- */

  /**
   * Migrate capacity to support multiple fields and units.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateCapacity(source) {
    if ( !source.capacity || !source.capacity.type || !source.capacity.value || (source.capacity.count !== undefined)
      || (foundry.utils.getType(source.capacity.weight) === "Object") ) return;
    if ( source.capacity.type === "weight" ) {
      source.capacity.weight ??= {};
      source.capacity.weight.value = source.capacity.value;
    } else if ( source.capacity.type === "item" ) {
      source.capacity.count = source.capacity.value;
    }
    delete source.capacity.type;
    delete source.capacity.value;
  }

  /* -------------------------------------------- */

  /**
   * Force quantity to always be 1.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateQuantity(source) {
    source.quantity = 1;
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareDerivedData() {
    super.prepareDerivedData();
    this.prepareDescriptionData();
    this.prepareIdentifiable();
    this.preparePhysicalData();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareFinalData() {
    this.prepareFinalEquippableData();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getFavoriteData() {
    const data = super.getFavoriteData();
    const capacity = await this.computeCapacity();
    if ( Number.isFinite(capacity.max) ) return foundry.utils.mergeObject(await data, { uses: capacity });
    return await data;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getSheetData(context) {
    context.subtitles = [
      { label: game.i18n.localize(CONFIG.Item.typeLabels.container) },
      ...this.physicalItemSheetFields
    ];
    context.parts = ["dnd5e.details-container"];
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preUpdate(changed, options, user) {
    if ( (await super._preUpdate(changed, options, user)) === false ) return false;
    await this.preUpdateIdentifiable(changed, options, user);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onUpdate(changed, options, userId) {
    // Keep contents folder synchronized with container
    if ( (game.user.id === userId) && foundry.utils.hasProperty(changed, "folder") ) {
      const contents = await this.contents;
      await Item.updateDocuments(contents.map(c => ({ _id: c.id, folder: changed.folder })), {
        parent: this.parent.parent, pack: this.parent.pack, ...options, render: false
      });
    }

    super._onUpdate(changed, options, userId);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onDelete(options, userId) {
    super._onDelete(options, userId);
    if ( (userId !== game.user.id) || !options.deleteContents ) return;

    // Delete a container's contents when it is deleted
    const contents = await this.allContainedItems;
    if ( contents?.size ) await Item.deleteDocuments(Array.from(contents.map(i => i.id)), {
      pack: this.parent.pack,
      parent: this.parent.parent
    });
  }
}

/**
 * @import { ActivitiesTemplateData } from "./_types.mjs";
 */

/**
 * Data model template for items with activities.
 * @extends {SystemDataModel<ActivitiesTemplateData>}
 * @mixin
 */
class ActivitiesTemplate extends SystemDataModel$1 {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.USES"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return {
      activities: new ActivitiesField(),
      uses: new UsesField()
    };
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Which ability score modifier is used by this item?
   * @type {string|null}
   */
  get abilityMod() {
    return this._typeAbilityMod || null;
  }

  /**
   * Default ability key defined for this type.
   * @type {string|null}
   * @internal
   */
  get _typeAbilityMod() {
    return null;
  }

  /* -------------------------------------------- */

  /**
   * Enchantments that have been applied by this item.
   * @type {ActiveEffect5e[]}
   */
  get appliedEnchantments() {
    return dnd5e.registry.enchantments.applied(this.parent.uuid);
  }

  /* -------------------------------------------- */

  /**
   * Value on a d20 die needed to roll a critical hit with an attack from this item.
   * @type {number|null}
   */
  get criticalThreshold() {
    return this._typeCriticalThreshold ?? null;
  }

  /* -------------------------------------------- */

  /**
   * Does the Item implement an attack roll as part of its usage?
   * @type {boolean}
   */
  get hasAttack() {
    return !!this.activities.getByType("attack").length;
  }

  /* -------------------------------------------- */

  /**
   * Is this Item limited in its ability to be used by charges or by recharge?
   * @type {boolean}
   */
  get hasLimitedUses() {
    return !!this._source.uses.max || !!this.uses.max;
  }

  /* -------------------------------------------- */

  /**
   * Does the Item implement a saving throw as part of its usage?
   * @type {boolean}
   */
  get hasSave() {
    return !!this.activities.getByType("save").length;
  }

  /* -------------------------------------------- */

  /**
   * Does this Item implement summoning as part of its usage?
   * @type {boolean}
   */
  get hasSummoning() {
    const activity = this.activities.getByType("summon")[0];
    return activity && activity.profiles.length > 0;
  }

  /* -------------------------------------------- */

  /**
   * Is this Item an activatable item?
   * @type {boolean}
   */
  get isActive() {
    return this.activities.size > 0;
  }

  /* -------------------------------------------- */

  /**
   * Can this item enchant other items?
   * @type {boolean}
   */
  get isEnchantment() {
    return !!this.activities.getByType("enchant").length;
  }

  /* -------------------------------------------- */

  /**
   * Does the Item provide an amount of healing instead of conventional damage?
   * @type {boolean}
   */
  get isHealing() {
    return !!this.activities.getByType("heal").length;
  }

  /* -------------------------------------------- */

  /**
   * Creatures summoned by this item.
   * @type {Actor5e[]}
   */
  get summonedCreatures() {
    if ( !this.actor ) return [];
    return this.activities.getByType("summon").map(a => a.summonedCreatures).flat();
  }

  /* -------------------------------------------- */
  /*  Data Migration                              */
  /* -------------------------------------------- */

  /**
   * Migrate the uses data structure from before activities.
   * @param {object} source  Candidate source data to migrate.
   */
  static migrateActivities(source) {
    ActivitiesTemplate.#migrateUses(source);
  }

  /* -------------------------------------------- */

  /**
   * Migrate the uses to the new data structure.
   * @param {object} source  Candidate source data to migrate.
   */
  static #migrateUses(source) {
    // Remove any old ternary operators from uses to prevent errors
    if ( source.uses?.max?.includes?.(" ? ") ) source.uses.max = "";
    for ( const activity of Object.values(source.activities ?? {}) ) {
      if ( activity?.uses?.max?.includes?.(" ? ") ) activity.uses.max = "";
    }

    if ( Array.isArray(source.uses?.recovery) ) return;

    const charged = source.recharge?.charged;
    if ( (source.recharge?.value !== null) && (charged !== undefined) && !source.uses?.max ) {
      source.uses ??= {};
      source.uses.spent = charged ? 0 : 1;
      source.uses.max = "1";
    }

    if ( foundry.utils.getType(source.uses?.recovery) !== "string" ) return;

    // If period is charges, set the recovery type to `formula`
    if ( source.uses?.per === "charges" ) {
      if ( source.uses.recovery ) {
        source.uses.recovery = [{ period: "lr", type: "formula", formula: source.uses.recovery }];
      } else {
        delete source.uses.recovery;
      }
    }

    // If period is not blank, set an appropriate recovery type
    else if ( source.uses?.per ) {
      if ( CONFIG.DND5E.limitedUsePeriods[source.uses.per]?.formula && source.uses.recovery ) {
        source.uses.recovery = [{ period: source.uses.per, type: "formula", formula: source.uses.recovery }];
      }
      else source.uses.recovery = [{ period: source.uses.per, type: "recoverAll" }];
    }

    // Otherwise, check to see if recharge is set
    else if ( source.recharge?.value ) {
      source.uses.recovery = [{ period: "recharge", formula: source.recharge.value }];
    }

    // Prevent a string value for uses recovery from being cleaned into a default recovery entry
    else if ( source.uses?.recovery === "" ) {
      delete source.uses.recovery;
    }
  }

  /* -------------------------------------------- */

  /**
   * Modify data before initialization to create initial activity if necessary.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static initializeActivities(source) {
    if ( this.#shouldCreateInitialActivity(source) ) this.#createInitialActivity(source);
    const uses = source.system?.uses ?? {};
    if ( source._id && source.type && ("value" in uses) && uses.max ) {
      foundry.utils.setProperty(source, "flags.dnd5e.migratedUses", uses.value);
    }
  }

  /* -------------------------------------------- */

  /**
   * Method to determine whether the activity creation migration should be performed. This migration should only be
   * performed on whole item data rather than partial updates, so check to ensure all of the necessary data is present.
   * @param {object} source  The candidate source data from which the model will be constructed.
   * @returns {boolean}
   */
  static #shouldCreateInitialActivity(source) {
    // Do not attempt to migrate partial source data.
    if ( !source._id || !source.type || !source.system || !source.effects ) return false;

    // If item doesn't have an action type or activation, then it doesn't need an activity
    if ( !source.system.actionType && !source.system.activation?.type
      && (source.type !== "tool") ) return false;

    // If item was updated after `4.0.1`, it shouldn't need the migration
    if ( !foundry.utils.isNewerVersion("4.0.1", source._stats?.systemVersion ?? "0.0.0") ) return false;

    // If the initial activity has already been created, no reason to create it again
    if ( !foundry.utils.isEmpty(source.system.activities) ) return false;

    return true;
  }

  /* -------------------------------------------- */

  /**
   * Migrate data from ActionTemplate and ActivatedEffectTemplate into a newly created activity.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #createInitialActivity(source) {
    let type = {
      mwak: "attack",
      rwak: "attack",
      msak: "attack",
      rsak: "attack",
      abil: "check",
      save: "save",
      ench: "enchant",
      summ: "summon",
      heal: "heal"
    }[source.system.actionType] ?? "utility";
    if ( (type === "utility") && source.system.damage?.parts?.length ) type = "damage";
    if ( source.type === "tool" ) type = "check";

    const cls = CONFIG.DND5E.activityTypes[type].documentClass;
    cls.createInitialActivity(source);

    if ( (type !== "save") && source.system.save?.ability ) {
      CONFIG.DND5E.activityTypes.save.documentClass.createInitialActivity(source, { offset: 1 });
    }
    if ( (source.type !== "weapon") && source.system.damage?.versatile ) {
      CONFIG.DND5E.activityTypes.damage.documentClass.createInitialActivity(source, { offset: 2, versatile: true });
    }
    if ( (type !== "utility") && source.system.formula ) {
      CONFIG.DND5E.activityTypes.utility.documentClass.createInitialActivity(source, { offset: 3 });
    }
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /**
   * Prepare final data for the activities & uses.
   * @param {object} rollData
   */
  prepareFinalActivityData(rollData) {
    const labels = this.parent.labels;
    UsesField.prepareData.call(this, rollData, labels);
    for ( const activity of this.activities ) activity.prepareFinalData();
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Retrieve information on available uses for display.
   * @returns {{ value: number, max: number, name: string }}
   */
  getUsesData() {
    return { value: this.uses.value, max: this.uses.max, name: "system.uses.value" };
  }

  /* -------------------------------------------- */

  /**
   * Perform any item & activity uses recovery.
   * @param {string[]} periods  Recovery periods to check.
   * @param {object} rollData   Roll data to use when evaluating recover formulas.
   * @returns {Promise<{ updates: object, rolls: BasicRoll[], destroy: boolean }>}
   */
  async recoverUses(periods, rollData) {
    const updates = {};
    const rolls = [];
    const autoRecharge = game.settings.get("dnd5e", "autoRecharge");
    const shouldRecharge = periods.includes("turnStart") && (this.parent.actor.type === "npc")
      && (autoRecharge !== "no");
    const recharge = async doc => {
      const config = { apply: false };
      const message = { create: autoRecharge !== "silent" };
      const result = await UsesField.rollRecharge.call(doc, config, {}, message);
      if ( result ) {
        if ( doc instanceof Item ) foundry.utils.mergeObject(updates, result.updates);
        else foundry.utils.mergeObject(updates, { [`system.activities.${doc.id}`]: result.updates });
        rolls.push(...result.rolls);
      }
    };

    const result = await UsesField.recoverUses.call(this, periods, rollData);
    if ( result ) {
      foundry.utils.mergeObject(updates, { "system.uses": result.updates });
      rolls.push(...result.rolls);
    }
    if ( shouldRecharge ) await recharge(this.parent);

    const destroy = this.uses.autoDestroy && this.uses.recovery.some(r => r.type === "loseAll")
      && (updates.system?.uses?.spent >= this.uses.max);

    for ( const activity of this.activities ) {
      if ( activity.dependentOrigin?.active === false ) continue;
      const result = await UsesField.recoverUses.call(activity, periods, rollData);
      if ( result ) {
        foundry.utils.mergeObject(updates, { [`system.activities.${activity.id}.uses`]: result.updates });
        rolls.push(...result.rolls);
      }
      if ( shouldRecharge ) await recharge(activity);
    }

    return { updates, rolls, destroy };
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /**
   * Perform any necessary actions when an item with activities is created.
   * @param {object} data     The initial data object provided to the document creation request.
   * @param {object} options  Additional options which modify the update request.
   * @param {string} userId   The id of the User requesting the document update.
   */
  async onCreateActivities(data, options, userId) {
    if ( (userId !== game.user.id) || !this.parent.isEmbedded ) return;

    // If item has any Cast activities, create locally cached copies of the spells
    const spells = (await Promise.all(
      this.activities.getByType("cast").map(a => !a.cachedSpell && a.getCachedSpellData())
    )).filter(_ => _);
    if ( spells.length ) this.parent.actor.createEmbeddedDocuments("Item", spells);
  }

  /* -------------------------------------------- */

  /**
   * Prepare any item or actor changes based on activity changes.
   * @param {object} changed  The differential data that is changed relative to the document's prior values.
   * @param {object} options  Additional options which modify the update request.
   * @param {User} user       The User requesting the document update.
   */
  async preUpdateActivities(changed, options, user) {
    if ( !foundry.utils.hasProperty(changed, "system.activities") ) return;

    // Track changes to rider activities & effects and store in item flags
    const cloneChanges = foundry.utils.deepClone(changed);
    const riders = this.parent.clone(cloneChanges).system.activities.getByType("enchant").reduce((riders, a) => {
      a.effects.forEach(e => {
        e.riders.activity.forEach(activity => riders.activity.add(activity));
        e.riders.effect.forEach(effect => riders.effect.add(effect));
      });
      return riders;
    }, { activity: new Set(), effect: new Set() });
    if ( !riders.activity.size && !riders.effect.size ) {
      foundry.utils.setProperty(changed, "flags.dnd5e.-=riders", null);
    } else {
      foundry.utils.setProperty(changed, "flags.dnd5e.riders", Object.entries(riders)
        .reduce((updates, [key, value]) => {
          if ( value.size ) updates[key] = Array.from(value);
          else updates[`-=${key}`] = null;
          return updates;
        }, {})
      );
    }

    if ( !this.parent.isEmbedded ) return;

    // Track changes to cached spells on cast activities
    const removed = Object.entries(changed.system?.activities ?? {}).map(([key, data]) => {
      if ( key.startsWith("-=") ) {
        const id = key.replace("-=", "");
        return this.activities.get(id).cachedSpell?.id;
      } else if ( foundry.utils.hasProperty(data, "spell.uuid") ) {
        return this.activities.get(key)?.cachedSpell?.id;
      }
      return null;
    }).filter(_ => _);
    if ( removed.length ) foundry.utils.setProperty(options, "dnd5e.removedCachedItems", removed);
  }

  /* -------------------------------------------- */

  /**
   * Perform any additional updates when an item with activities is updated.
   * @param {object} changed  The differential data that is changed relative to the document's prior values.
   * @param {object} options  Additional options which modify the update request.
   * @param {string} userId   The id of the User requesting the document update.
   */
  async onUpdateActivities(changed, options, userId) {
    if ( (userId !== game.user.id) || !this.parent.isEmbedded
      || !foundry.utils.hasProperty(changed, "system.activities") ) return;

    // If any Cast activities were removed, or their spells changed, remove old cached spells
    if ( options.dnd5e?.removedCachedItems ) {
      await this.parent.actor.deleteEmbeddedDocuments("Item", options.dnd5e.removedCachedItems);
    }

    // Create any new cached spells & update existing ones as necessary
    const cachedInserts = [];
    for ( const id of Object.keys(changed.system.activities) ) {
      const activity = this.activities.get(id);
      if ( !(activity instanceof CastActivity) ) continue;
      const existingSpell = activity.cachedSpell;
      if ( existingSpell ) {
        const enchantment = existingSpell.effects.get(CastActivity.ENCHANTMENT_ID);
        await enchantment?.update({ changes: activity.getSpellChanges() });
      } else {
        const cached = await activity.getCachedSpellData();
        if ( cached ) cachedInserts.push(cached);
      }
    }
    if ( cachedInserts.length ) await this.parent.actor.createEmbeddedDocuments("Item", cachedInserts);
  }

  /* -------------------------------------------- */

  /**
   * Perform any necessary cleanup when an item with activities is deleted.
   * @param {object} options  Additional options which modify the deletion request.
   * @param {string} userId   The id of the User requesting the document update.
   */
  onDeleteActivities(options, userId) {
    if ( (userId !== game.user.id) || !this.parent.isEmbedded ) return;

    // If item has any Cast activities, clean up any cached spells
    const spellIds = this.activities.getByType("cast").map(a => a.cachedSpell?.id).filter(_ => _);
    if ( spellIds.length ) this.parent.actor.deleteEmbeddedDocuments("Item", spellIds);
  }
}

/**
 * Data model template with item type, subtype and baseItem.
 * @extends {SystemDataModel}
 * @mixin
 */
class ItemTypeTemplate extends SystemDataModel$1 {

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Item categories used to populate `system.type.value`.
   * @type {Record<string, string>}
   */
  static get itemCategories() {
    return {};
  }

  get itemCategories() {
    return this.constructor.itemCategories();
  }

  /* -------------------------------------------- */
  /*  Data Migration                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static _migrateData(source) {
    super._migrateData(source);
    ItemTypeTemplate.#migrateType(source);
  }

  /* -------------------------------------------- */

  /**
   * Convert old types into the new standard.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateType(source) {
    if ( foundry.utils.getType(source.type) === "Object" ) return;
    const oldType = source.consumableType ?? source.armor?.type ?? source.toolType ?? source.weaponType;
    if ( (oldType !== null) && (oldType !== undefined) ) foundry.utils.setProperty(source, "type.value", oldType);
    if ( "baseItem" in source ) foundry.utils.setProperty(source, "type.baseItem", source.baseItem);
  }
}

const { ArrayField: ArrayField$g, DocumentUUIDField: DocumentUUIDField$9, NumberField: NumberField$z, SchemaField: SchemaField$H, StringField: StringField$X } = foundry.data.fields;

/**
 * @import { MountableTemplateData } from "./_types.mjs";
 */

/**
 * Data model template for equipment that can be mounted on a vehicle.
 * @extends {SystemDataModel<MountableTemplateData>}
 * @mixin
 */
class MountableTemplate extends SystemDataModel$1 {
  /** @inheritDoc */
  static defineSchema() {
    return {
      cover: new NumberField$z({ min: 0, max: 1 }),
      crew: new SchemaField$H({
        max: new NumberField$z({ min: 0, integer: true }),
        value: new ArrayField$g(new DocumentUUIDField$9({ type: "Actor" }))
      }),
      hp: new SchemaField$H({
        conditions: new StringField$X(),
        dt: new NumberField$z({ integer: true, min: 0 }),
        max: new NumberField$z({ integer: true, min: 0 }),
        value: new NumberField$z({ integer: true, min: 0 })
      }, { required: false, initial: undefined }),
      speed: new SchemaField$H({
        conditions: new StringField$X(),
        units: new StringField$X({ required: true, blank: false, initial: () => defaultUnits("length") }),
        value: new NumberField$z({ min: 0, integer: true })
      }, { required: false, initial: undefined })
    };
  }

  /* -------------------------------------------- */

  /**
   * Prepare mountable item properties.
   */
  prepareMountableData() {
    const { hp } = this;
    if ( hp ) hp.pct = hp.max ? Math.clamp((hp.value / hp.max) * 100, 0, 100) : 0;
  }
}

const { SchemaField: SchemaField$G, StringField: StringField$W } = foundry.data.fields;

/**
 * A field for storing Item type data.
 *
 * @param {object} [options={}]                   Options to configure this field's behavior.
 * @param {string} [options.value]                An initial value for the Item's type.
 * @param {string|boolean} [options.subtype]      An initial value for the Item's subtype, or false to exclude it.
 * @param {string|boolean} [options.baseItem]     An initial value for the Item's baseItem, or false to exclude it.
 * @param {DataFieldOptions} [schemaOptions={}]   Options forwarded to the SchemaField.
 */
class ItemTypeField extends SchemaField$G {
  constructor(options={}, schemaOptions={}) {
    const fields = {
      value: new StringField$W({
        required: true, blank: true, initial: options.value ?? "", label: "DND5E.Type"
      }),
      subtype: new StringField$W({
        required: true, blank: true, initial: options.subtype ?? "", label: "DND5E.Subtype"
      }),
      baseItem: new StringField$W({
        required: true, blank: true, initial: options.baseItem ?? "", label: "DND5E.BaseItem"
      })
    };
    if ( options.subtype === false ) delete fields.subtype;
    if ( options.baseItem === false ) delete fields.baseItem;
    super(fields, schemaOptions);
  }
}

const { NumberField: NumberField$y, SchemaField: SchemaField$F, SetField: SetField$v, StringField: StringField$V } = foundry.data.fields;

/**
 * @import { InventorySectionDescriptor } from "../../applications/components/_types.mjs";
 * @import { EquipmentItemSystemData } from "./_types.mjs";
 * @import {
 *   ActivitiesTemplateData, EquippableItemTemplateData, IdentifiableTemplateData,
 *   ItemDescriptionTemplateData, ItemTypeTemplateData, MountableTemplateData, PhysicalItemTemplateData
 * } from "./templates/_types.mjs";
 */

/**
 * Data definition for Equipment items.
 * @extends {ItemDataModel<
 *   ActivitiesTemplate & ItemDescriptionTemplate & IdentifiableTemplate & ItemTypeTemplate &
 *   PhysicalItemTemplate & EquippableItemTemplate & MountableTemplate & EquipmentItemSystemData
 * >}
 * @mixes ActivitiesTemplateData
 * @mixes ItemDescriptionTemplateData
 * @mixes ItemTypeTemplateData
 * @mixes IdentifiableTemplateData
 * @mixes PhysicalItemTemplateData
 * @mixes EquippableItemTemplateData
 * @mixes MountableTemplateData
 * @mixes EquipmentItemSystemData
 */
class EquipmentData extends ItemDataModel$1.mixin(
  ActivitiesTemplate, ItemDescriptionTemplate, IdentifiableTemplate, ItemTypeTemplate,
  PhysicalItemTemplate, EquippableItemTemplate, MountableTemplate
) {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.VEHICLE.MOUNTABLE", "DND5E.SOURCE"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return this.mergeSchema(super.defineSchema(), {
      armor: new SchemaField$F({
        value: new NumberField$y({ required: true, integer: true, min: 0, label: "DND5E.ArmorClass" }),
        magicalBonus: new NumberField$y({ min: 0, integer: true, label: "DND5E.MagicalBonus" }),
        dex: new NumberField$y({ required: true, integer: true, label: "DND5E.ItemEquipmentDexMod" })
      }),
      proficient: new NumberField$y({
        required: true, min: 0, max: 1, integer: true, initial: null, label: "DND5E.ProficiencyLevel"
      }),
      properties: new SetField$v(new StringField$V(), { label: "DND5E.ItemEquipmentProperties" }),
      strength: new NumberField$y({ required: true, integer: true, min: 0, label: "DND5E.ItemRequiredStr" }),
      type: new ItemTypeField({ subtype: false }, { label: "DND5E.ItemEquipmentType" })
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static metadata = Object.freeze(foundry.utils.mergeObject(super.metadata, {
    hasEffects: true,
    enchantable: true
  }, {inplace: false}));

  /* -------------------------------------------- */

  /** @override */
  static get compendiumBrowserFilters() {
    return new Map([
      ["type", {
        label: "DND5E.ItemEquipmentType",
        type: "set",
        config: {
          choices: CONFIG.DND5E.equipmentTypes,
          keyPath: "system.type.value"
        }
      }],
      ["attunement", this.compendiumBrowserAttunementFilter],
      ...this.compendiumBrowserPhysicalItemFilters,
      ["properties", this.compendiumBrowserPropertiesFilter("equipment")]
    ]);
  }

  /* -------------------------------------------- */

  /**
   * Default configuration for this item type's inventory section.
   * @returns {InventorySectionDescriptor}
   */
  static get inventorySection() {
    return {
      id: "equipment",
      order: 200,
      label: "TYPES.Item.equipmentPl",
      groups: { type: "equipment" },
      columns: ["price", "weight", "quantity", "charges", "controls"]
    };
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Properties displayed in chat.
   * @type {string[]}
   */
  get chatProperties() {
    return [
      this.type.label,
      (this.isArmor || this.isMountable) ? (this.parent.labels?.armor ?? null) : null,
      this.properties.has("stealthDisadvantage") ? game.i18n.localize("DND5E.ITEM.Property.StealthDisadvantage") : null
    ];
  }

  /* -------------------------------------------- */

  /**
   * Properties displayed on the item card.
   * @type {string[]}
   */
  get cardProperties() {
    return [
      (this.isArmor || this.isMountable) ? (this.parent.labels?.armor ?? null) : null,
      this.properties.has("stealthDisadvantage") ? game.i18n.localize("DND5E.ITEM.Property.StealthDisadvantage") : null
    ];
  }

  /* -------------------------------------------- */

  /**
   * Is this Item any of the armor subtypes?
   * @type {boolean}
   */
  get isArmor() {
    return this.type.value 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.type.value === "vehicle";
  }

  /* -------------------------------------------- */

  /** @override */
  static get itemCategories() {
    return CONFIG.DND5E.equipmentTypes;
  }

  /* -------------------------------------------- */

  /**
   * The proficiency multiplier for this item.
   * @returns {number}
   */
  get proficiencyMultiplier() {
    if ( Number.isFinite(this.proficient) ) return this.proficient;
    const actor = this.parent.actor;
    if ( !actor ) return 0;
    if ( actor.type === "npc" ) return 1; // NPCs are always considered proficient with any armor in their stat block.
    const config = CONFIG.DND5E.armorProficienciesMap;
    const itemProf = config[this.type.value];
    const actorProfs = actor.system.traits?.armorProf?.value ?? new Set();
    const isProficient = (itemProf === true) || actorProfs.has(itemProf) || actorProfs.has(this.type.baseItem);
    return Number(isProficient);
  }

  /* -------------------------------------------- */
  /*  Data Migration                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static _migrateData(source) {
    super._migrateData(source);
    ActivitiesTemplate.migrateActivities(source);
    EquipmentData.#migrateArmor(source);
    EquipmentData.#migrateType(source);
    EquipmentData.#migrateStrength(source);
    EquipmentData.#migrateProficient(source);
  }

  /* -------------------------------------------- */

  /**
   * Apply migrations to the armor field.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateArmor(source) {
    if ( !("armor" in source) ) return;
    source.armor ??= {};
    if ( (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);
    }
  }

  /* -------------------------------------------- */

  /**
   * Apply migrations to the type field.
   * @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.value === "bonus" ) source.type.value = "trinket";
  }

  /* -------------------------------------------- */

  /**
   * 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);
  }

  /* -------------------------------------------- */

  /**
   * Migrates stealth disadvantage boolean to properties.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static _migrateStealth(source) {
    if ( foundry.utils.getProperty(source, "system.stealth") === true ) {
      foundry.utils.setProperty(source, "flags.dnd5e.migratedProperties", ["stealthDisadvantage"]);
    }
  }

  /* -------------------------------------------- */

  /**
   * Migrate the proficient field to convert boolean values.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateProficient(source) {
    if ( typeof source.proficient === "boolean" ) source.proficient = Number(source.proficient);
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareBaseData() {
    super.prepareBaseData();
    this.armor.base = this.armor.value = (this._source.armor.value ?? 0);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareDerivedData() {
    super.prepareDerivedData();
    this.prepareDescriptionData();
    this.prepareIdentifiable();
    this.preparePhysicalData();
    this.prepareMountableData();
    if ( this.magicAvailable && this.armor.magicalBonus ) this.armor.value += this.armor.magicalBonus;
    this.type.label = CONFIG.DND5E.equipmentTypes[this.type.value]
      ?? game.i18n.localize(CONFIG.Item.typeLabels.equipment);
    this.type.identifier = this.type.value === "shield"
      ? CONFIG.DND5E.shieldIds[this.type.baseItem]
      : CONFIG.DND5E.armorIds[this.type.baseItem];

    const labels = this.parent.labels ??= {};
    labels.armor = this.armor.value ? `${this.armor.value} ${game.i18n.localize("DND5E.AC")}` : "";
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareFinalData() {
    this.prepareFinalActivityData(this.parent.getRollData({ deterministic: true }));
    this.prepareFinalEquippableData();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getFavoriteData() {
    return foundry.utils.mergeObject(await super.getFavoriteData(), {
      subtitle: [this.type.label, this.parent.labels.activation],
      uses: this.hasLimitedUses ? this.getUsesData() : null
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getSheetData(context) {
    context.subtitles = [
      { label: this.type.label },
      ...this.physicalItemSheetFields
    ];

    context.parts = ["dnd5e.details-equipment", "dnd5e.field-uses"];
    context.equipmentTypeOptions = [
      ...Object.entries(CONFIG.DND5E.miscEquipmentTypes).map(([value, label]) => ({ value, label })),
      ...Object.entries(CONFIG.DND5E.armorTypes).map(([value, label]) => ({ value, label, group: "DND5E.Armor" }))
    ];
    context.hasDexModifier = this.isArmor && (this.type.value !== "shield");
    if ( this.armor.value && (this.isArmor || (this.type.value === "shield")) ) {
      context.properties.active.shift();
      context.info = [{
        label: "DND5E.ArmorClass",
        classes: "info-lg",
        value: this.type.value === "shield" ? dnd5e.utils.formatModifier(this.armor.value) : this.armor.value
      }];
    }
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preCreate(data, options, user) {
    if ( (await super._preCreate(data, options, user)) === false ) return false;
    await this.preCreateEquipped(data, options, user);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preUpdate(changed, options, user) {
    if ( (await super._preUpdate(changed, options, user)) === false ) return false;
    await this.preUpdateIdentifiable(changed, options, user);
  }
}

const { BooleanField: BooleanField$A, NumberField: NumberField$x, SchemaField: SchemaField$E, SetField: SetField$u, StringField: StringField$U } = foundry.data.fields;

/**
 * @import { SpellItemSystemData } from "./_types.mjs";
 * @import { ActivitiesTemplateData ItemDescriptionTemplateData } from "./templates/_types.mjs";
 */

/**
 * Data definition for Spell items.
 * @extends {ItemDataModel<ActivitiesTemplate & ItemDescriptionTemplate & SpellItemSystemData>}
 * @mixes ActivitiesTemplateData
 * @mixes ItemDescriptionTemplateData
 * @mixes SpellItemSystemData
 */
class SpellData extends ItemDataModel$1.mixin(ActivitiesTemplate, ItemDescriptionTemplate) {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static LOCALIZATION_PREFIXES = [
    "DND5E.ACTIVATION", "DND5E.DURATION", "DND5E.RANGE", "DND5E.SOURCE", "DND5E.TARGET"
  ];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return this.mergeSchema(super.defineSchema(), {
      ability: new StringField$U({ label: "DND5E.SpellAbility" }),
      activation: new ActivationField(),
      duration: new DurationField(),
      level: new NumberField$x({ required: true, integer: true, initial: 1, min: 0, label: "DND5E.SpellLevel" }),
      materials: new SchemaField$E({
        value: new StringField$U({ required: true, label: "DND5E.SpellMaterialsDescription" }),
        consumed: new BooleanField$A({ required: true, label: "DND5E.SpellMaterialsConsumed" }),
        cost: new NumberField$x({ required: true, initial: 0, min: 0, label: "DND5E.SpellMaterialsCost" }),
        supply: new NumberField$x({ required: true, initial: 0, min: 0, label: "DND5E.SpellMaterialsSupply" })
      }, { label: "DND5E.SpellMaterials" }),
      method: new StringField$U({ required: true, initial: "", label: "DND5E.SpellPreparation.Method" }),
      prepared: new NumberField$x({ required: true, nullable: false, integer: true, min: 0, initial: 0 }),
      properties: new SetField$u(new StringField$U(), { label: "DND5E.SpellComponents" }),
      range: new RangeField(),
      school: new StringField$U({ required: true, label: "DND5E.SpellSchool" }),
      sourceClass: new StringField$U({ label: "DND5E.SpellSourceClass" }),
      target: new TargetField()
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static metadata = Object.freeze(foundry.utils.mergeObject(super.metadata, {
    enchantable: true,
    hasEffects: true
  }, { inplace: false }));

  /* -------------------------------------------- */

  /** @override */
  static get compendiumBrowserFilters() {
    return new Map([
      ["level", {
        label: "DND5E.Level",
        type: "range",
        config: {
          keyPath: "system.level",
          min: 0,
          max: Object.keys(CONFIG.DND5E.spellLevels).length - 1
        }
      }],
      ["school", {
        label: "DND5E.School",
        type: "set",
        config: {
          choices: CONFIG.DND5E.spellSchools,
          keyPath: "system.school"
        }
      }],
      ["spelllist", {
        label: "TYPES.JournalEntryPage.spells",
        type: "set",
        createFilter: (filters, value, def) => {
          let include = new Set();
          let exclude = new Set();
          for ( const [k, v] of Object.entries(value ?? {}) ) {
            const list = dnd5e.registry.spellLists.forType(k);
            if ( !list || (v === 0) ) continue;
            if ( v === 1 ) include = include.union(list.identifiers);
            else if ( v === -1 ) exclude = exclude.union(list.identifiers);
          }
          if ( include.size ) filters.push({ k: "system.identifier", o: "in", v: include });
          if ( exclude.size ) filters.push({ o: "NOT", v: { k: "system.identifier", o: "in", v: exclude } });
        },
        config: {
          choices: dnd5e.registry.spellLists.options.reduce((obj, entry) => {
            const [type, identifier] = entry.value.split(":");
            const list = dnd5e.registry.spellLists.forType(type, identifier);
            if ( list?.identifiers.size ) obj[entry.value] = {
              label: entry.label, group: CONFIG.DND5E.spellListTypes[type]
            };
            return obj;
          }, {}),
          collapseGroup: group => group !== CONFIG.DND5E.spellListTypes.class
        }
      }],
      ["properties", this.compendiumBrowserPropertiesFilter("spell")]
    ]);
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Attack classification of this spell.
   * @type {"spell"}
   */
  get attackClassification() {
    return "spell";
  }

  /* -------------------------------------------- */

  /** @override */
  get availableAbilities() {
    if ( this.ability ) return new Set([this.ability]);
    const spellcasting = this.parent?.actor?.spellcastingClasses[this.sourceClass]?.spellcasting.ability
      ?? this.parent?.actor?.system.attributes?.spellcasting;
    return new Set(spellcasting ? [spellcasting] : []);
  }

  /* -------------------------------------------- */

  /** @override */
  get canConfigureScaling() {
    return this.level > 0;
  }

  /* -------------------------------------------- */

  /**
   * Whether the spell can be prepared.
   * @type {boolean}
   */
  get canPrepare() {
    return !!CONFIG.DND5E.spellcasting[this.method]?.prepares;
  }

  /* -------------------------------------------- */

  /** @override */
  get canScale() {
    return (this.level > 0) && !!CONFIG.DND5E.spellcasting[this.method]?.slots;
  }

  /* -------------------------------------------- */

  /** @override */
  get canScaleDamage() {
    return true;
  }

  /* -------------------------------------------- */

  /**
   * 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,
      this.parent.labels.duration
    ];
  }

  /* -------------------------------------------- */

  /**
   * Whether this spell counts towards a class' number of prepared spells.
   * @type {boolean}
   */
  get countsPrepared() {
    return !!CONFIG.DND5E.spellcasting[this.method]?.prepares
      && (this.level > 0)
      && (this.prepared === CONFIG.DND5E.spellPreparationStates.prepared.value);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  get _typeAbilityMod() {
    return this.availableAbilities.first() ?? "int";
  }

  /* -------------------------------------------- */

  /** @override */
  get criticalThreshold() {
    return this.parent?.actor?.flags.dnd5e?.spellCriticalThreshold ?? Infinity;
  }

  /* -------------------------------------------- */

  /**
   * Retrieve a linked activity that granted this spell using the stored `cachedFor` value.
   * @returns {Activity|null}
   */
  get linkedActivity() {
    const relative = this.parent.actor;
    const uuid = this.parent.getFlag("dnd5e", "cachedFor");
    if ( !relative || !uuid ) return null;
    const data = foundry.utils.parseUuid(uuid, { relative });
    const [itemId, , activityId] = (data?.embedded ?? []).slice(-3);
    return relative.items.get(itemId)?.system.activities?.get(activityId) ?? null;
    // TODO: Swap back to fromUuidSync once https://github.com/foundryvtt/foundryvtt/issues/11214 is resolved
    // return fromUuidSync(this.parent.getFlag("dnd5e", "cachedFor"), { relative, strict: false }) ?? null;
  }

  /* -------------------------------------------- */

  /**
   * The proficiency multiplier for this item.
   * @returns {number}
   */
  get proficiencyMultiplier() {
    return 1;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  get scalingIncrease() {
    if ( this.level !== 0 ) return null;
    return Math.floor(((this.parent.actor?.system.cantripLevel?.(this.parent) ?? 0) + 1) / 6);
  }

  /* -------------------------------------------- */

  /** @override */
  get tooltipSubtitle() {
    return [this.parent.labels.level, CONFIG.DND5E.spellSchools[this.school]?.label];
  }

  /* -------------------------------------------- */
  /*  Data Migration                              */
  /* -------------------------------------------- */

  /**
   * @deprecated since 5.1
   * @ignore
   */
  get preparation() {
    foundry.utils.logCompatibilityWarning("SpellData#preparation is deprecated. Please use SpellData#method in "
      + "place of preparation.mode and SpellData#prepared in place of preparation.prepared.",
    { since: "DnD5e 5.1", until: "DnD5e 5.4" });
    if ( this.prepared === 2 ) return { mode: "always", prepared: 1 };
    if ( this.method === "spell" ) return { mode: "prepared", prepared: Boolean(this.prepared) };
    return { mode: this.method, prepared: Boolean(this.prepared) };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static _migrateData(source) {
    super._migrateData(source);
    ActivitiesTemplate.migrateActivities(source);
    SpellData.#migrateActivation(source);
    SpellData.#migrateTarget(source);
    SpellData.#migratePreparation(source);
  }

  /* -------------------------------------------- */

  /**
   * Migrate the component object to be 'properties' instead.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static _migrateComponentData(source) {
    const components = filteredKeys(source.system?.components ?? {});
    if ( components.length ) {
      foundry.utils.setProperty(source, "flags.dnd5e.migratedProperties", components);
    }
  }

  /* -------------------------------------------- */

  /**
   * Migrate activation data.
   * Added in DnD5e 4.0.0.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateActivation(source) {
    if ( source.activation?.cost ) source.activation.value = source.activation.cost;
  }

  /* -------------------------------------------- */

  /**
   * Migrate target data.
   * Added in DnD5e 4.0.0.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateTarget(source) {
    if ( !("target" in source) ) return;
    source.target.affects ??= {};
    source.target.template ??= {};

    if ( "units" in source.target ) source.target.template.units = source.target.units;
    if ( "width" in source.target ) source.target.template.width = source.target.width;

    const type = source.target.type ?? source.target.template.type ?? source.target.affects.type;
    if ( type in CONFIG.DND5E.areaTargetTypes ) {
      if ( "type" in source.target ) source.target.template.type = type;
      if ( "value" in source.target ) source.target.template.size = source.target.value;
    } else if ( type in CONFIG.DND5E.individualTargetTypes ) {
      if ( "type" in source.target ) source.target.affects.type = type;
      if ( "value" in source.target ) source.target.affects.count = source.target.value;
    }
  }

  /* -------------------------------------------- */

  /**
   * Migrate preparation data.
   * @since 5.1.0
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migratePreparation(source) {
    if ( source.preparation === undefined ) return;
    if ( source.preparation.mode === "always" ) {
      if ( !("method" in source) ) source.method = "spell";
      if ( !("prepared" in source) ) source.prepared = 2;
    } else {
      if ( !("method" in source) ) {
        if ( source.preparation.mode === "prepared" ) source.method = "spell";
        else if ( source.preparation.mode ) source.method = source.preparation.mode;
      }
      if ( (typeof source.preparation.prepared === "boolean") && !("prepared" in source) ) {
        source.prepared = Number(source.preparation.prepared);
      }
    }
    delete source.preparation;
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareDerivedData() {
    super.prepareDerivedData();
    this.prepareDescriptionData();
    this.properties.add("mgc");
    this.duration.concentration = this.properties.has("concentration");

    const labels = this.parent.labels ??= {};
    labels.level = CONFIG.DND5E.spellLevels[this.level];
    labels.school = CONFIG.DND5E.spellSchools[this.school]?.label;
    if ( this.properties.has("material") ) labels.materials = this.materials.value;

    labels.components = this.properties.reduce((obj, c) => {
      const config = this.validProperties.has(c) ? CONFIG.DND5E.itemProperties[c] : null;
      if ( !config ) return obj;
      const { abbreviation: abbr, label, icon } = config;
      // Only add properties to display arrays if they have displayable content
      if ( config.isTag ) {
        // Tag properties: add to tags if has label
        if ( label ) obj.tags.push(label);
        if ( abbr || icon ) obj.all.push({ abbr, icon, tag: true });
      } else if ( abbr ) {
        // VSM properties: only add if has abbreviation
        obj.vsm.push(abbr);
        obj.all.push({ abbr, icon, tag: false });
      }
      // Properties with neither abbreviation nor isTag are silently ignored for display
      return obj;
    }, { all: [], vsm: [], tags: [] });
    labels.components.vsm = game.i18n.getListFormatter({ style: "narrow" }).format(labels.components.vsm);

    const uuid = this.parent._stats.compendiumSource ?? this.parent.uuid;
    Object.defineProperty(labels, "classes", {
      get() {
        return Array.from(dnd5e.registry.spellLists.forSpell(uuid))
          .filter(list => list.metadata.type === "class")
          .map(list => list.name)
          .sort((lhs, rhs) => lhs.localeCompare(rhs, game.i18n.lang));
      },
      configurable: true
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareFinalData() {
    const rollData = this.parent.getRollData({ deterministic: true });
    const labels = this.parent.labels ??= {};
    this.prepareFinalActivityData(rollData);
    ActivationField.prepareData.call(this, rollData, labels);
    DurationField.prepareData.call(this, rollData, labels);
    RangeField.prepareData.call(this, rollData, labels);
    TargetField.prepareData.call(this, rollData, labels);

    // Count preparations.
    if ( this.parent.isOwned && this.sourceClass && this.countsPrepared ) {
      const sourceClass = this.parent.actor.spellcastingClasses[this.sourceClass];
      const sourceSubclass = sourceClass?.subclass;
      if ( sourceClass ) sourceClass.system.spellcasting.preparation.value++;
      if ( sourceSubclass ) sourceSubclass.system.spellcasting.preparation.value++;
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getCardData(enrichmentOptions={}) {
    const context = await super.getCardData(enrichmentOptions);
    context.isSpell = true;
    const { activation, components, duration, range, target } = this.parent.labels;
    context.properties = [components?.vsm, activation, duration, range, target].filter(_ => _);
    if ( !this.properties.has("material") ) delete context.materials;
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getFavoriteData() {
    return foundry.utils.mergeObject(await super.getFavoriteData(), {
      subtitle: [this.parent.labels.components.vsm, this.parent.labels.activation],
      modifier: this.parent.labels.modifier,
      range: this.range,
      save: this.activities.getByType("save")[0]?.save
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getSheetData(context) {
    context.properties.active = [...(this.parent.labels?.components?.tags ?? []), ...(context.labels.classes ?? [])];
    context.subtitles = [
      { label: context.labels.level },
      { label: context.labels.school },
      { label: CONFIG.DND5E.spellcasting[this.method]?.label }
    ];

    context.parts = ["dnd5e.details-spell", "dnd5e.field-uses"];

    // Default Ability & Spellcasting Classes
    if ( this.parent.actor ) {
      const ability = CONFIG.DND5E.abilities[
        this.parent.actor.spellcastingClasses[this.sourceClass]?.spellcasting.ability
          ?? this.parent.actor.system.attributes?.spellcasting
      ]?.label?.toLowerCase();
      if ( ability ) context.defaultAbility = game.i18n.format("DND5E.DefaultSpecific", { default: ability });
      else context.defaultAbility = game.i18n.localize("DND5E.Default");
      context.spellcastingClasses = Object.entries(this.parent.actor.spellcastingClasses ?? {})
        .map(([value, cls]) => ({ value, label: cls.name }));
    }

    // Activation
    context.activationTypes = [
      ...Object.entries(CONFIG.DND5E.activityActivationTypes).map(([value, { label, group }]) => {
        return { value, label, group };
      }),
      { value: "", label: "DND5E.NoneActionLabel" }
    ];

    // Duration
    context.durationUnits = [
      ...Object.entries(CONFIG.DND5E.specialTimePeriods).map(([value, label]) => ({ value, label })),
      ...Object.entries(CONFIG.DND5E.scalarTimePeriods).map(([value, label]) => {
        return { value, label, group: "DND5E.DurationTime" };
      }),
      ...Object.entries(CONFIG.DND5E.permanentTimePeriods).map(([value, label]) => {
        return { value, label, group: "DND5E.DurationPermanent" };
      })
    ];

    // Targets
    context.targetTypes = [
      ...Object.entries(CONFIG.DND5E.individualTargetTypes).map(([value, { label }]) => {
        return { value, label, group: "DND5E.TargetTypeIndividual" };
      }),
      ...Object.entries(CONFIG.DND5E.areaTargetTypes).map(([value, { label }]) => {
        return { value, label, group: "DND5E.TargetTypeArea" };
      })
    ];
    context.scalarTarget = this.target.affects.type
      && (CONFIG.DND5E.individualTargetTypes[this.target.affects.type]?.scalar !== false);
    context.affectsPlaceholder = game.i18n.localize(`DND5E.TARGET.Count.${
      this.target?.template?.type ? "Every" : "Any"}`);
    context.dimensions = this.target.template.dimensions;
    // TODO: Ensure this behaves properly with enchantments, will probably need source target data

    // Range
    context.rangeTypes = [
      ...Object.entries(CONFIG.DND5E.rangeTypes).map(([value, label]) => ({ value, label })),
      ...Object.entries(CONFIG.DND5E.movementUnits).map(([value, { label }]) => {
        return { value, label, group: "DND5E.RangeDistance" };
      })
    ];

    // Spellcasting
    context.canPrepare = this.canPrepare;
    context.spellcastingMethods = Object.values(CONFIG.DND5E.spellcasting).map(({ key, label }) => {
      return { label, value: key };
    });
    if ( this.method && !(this.method in CONFIG.DND5E.spellcasting) ) {
      context.spellcastingMethods.push({ label: this.method, value: this.method });
    }
  }

  /* -------------------------------------------- */
  /*  Drag & Drop                                 */
  /* -------------------------------------------- */

  /** @override */
  static onDropCreate(event, actor, itemData) {
    if ( !["npc", "character"].includes(actor.type) ) return;

    // Determine the section it is dropped on, if any.
    let header = event.target.closest(".items-header"); // Dropped directly on the header.
    if ( !header ) {
      const list = event.target.closest(".item-list"); // Dropped inside an existing list.
      header = list?.previousElementSibling;
    }
    const { method } = header?.closest("[data-level]")?.dataset ?? {};

    // Determine the actor's spell slot progressions, if any.
    const spellcastKeys = Object.keys(CONFIG.DND5E.spellcasting);
    const progs = Object.values(actor.classes).reduce((acc, cls) => {
      const type = cls.spellcasting?.type;
      if ( spellcastKeys.includes(type) ) acc.add(type);
      return acc;
    }, new Set());

    const { system } = itemData;
    const methods = CONFIG.DND5E.spellcasting;
    if ( methods[method] ) system.method = method;
    else if ( progs.size ) system.method = progs.first();
    else if ( actor.system.attributes.spell?.level ) system.method = "spell";
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  getRollData(...options) {
    const data = super.getRollData(...options);
    data.item.level = data.item.level + (this.parent.getFlag("dnd5e", "scaling") ?? 0);
    return data;
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preCreate(data, options, user) {
    if ( (await super._preCreate(data, options, user)) === false ) return false;
    if ( !this.parent.isEmbedded ) return;
    const system = data.system ?? {};

    // Set as prepared for NPCs, and not prepared for PCs
    if ( ["character", "npc"].includes(this.parent.actor.type) && !("prepared" in system) ) {
      this.updateSource({ prepared: Number(this.parent.actor.type === "npc" || (this.level < 1)) });
    }

    if ( ["atwill", "innate"].includes(system.method) || this.sourceClass ) return;
    const classes = new Set(Object.keys(this.parent.actor.spellcastingClasses));
    if ( !classes.size ) return;

    // Set the source class, and ensure the preparation mode matches if adding a prepared spell to an alt class
    const setClass = cls => {
      this.updateSource({ sourceClass: cls, method: this.parent.actor.classes[cls].spellcasting.type });
    };

    // If preparation mode matches an alt spellcasting type and matching class exists, set as that class
    if ( (system.method !== "spell") && (system.method in CONFIG.DND5E.spellcasting) ) {
      const altClasses = classes.filter(i => this.parent.actor.classes[i].spellcasting.type === system.method);
      if ( altClasses.size === 1 ) setClass(altClasses.first());
      return;
    }

    // If only a single spellcasting class is present, use that
    if ( classes.size === 1 ) {
      setClass(classes.first());
      return;
    }

    // Create intersection of spellcasting classes and classes that offer the spell
    const spellClasses = new Set(
      dnd5e.registry.spellLists.forSpell(this.parent._stats.compendiumSource).map(l => l.metadata.identifier)
    );
    const intersection = classes.intersection(spellClasses);
    if ( intersection.size === 1 ) setClass(intersection.first());
  }
}

/**
 * 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";
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Should only deterministic proficiency be returned, regardless of system settings?
   * @type {boolean}
   */
  deterministic = false;

  /* -------------------------------------------- */

  /**
   * 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.deterministic
      ? this.dice : String(this.flat);
  }

  /* -------------------------------------------- */

  /**
   * Whether the proficiency is greater than zero.
   * @type {boolean}
   */
  get hasProficiency() {
    return (this._baseProficiency > 0) && (this.multiplier > 0);
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * 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);
  }

  /* -------------------------------------------- */

  /**
   * Return a clone of this proficiency with any changes applied.
   * @param {object} [updates={}]
   * @param {number} updates.proficiency  Actor's flat proficiency bonus based on their current level.
   * @param {number} updates.multiplier   Value by which to multiply the actor's base proficiency value.
   * @param {boolean} updates.roundDown   Should half-values be rounded up or down?
   * @returns {Proficiency}
   */
  clone({ proficiency, multiplier, roundDown }={}) {
    proficiency ??= this._baseProficiency;
    multiplier ??= this.multiplier;
    roundDown ??= this.rounding === "down";
    return new this.constructor(proficiency, multiplier, roundDown);
  }

  /* -------------------------------------------- */

  /**
   * Override the default `toString` method to return flat proficiency for backwards compatibility in formula.
   * @returns {string}  Either flat or dice proficiency term based on configured setting.
   */
  toString() {
    return this.term;
  }
}

/**
 * Mixin used to add system flags enforcement to types.
 * @template {foundry.abstract.Document} T
 * @param {typeof T} Base  The base document class to wrap.
 * @returns {typeof SystemFlags}
 * @mixin
 */
function SystemFlagsMixin(Base) {
  class SystemFlags extends Base {
    /**
     * Get the data model that represents system flags.
     * @type {typeof DataModel|null}
     * @abstract
     */
    get _systemFlagsDataModel() {
      return null;
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    prepareData() {
      super.prepareData();
      if ( ("dnd5e" in this.flags) && this._systemFlagsDataModel ) {
        this.flags.dnd5e = new this._systemFlagsDataModel(this._source.flags.dnd5e, { parent: this });
      }
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    async setFlag(scope, key, value) {
      if ( (scope === "dnd5e") && this._systemFlagsDataModel ) {
        let diff;
        const changes = foundry.utils.expandObject({ [key]: value });
        if ( this.flags.dnd5e ) diff = this.flags.dnd5e.updateSource(changes, { dryRun: true });
        else diff = new this._systemFlagsDataModel(changes, { parent: this }).toObject();
        return this.update({ flags: { dnd5e: diff } });
      }
      return super.setFlag(scope, key, value);
    }
  }
  return SystemFlags;
}

/**
 * Mixin used to share some logic between Actor & Item documents.
 * @template {foundry.abstract.Document} T
 * @param {typeof T} Base  The base document class to wrap.
 * @returns {typeof SystemDocument}
 * @mixin
 */
function SystemDocumentMixin(Base) {
  class SystemDocument extends DependentDocumentMixin(SystemFlagsMixin(Base)) {
    /** @inheritDoc */
    get _systemFlagsDataModel() {
      return this.system?.metadata?.systemFlagsModel ?? null;
    }
  }
  return SystemDocument;
}

const TextEditor$b = foundry.applications.ux.TextEditor.implementation;

/**
 * @import { D20RollConfiguration } from "../dice/_types.mjs";
 * @import { ItemContentsTransformer, SpellcastingDescription, SpellScrollConfiguration } from "./_types.mjs";
 * @import {
 *   ActivityDialogConfiguration, ActivityMessageConfiguration, ActivityUsageResults, ActivityUseConfiguration
 * } from "./activity/_types.mjs";
 */

/**
 * Override and extend the basic Item implementation.
 */
class Item5e extends SystemDocumentMixin(Item) {

  /** @override */
  static DEFAULT_ICON = "systems/dnd5e/icons/svg/documents/item.svg";

  /* -------------------------------------------- */

  /**
   * Caches an item linked to this one, such as a subclass associated with a class.
   * @type {Item5e}
   * @private
   */
  _classLink;

  /* -------------------------------------------- */

  /**
   * An object that tracks which tracks the changes to the data model which were applied by active effects
   * @type {object}
   */
  overrides = this.overrides ?? {};

  /* -------------------------------------------- */

  /**
   * Types that can be selected within the compendium browser.
   * @param {object} [options={}]
   * @param {Set<string>} [options.chosen]  Types that have been selected.
   * @returns {SelectChoices}
   */
  static compendiumBrowserTypes({ chosen=new Set() }={}) {
    const [generalTypes, physicalTypes] = Item.TYPES.reduce(([g, p], t) => {
      if ( ![CONST.BASE_DOCUMENT_TYPE, "backpack"].includes(t) ) {
        if ( "inventorySection" in (CONFIG.Item.dataModels[t] ?? {}) ) p.push(t);
        else g.push(t);
      }
      return [g, p];
    }, [[], []]);

    const makeChoices = (types, categoryChosen) => types.reduce((obj, type) => {
      obj[type] = {
        label: CONFIG.Item.typeLabels[type],
        chosen: chosen.has(type) || categoryChosen
      };
      return obj;
    }, {});
    const choices = makeChoices(generalTypes);
    choices.physical = {
      label: game.i18n.localize("DND5E.ITEM.Category.Physical"),
      children: makeChoices(physicalTypes, chosen.has("physical"))
    };
    return new SelectChoices(choices);
  }

  /* -------------------------------------------- */
  /*  Data Migration                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _initializeSource(data, options={}) {
    if ( data instanceof foundry.abstract.DataModel ) data = data.toObject();

    // Migrate backpack -> container.
    if ( data.type === "backpack" ) {
      data.type = "container";
      foundry.utils.setProperty(data, "flags.dnd5e.persistSourceMigration", true);
    }

    /**
     * A hook event that fires before source data is initialized for an Item in a compendium.
     * @function dnd5e.initializeItemSource
     * @memberof hookEvents
     * @param {Item5e} item     Item for which the data is being initialized.
     * @param {object} data     Source data being initialized.
     * @param {object} options  Additional data initialization options.
     */
    if ( options.pack || options.parent?.pack ) Hooks.callAll("dnd5e.initializeItemSource", this, data, options);

    if ( data.type === "spell" ) {
      return super._initializeSource(new Proxy(data, {
        set(target, prop, value, receiver) {
          if ( prop === "preparation" ) console.trace(value);
          return Reflect.set(target, prop, value, receiver);
        },

        defineProperty(target, prop, attributes) {
          if ( prop === "preparation" ) console.trace(attributes);
          return Reflect.defineProperty(target, prop, attributes);
        }
      }), options);
    }

    return super._initializeSource(data, options);
  }

  /* -------------------------------------------- */
  /*  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;
  }

  /* -------------------------------------------- */

  /**
   * Should deletion of this item be allowed? Doesn't prevent programatic deletion, but affects UI controls.
   * @type {boolean}
   */
  get canDelete() {
    return !this.flags.dnd5e?.cachedFor;
  }

  /* -------------------------------------------- */

  /**
   * Should duplication of this item be allowed? Doesn't prevent programatic duplication, but affects UI controls.
   * @type {boolean}
   */
  get canDuplicate() {
    return !this.system.metadata?.singleton && !["class", "subclass"].includes(this.type)
      && !this.flags.dnd5e?.cachedFor;
  }

  /* --------------------------------------------- */

  /**
   * The item that contains this item, if it is in a container. Returns a promise if the item is located
   * in a compendium pack.
   * @type {Item5e|Promise<Item5e>|void}
   */
  get container() {
    if ( !this.system.container ) return;
    if ( this.isEmbedded ) return this.actor.items.get(this.system.container);
    if ( this.pack ) return game.packs.get(this.pack).getDocument(this.system.container);
    return game.items.get(this.system.container);
  }

  /* -------------------------------------------- */

  /**
   * 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;
  }

  /* -------------------------------------------- */

  /**
   * Active effect that granted this item as a rider.
   * @type {ActiveEffect5e|null}
   */
  get dependentOrigin() {
    return fromUuidSync(this.flags.dnd5e?.dependentOn, { relative: this, strict: false }) ?? null;
  }

  /* -------------------------------------------- */

  /**
   * Does this item support advancement and have advancements defined?
   * @type {boolean}
   */
  get hasAdvancement() {
    return !!this.system.advancement?.length;
  }

  /* -------------------------------------------- */

  /**
   * 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;
  }

  /* -------------------------------------------- */

  /**
   * 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;
  }

  /* -------------------------------------------- */

  /**
   * Return an item's identifier.
   * @type {string}
   */
  get identifier() {
    if ( this.system.identifier ) return this.system.identifier;
    return formatIdentifier(this.name);
  }

  /* --------------------------------------------- */

  /**
   * Is this Item an activatable item?
   * @type {boolean}
   */
  get isActive() {
    return this.system.isActive ?? false;
  }

  /* -------------------------------------------- */

  /**
   * 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 || !this.parent.system.details?.originalClass ) 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;
  }

  /* -------------------------------------------- */

  /**
   * Is the item rechargeable?
   * @type {boolean}
   */
  get hasRecharge() {
    return this.hasLimitedUses && (this.system.uses?.recovery[0]?.period === "recharge");
  }

  /* --------------------------------------------- */

  /**
   * Is the item on recharge cooldown?
   * @type {boolean}
   */
  get isOnCooldown() {
    return this.hasRecharge && (this.system.uses.value < 1);
  }

  /* --------------------------------------------- */

  /**
   * Does this item require concentration?
   * @type {boolean}
   */
  get requiresConcentration() {
    if ( this.system.validProperties.has("concentration") && this.system.properties.has("concentration") ) return true;
    return this.system.activities?.contents[0]?.duration.concentration ?? 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 {Record<string, ScaleValueType>}
   */
  get scaleValues() {
    if ( !this.advancement.byType.ScaleValue ) return {};
    const item = ["class", "subclass"].includes(this.advancementRootItem?.type) ? this.advancementRootItem : this;
    const level = item.type === "class" ? item.system.levels : item.type === "subclass" ? item.class?.system.levels
      : item.system.advancementLevel ?? this.parent?.system.details.level ?? 0;
    return this.advancement.byType.ScaleValue.reduce((obj, advancement) => {
      obj[advancement.identifier] = advancement.valueForLevel(level);
      return obj;
    }, {});
  }

  /* -------------------------------------------- */

  /**
   * Scaling increase for this item based on flag or item-type specific details.
   * @type {number}
   */
  get scalingIncrease() {
    return this.system?.scalingIncrease ?? this.getFlag("dnd5e", "scaling") ?? 0;
  }

  /* -------------------------------------------- */

  /**
   * 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
    );
    return finalSC ?? null;
  }

  /* -------------------------------------------- */
  /*  Active Effects                              */
  /* -------------------------------------------- */

  /**
   * Get all ActiveEffects that may apply to this Item.
   * @yields {ActiveEffect5e}
   * @returns {Generator<ActiveEffect5e, void, void>}
   */
  *allApplicableEffects() {
    for ( const effect of this.effects ) {
      if ( effect.isAppliedEnchantment ) yield effect;
    }
  }

  /* -------------------------------------------- */

  /**
   * Apply any transformation to the Item data which are caused by enchantment Effects.
   */
  applyActiveEffects() {
    const overrides = {};

    // Organize non-disabled effects by their application priority
    const changes = [];
    for ( const effect of this.allApplicableEffects() ) {
      if ( !effect.active ) continue;
      changes.push(...effect.changes.map(change => {
        const c = foundry.utils.deepClone(change);
        c.effect = effect;
        c.priority ??= c.mode * 10;
        return c;
      }));
    }
    changes.sort((a, b) => a.priority - b.priority);

    // Apply all changes
    for ( const change of changes ) {
      if ( !change.key ) continue;
      const changes = change.effect.apply(this, change);
      Object.assign(overrides, changes);
    }

    // Expand the set of final overrides
    this.overrides = foundry.utils.expandObject(overrides);
  }

  /* -------------------------------------------- */

  /**
   * Should this item's active effects be suppressed.
   * @type {boolean}
   */
  get areEffectsSuppressed() {
    const requireEquipped = (this.type !== "consumable")
      || ["rod", "trinket", "wand"].includes(this.system.type.value);
    if ( requireEquipped && (this.system.equipped === false) ) return true;
    return !this.system.attuned && (this.system.attunement === "required");
  }

  /* -------------------------------------------- */
  /*  Data Initialization                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  clone(data={}, options={}) {
    if ( options.save ) return super.clone(data, options);
    if ( this.parent ) this.parent._embeddedPreparation = true;
    const item = super.clone(data, options);
    if ( item.parent ) {
      delete item.parent._embeddedPreparation;
      item.prepareFinalAttributes();
    }
    return item;
  }

  /* -------------------------------------------- */
  /*  Data Migration                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static migrateData(source) {
    source = super.migrateData(source);
    ActivitiesTemplate.initializeActivities(source);
    if ( source.type === "class" ) ClassData._migrateTraitAdvancement(source);
    else if ( source.type === "container" ) ContainerData._migrateWeightlessData(source);
    else if ( source.type === "equipment" ) EquipmentData._migrateStealth(source);
    else if ( source.type === "spell" ) SpellData._migrateComponentData(source);
    return source;
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareBaseData() {
    this.labels = {};
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareEmbeddedDocuments() {
    super.prepareEmbeddedDocuments();
    for ( const activity of this.system.activities ?? [] ) activity.prepareData();
    for ( const advancement of this.system.advancement ?? [] ) {
      if ( !(advancement instanceof Advancement) ) continue;
      advancement.prepareData();
    }
    if ( !this.actor || this.actor._embeddedPreparation ) this.applyActiveEffects();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareDerivedData() {
    this.labels ??= {};
    super.prepareDerivedData();

    // Clear out linked item cache
    this._classLink = undefined;

    // Advancement
    this._prepareAdvancement();

    // Item Properties
    if ( this.system.properties ) {
      this.labels.properties = this.system.properties.reduce((acc, prop) => {
        if ( (prop === "concentration") && !this.requiresConcentration ) return acc;
        acc.push({
          abbr: prop,
          label: CONFIG.DND5E.itemProperties[prop]?.label,
          icon: CONFIG.DND5E.itemProperties[prop]?.icon
        });
        return acc;
      }, []);
    }

    // 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 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
        || ((advancement.levels.length === 1) && (advancement.levels[0] < minAdvancementLevel)) ) {
        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), game.i18n.lang);
    }));
  }

  /* -------------------------------------------- */

  /**
   * Determine an item's proficiency level based on its parent actor's proficiencies.
   * @protected
   */
  _prepareProficiency() {
    if ( !["spell", "weapon", "equipment", "tool", "feat", "consumable"].includes(this.type) ) return;
    if ( !this.actor?.system.attributes?.prof ) {
      this.system.prof = new Proficiency(0, 0);
      return;
    }

    this.system.prof = new Proficiency(this.actor.system.attributes.prof, this.system.proficiencyMultiplier ?? 0);
  }

  /* -------------------------------------------- */

  /**
   * Compute item attributes which might depend on prepared actor data. If this item is embedded this method will
   * be called after the actor's data is prepared.
   * Otherwise, it will be called at the end of `Item5e#prepareDerivedData`.
   */
  prepareFinalAttributes() {
    this._prepareProficiency();
    this.system.prepareFinalData?.();
    this._prepareLabels();
  }

  /* -------------------------------------------- */

  /**
   * Prepare top-level summary labels based on configured activities.
   * @protected
   */
  _prepareLabels() {
    const activations = this.labels.activations = [];
    const attacks = this.labels.attacks = [];
    const damages = this.labels.damages = [];
    if ( !this.system.activities?.size ) return;
    const existingDamageLabels = new Set();
    let firstDamage = true;
    for ( const activity of this.system.activities ) {
      if ( !("activation" in activity) || !activity.canUse ) continue;
      const activationLabels = activity.activationLabels;
      if ( activationLabels ) activations.push({
        ...activationLabels,
        concentrationDuration: activity.labels.concentrationDuration,
        ritualActivation: activity.labels.ritualActivation
      });
      if ( activity.type === "attack" ) {
        const { toHit, modifier } = activity.labels;
        attacks.push({ toHit, modifier });
      }
      for ( const damage of activity.labels?.damage ?? [] ) {
        if ( existingDamageLabels.has(damage.label) ) continue;
        existingDamageLabels.add(damage.label);
        damages.push({ ...damage, firstDamage });
      }
      if ( activity.labels?.damage?.length ) firstDamage = false;
    }
    if ( activations.length ) {
      Object.assign(this.labels, activations[0]);
      delete activations[0].concentrationDuration;
      delete activations[0].ritualActivation;
    }
    if ( attacks.length ) Object.assign(this.labels, attacks[0]);
  }

  /* -------------------------------------------- */

  /**
   * Render a rich tooltip for this item.
   * @param {EnrichmentOptions} [enrichmentOptions={}]  Options for text enrichment.
   * @returns {Promise<{content: string, classes: string[]}>|null}
   */
  richTooltip(enrichmentOptions={}) {
    return this.system.richTooltip?.() ?? null;
  }

  /* -------------------------------------------- */

  /**
   * Trigger an Item usage, optionally creating a chat message with followup actions.
   * @param {ActivityUseConfiguration} config       Configuration info for the activation.
   * @param {boolean} [config.chooseActivity=false] Force the activity selection prompt unless the fast-forward modifier
   *                                                is held.
   * @param {ActivityDialogConfiguration} dialog    Configuration info for the usage dialog.
   * @param {ActivityMessageConfiguration} message  Configuration info for the created chat message.
   * @returns {Promise<ActivityUsageResults|ChatMessage|object|void>}  Returns the usage results for the triggered
   *                                                                   activity, or the chat message if the Item had no
   *                                                                   activities and was posted directly to chat.
   */
  async use(config={}, dialog={}, message={}) {
    if ( this.pack ) return;

    let event = config.event;
    const activities = this.system.activities?.filter(a => a.canUse);
    if ( activities?.length ) {
      const { chooseActivity, ...activityConfig } = config;
      let usageConfig = activityConfig;
      let dialogConfig = dialog;
      let messageConfig = message;
      let activity = activities[0];
      if ( ((activities.length > 1) || chooseActivity) && !event?.shiftKey ) {
        activity = await ActivityChoiceDialog.create(this);
      }
      if ( !activity ) return;
      return activity.use(usageConfig, dialogConfig, messageConfig);
    }
    if ( this.actor ) return this.displayCard(message);
  }

  /* -------------------------------------------- */

  /**
   * Display the chat card for an Item as a Chat Message
   * @param {Partial<ActivityMessageConfiguration>} [message]  Configuration info for the created chat message.
   * @returns {Promise<ChatMessage5e|object|void>}
   */
  async displayCard(message={}) {
    const context = {
      actor: this.actor,
      config: CONFIG.DND5E,
      tokenId: this.actor.token?.uuid || null,
      item: this,
      data: await this.system.getCardData(),
      isSpell: this.type === "spell"
    };

    const messageConfig = foundry.utils.mergeObject({
      create: message?.createMessage ?? true,
      data: {
        content: await foundry.applications.handlebars.renderTemplate(
          "systems/dnd5e/templates/chat/item-card.hbs", context
        ),
        flags: {
          "dnd5e.item": { id: this.id, uuid: this.uuid, type: this.type }
        },
        speaker: ChatMessage.getSpeaker({ actor: this.actor, token: this.actor.token }),
        title: this.name
      },
      rollMode: game.settings.get("core", "rollMode")
    }, message);

    // Merge in the flags from options
    if ( foundry.utils.getType(message.flags) === "Object" ) {
      foundry.utils.mergeObject(messageConfig.data.flags, message.flags);
      delete messageConfig.flags;
    }

    /**
     * A hook event that fires before an item chat card is created without using an activity.
     * @function dnd5e.preDisplayCard
     * @memberof hookEvents
     * @param {Item5e} item                           Item for which the card will be created.
     * @param {ActivityMessageConfiguration} message  Configuration for the roll message.
     * @returns {boolean}                             Return `false` to prevent the card from being displayed.
     */
    if ( Hooks.call("dnd5e.preDisplayCard", this, messageConfig) === false ) return;
    if ( Hooks.call("dnd5e.preDisplayCardV2", this, messageConfig) === false ) return;

    ChatMessage.applyRollMode(messageConfig.data, messageConfig.rollMode);
    const card = messageConfig.create === false ? messageConfig.data : await ChatMessage.create(messageConfig.data);

    /**
     * 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 {ChatMessage5e|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 context = {};
    let { identified, unidentified, description } = this.system;

    // Rich text description
    const isIdentified = identified !== false;
    description = game.user.isGM || isIdentified ? description.value : unidentified?.description;
    context.description = await TextEditor$b.enrichHTML(description ?? "", {
      relativeTo: this,
      rollData: this.getRollData(),
      ...htmlOptions
    });

    // Type specific properties
    context.properties = [
      ...this.system.chatProperties ?? [],
      ...this.system.equippableItemCardProperties ?? [],
      ...Object.values(this.labels.activations?.[0] ?? {})
    ].filter(p => p);

    return context;
  }

  /* -------------------------------------------- */
  /*  Item Rolls - Attack, Damage, Saves, Checks  */
  /* -------------------------------------------- */

  /**
   * 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({
      ability: this.system.ability,
      bonus: this.system.bonus,
      prof: this.system.prof,
      item: this,
      tool: this.system.type.baseItem,
      ...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 }={}) {
    let data;
    if ( this.system.getRollData ) data = this.system.getRollData({ deterministic });
    else data = { ...(this.actor?.getRollData({ deterministic }) ?? {}), item: { ...this.system } };
    if ( data?.item ) {
      data.item.flags = { ...this.flags };
      data.item.name = this.name;
    }
    data.scaling = new Scaling(this.scalingIncrease);
    return data;
  }

  /* -------------------------------------------- */
  /*  Chat Message Helpers                        */
  /* -------------------------------------------- */

  /**
   * Apply listeners to chat messages.
   * @param {HTMLElement} html  Rendered chat message.
   */
  static chatListeners(html) {
    html.addEventListener("click", event => {
      if ( event.target.closest("[data-context-menu]") ) ContextMenu5e.triggerEvent(event);
      else if ( event.target.closest(".collapsible") ) this._onChatCardToggleContent(event);
    });
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling the visibility of chat card content when the name is clicked
   * @param {Event} event   The originating click event
   * @private
   */
  static _onChatCardToggleContent(event) {
    const header = event.target.closest(".collapsible");
    if ( !event.target.closest(".collapsible-content.card-content") ) {
      event.preventDefault();
      header.classList.toggle("collapsed");

      // Clear the height from the chat popout container so that it appropriately resizes.
      const popout = header.closest(".chat-popout");
      if ( popout ) popout.style.height = "";
    }
  }

  /* -------------------------------------------- */
  /*  Activities & Advancements                   */
  /* -------------------------------------------- */

  /**
   * Create a new activity of the specified type.
   * @param {string} type                          Type of activity to create.
   * @param {object} [data]                        Data to use when creating the activity.
   * @param {object} [options={}]
   * @param {boolean} [options.renderSheet=true]  Should the sheet be rendered after creation?
   * @returns {Promise<ActivitySheet|null>}
   */
  async createActivity(type, data={}, { renderSheet=true }={}) {
    if ( !this.system.activities ) return;

    const config = CONFIG.DND5E.activityTypes[type];
    if ( !config ) throw new Error(`${type} not found in CONFIG.DND5E.activityTypes`);
    const cls = config.documentClass;

    const createData = foundry.utils.deepClone(data);
    const activity = new cls({ type, ...data }, { parent: this });
    if ( activity._preCreate(createData) === false ) return;

    await this.update({ [`system.activities.${activity.id}`]: activity.toObject() });
    const created = this.system.activities.get(activity.id);
    if ( renderSheet ) return created.sheet?.render({ force: true });
  }

  /* -------------------------------------------- */

  /**
   * Update an activity belonging to this item.
   * @param {string} id          ID of the activity to update.
   * @param {object} updates     Updates to apply to this activity.
   * @returns {Promise<Item5e>}  This item with the changes applied.
   */
  updateActivity(id, updates) {
    if ( !this.system.activities ) return this;
    if ( !this.system.activities.has(id) ) throw new Error(`Activity of ID ${id} could not be found to update`);
    return this.update({ [`system.activities.${id}`]: updates });
  }

  /* -------------------------------------------- */

  /**
   * Remove an activity from this item.
   * @param {string} id          ID of the activity to remove.
   * @returns {Promise<Item5e>}  This item with the changes applied.
   */
  async deleteActivity(id) {
    const activity = this.system.activities?.get(id);
    if ( !activity ) return this;
    await Promise.allSettled(activity.constructor._apps.get(activity.uuid)?.map(a => a.close()) ?? []);
    return this.update({ [`system.activities.-=${id}`]: null });
  }

  /* -------------------------------------------- */

  /**
   * 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.renderSheet=true]   Should the advancement's sheet be rendered after creation?
   * @param {boolean} [options.showConfig]         Deprecated, use `renderSheet`.
   * @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={}, { renderSheet=true, showConfig=renderSheet, source=false }={}) {
    if ( !this.system.advancement ) return this;

    const config = CONFIG.DND5E.advancementTypes[type];
    if ( !config ) throw new Error(`${type} not found in CONFIG.DND5E.advancementTypes`);
    const cls = config.documentClass;

    if ( !config.validItemTypes.has(this.type) || !cls.availableForItem(this) ) {
      throw new Error(`${type} advancement cannot be added to ${this.name}`);
    }

    const createData = foundry.utils.deepClone(data);
    const advancement = new cls(data, {parent: this});
    if ( advancement._preCreate(createData) === false ) return;

    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.advancement.byId[advancement.id]?.sheet?.render(true);
      return this;
    });
  }

  /* -------------------------------------------- */

  /**
   * 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];
    if ( source ) {
      advancement.updateSource(updates);
      advancement.render();
      return this;
    }

    const advancementCollection = this.toObject().system.advancement;
    const clone = new advancement.constructor(advancementCollection[idx], { parent: advancement.parent });
    clone.updateSource(updates);
    advancementCollection[idx] = clone.toObject();
    return this.update({"system.advancement": advancementCollection}).then(r => {
      advancement.render(false, { height: "auto" });
      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) {
    let doc;
    switch ( embeddedName ) {
      case "Activity": doc = this.system.activities?.get(id); break;
      case "Advancement": doc = this.advancement.byId[id]; break;
      default: return super.getEmbeddedDocument(embeddedName, id, options);
    }
    if ( options?.strict && (advancement === undefined) ) {
      throw new Error(`The key ${id} does not exist in the ${embeddedName} Collection`);
    }
    return doc;
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preCreate(data, options, user) {
    if ( (await super._preCreate(data, options, user)) === false ) return false;

    const isPhysical = this.system.constructor._schemaTemplates?.includes(PhysicalItemTemplate);
    if ( this.parent?.system?.isGroup && !isPhysical ) return false;

    // Create identifier based on name
    if ( this.system.hasOwnProperty("identifier") && !data.system?.identifier ) {
      this.updateSource({ "system.identifier": this.identifier });
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onCreate(data, options, userId) {
    super._onCreate(data, options, userId);
    await this.system.onCreateActivities?.(data, options, userId);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preUpdate(changed, options, user) {
    if ( (await super._preUpdate(changed, options, user)) === false ) return false;
    await this.system.preUpdateActivities?.(changed, options, user);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onUpdate(changed, options, userId) {
    super._onUpdate(changed, options, userId);
    await this.system.onUpdateActivities?.(changed, options, userId);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onDelete(options, userId) {
    super._onDelete(options, userId);
    await this.system.onDeleteActivities?.(options, userId);
    if ( game.user.isActiveGM ) this.effects.forEach(e => e.getDependents().forEach(e => e.delete()));
    if ( userId !== game.user.id ) return;
    this.parent?.endConcentration?.(this);
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async deleteDialog(options={}) {
    // If item has advancement, handle it separately
    if ( this.actor?.system.metadata?.supportsAdvancement && !game.settings.get("dnd5e", "disableAdvancements") ) {
      const manager = AdvancementManager.forDeletedItem(this.actor, this.id);
      if ( manager.steps.length ) {
        try {
          const shouldRemoveAdvancements = await AdvancementConfirmationDialog.forDelete(this);
          if ( shouldRemoveAdvancements ) return manager.render(true);
          return this.delete({ shouldRemoveAdvancements });
        } catch(err) {
          return;
        }
      }
    }

    // Display custom delete dialog when deleting a container with contents
    const count = await this.system.contentsCount;
    if ( count ) {
      return Dialog.confirm({
        title: `${game.i18n.format("DOCUMENT.Delete", {type: game.i18n.localize("DND5E.Container")})}: ${this.name}`,
        content: `<h4>${game.i18n.localize("AreYouSure")}</h4>
          <p>${game.i18n.format("DND5E.ContainerDeleteMessage", {count})}</p>
          <label>
            <input type="checkbox" name="deleteContents">
            ${game.i18n.localize("DND5E.ContainerDeleteContents")}
          </label>`,
        yes: html => {
          const deleteContents = html.querySelector('[name="deleteContents"]').checked;
          this.delete({ deleteContents });
        },
        options: { ...options, jQuery: false }
      });
    }

    return super.deleteDialog(options);
  }

  /* -------------------------------------------- */
  /*  Factory Methods                             */
  /* -------------------------------------------- */

  /**
   * Add additional system-specific sidebar directory context menu options for Item documents.
   * @param {ItemDirectory} app      The sidebar application.
   * @param {object[]} entryOptions  The default array of context menu options.
   */
  static addDirectoryContextOptions(app, entryOptions) {
    entryOptions.push({
      name: "DND5E.Scroll.CreateScroll",
      icon: '<i class="fa-solid fa-scroll"></i>',
      callback: async li => {
        let spell = game.items.get(li.dataset.entryId);
        if ( app.collection instanceof foundry.documents.collections.CompendiumCollection ) {
          spell = await app.collection.getDocument(li.dataset.entryId);
        }
        const scroll = await Item5e.createScrollFromSpell(spell);
        if ( scroll ) Item5e.create(scroll);
      },
      condition: li => {
        let item = game.items.get(li.dataset.documentId ?? li.dataset.entryId);
        if ( app.collection instanceof foundry.documents.collections.CompendiumCollection ) {
          item = app.collection.index.get(li.dataset.entryId);
        }
        return (item.type === "spell") && game.user.hasPermission("ITEM_CREATE");
      },
      group: "system"
    });
  }

  /* -------------------------------------------- */

  /**
   * Prepare creation data for the provided items and any items contained within them. The data created by this method
   * can be passed to `createDocuments` with `keepId` always set to true to maintain links to container contents.
   * @param {Item5e[]} items                     Items to create.
   * @param {object} [context={}]                Context for the item's creation.
   * @param {Item5e} [context.container]         Container in which to create the item.
   * @param {boolean} [context.keepId=false]     Should IDs be maintained?
   * @param {ItemContentsTransformer} [context.transformAll]    Method called on provided items and their contents.
   * @param {ItemContentsTransformer} [context.transformFirst]  Method called only on provided items.
   * @returns {Promise<object[]>}                Data for items to be created.
   */
  static async createWithContents(items, { container, keepId=false, transformAll, transformFirst }={}) {
    let depth = 0;
    if ( container ) {
      depth = 1 + (await container.system.allContainers()).length;
      if ( depth > PhysicalItemTemplate.MAX_DEPTH ) {
        ui.notifications.warn(game.i18n.format("DND5E.ContainerMaxDepth", { depth: PhysicalItemTemplate.MAX_DEPTH }));
        return;
      }
    }

    const createItemData = async (item, containerId, depth) => {
      const o = { container: containerId, depth };
      let newItemData = transformAll ? await transformAll(item, o) : item;
      if ( transformFirst && (depth === 0) ) newItemData = await transformFirst(newItemData, o);
      if ( !newItemData ) return;
      if ( newItemData instanceof Item ) newItemData = game.items.fromCompendium(newItemData, {
        clearSort: false, keepId: true, clearOwnership: false
      });
      foundry.utils.mergeObject(newItemData, {"system.container": containerId} );
      if ( !keepId ) newItemData._id = foundry.utils.randomID();

      created.push(newItemData);

      const contents = await item.system.contents;
      if ( contents && (depth < PhysicalItemTemplate.MAX_DEPTH) ) {
        for ( const doc of contents ) await createItemData(doc, newItemData._id, depth + 1);
      }
    };

    const created = [];
    for ( const item of items ) await createItemData(item, container?.id, depth);
    return created;
  }

  /* -------------------------------------------- */

  /**
   * Create a consumable spell scroll Item from a spell Item.
   * @param {Item5e|object} spell                   The spell or item data to be made into a scroll.
   * @param {object} [options]                      Additional options that modify the created scroll.
   * @param {SpellScrollConfiguration} [config={}]  Configuration options for scroll creation.
   * @returns {Promise<Item5e|void>}                The created scroll consumable item.
   */
  static async createScrollFromSpell(spell, options={}, config={}) {
    if ( spell.pack ) return this.createScrollFromCompendiumSpell(spell.uuid, config);

    const values = {};
    if ( (spell instanceof Item5e) && spell.isOwned && (game.settings.get("dnd5e", "rulesVersion") === "modern") ) {
      const spellcastingClass = spell.actor.spellcastingClasses?.[spell.system.sourceClass];
      if ( spellcastingClass ) {
        values.bonus = spellcastingClass.spellcasting.attack;
        values.dc = spellcastingClass.spellcasting.save;
      } else {
        values.bonus = spell.actor.system.attributes?.spell?.mod;
        values.dc = spell.actor.system.attributes?.spell?.dc;
      }
    }

    config = foundry.utils.mergeObject({
      explanation: game.user.getFlag("dnd5e", "creation.scrollExplanation") ?? "reference",
      level: spell.system.level,
      values
    }, config);

    if ( config.dialog !== false ) {
      const result = await CreateScrollDialog.create(spell, config);
      if ( !result ) return;
      foundry.utils.mergeObject(config, result);
      await game.user.setFlag("dnd5e", "creation.scrollExplanation", config.explanation);
    }

    // Get spell data
    const itemData = (spell instanceof Item5e) ? spell.toObject() : spell;
    const flags = itemData.flags ?? {};
    if ( Number.isNumeric(config.level) ) {
      flags.dnd5e ??= {};
      flags.dnd5e.scaling = Math.max(0, config.level - spell.system.level);
      flags.dnd5e.spellLevel = {
        value: config.level,
        base: spell.system.level
      };
      itemData.system.level = config.level;
    }

    /**
     * A hook event that fires before the item data for a scroll is created.
     * @function dnd5e.preCreateScrollFromSpell
     * @memberof hookEvents
     * @param {object} itemData                  The initial item data of the spell to convert to a scroll.
     * @param {object} options                   Additional options that modify the created scroll.
     * @param {SpellScrollConfiguration} config  Configuration options for scroll creation.
     * @returns {boolean}                        Explicitly return false to prevent the scroll to be created.
     */
    if ( Hooks.call("dnd5e.preCreateScrollFromSpell", itemData, options, config) === false ) return;

    let { activities, level, properties, source } = itemData.system;

    // Get scroll data
    let scrollUuid;
    const id = CONFIG.DND5E.spellScrollIds[level];
    if ( foundry.data.validators.isValidId(id) ) {
      scrollUuid = game.packs.get(CONFIG.DND5E.sourcePacks.ITEMS).index.get(id).uuid;
    } else {
      scrollUuid = id;
    }
    const scrollItem = await fromUuid(scrollUuid);
    const scrollData = game.items.fromCompendium(scrollItem);

    // Create a composite description from the scroll description and the spell details
    const desc = this._createScrollDescription(scrollItem, itemData, null, config);

    for ( const level of Array.fromRange(itemData.system.level + 1).reverse() ) {
      const values = CONFIG.DND5E.spellScrollValues[level];
      if ( values ) {
        config.values.bonus ??= values.bonus;
        config.values.dc ??= values.dc;
        break;
      }
    }

    // Apply inferred spell activation, duration, range, and target data to activities
    for ( const activity of Object.values(activities) ) {
      for ( const key of ["activation", "duration", "range", "target"] ) {
        if ( activity[key]?.override !== false ) continue;
        activity[key].override = true;
        foundry.utils.mergeObject(activity[key], itemData.system[key]);
      }
      activity.consumption.targets.push({ type: "itemUses", target: "", value: "1" });
      if ( activity.type === "attack" ) {
        activity.attack.flat = true;
        activity.attack.bonus = values.bonus;
      } else if ( activity.type === "save" ) {
        activity.save.dc.calculation = "";
        activity.save.dc.formula = values.dc;
      }
    }

    // Create the spell scroll data
    const spellScrollData = foundry.utils.mergeObject(scrollData, {
      name: `${game.i18n.localize("DND5E.SpellScroll")}: ${itemData.name}`,
      effects: itemData.effects ?? [],
      flags,
      system: {
        activities, description: { value: desc.trim() }, properties, source
      }
    });
    foundry.utils.mergeObject(spellScrollData, options);
    spellScrollData.system.properties = [
      "mgc",
      ...scrollData.system.properties,
      ...properties ?? [],
      ...options.system?.properties ?? []
    ];

    /**
     * A hook event that fires after the item data for a scroll is created but before the item is returned.
     * @function dnd5e.createScrollFromSpell
     * @memberof hookEvents
     * @param {Item5e|object} spell              The spell or item data to be made into a scroll.
     * @param {object} spellScrollData           The final item data used to make the scroll.
     * @param {SpellScrollConfiguration} config  Configuration options for scroll creation.
     */
    Hooks.callAll("dnd5e.createScrollFromSpell", spell, spellScrollData, config);

    return new this(spellScrollData);
  }

  /* -------------------------------------------- */

  /**
   * Create a consumable spell scroll Item from a spell Item.
   * @param {string} uuid                           UUID of the spell to add to the scroll.
   * @param {SpellScrollConfiguration} [config={}]  Configuration options for scroll creation.
   * @returns {Promise<Item5e|void>}                The created scroll consumable item.
   */
  static async createScrollFromCompendiumSpell(uuid, config={}) {
    const spell = await fromUuid(uuid);
    if ( !spell ) return;

    const values = {};

    config = foundry.utils.mergeObject({
      explanation: game.user.getFlag("dnd5e", "creation.scrollExplanation") ?? "reference",
      level: spell.system.level,
      values
    }, config);

    if ( config.dialog !== false ) {
      const result = await CreateScrollDialog.create(spell, config);
      if ( !result ) return;
      foundry.utils.mergeObject(config, result);
      await game.user.setFlag("dnd5e", "creation.scrollExplanation", config.explanation);
    }

    /**
     * A hook event that fires before the item data for a scroll is created for a compendium spell.
     * @function dnd5e.preCreateScrollFromCompendiumSpell
     * @memberof hookEvents
     * @param {Item5e} spell                     Spell to add to the scroll.
     * @param {SpellScrollConfiguration} config  Configuration options for scroll creation.
     * @returns {boolean}                        Explicitly return `false` to prevent the scroll to be created.
     */
    if ( Hooks.call("dnd5e.preCreateScrollFromCompendiumSpell", spell, config) === false ) return;

    // Get scroll data
    let scrollUuid;
    const id = CONFIG.DND5E.spellScrollIds[spell.system.level];
    if ( foundry.data.validators.isValidId(id) ) {
      scrollUuid = game.packs.get(CONFIG.DND5E.sourcePacks.ITEMS).index.get(id).uuid;
    } else {
      scrollUuid = id;
    }
    const scrollItem = await fromUuid(scrollUuid);
    const scrollData = game.items.fromCompendium(scrollItem);

    for ( const level of Array.fromRange(spell.system.level + 1).reverse() ) {
      const values = CONFIG.DND5E.spellScrollValues[level];
      if ( values ) {
        config.values.bonus ??= values.bonus;
        config.values.dc ??= values.dc;
        break;
      }
    }

    const activity = {
      _id: staticID("dnd5escrollspell"),
      type: "cast",
      consumption: {
        targets: [{ type: "itemUses", value: "1" }]
      },
      spell: {
        challenge: {
          attack: config.values.bonus,
          save: config.values.dc,
          override: true
        },
        level: config.level,
        uuid
      }
    };

    // Create the spell scroll data
    const spellScrollData = foundry.utils.mergeObject(scrollData, {
      name: `${game.i18n.localize("DND5E.SpellScroll")}: ${spell.name}`,
      system: {
        activities: { ...(scrollData.system.activities ?? {}), [activity._id]: activity },
        description: {
          value: this._createScrollDescription(scrollItem, spell, `<p>@Embed[${uuid} inline]</p>`, config).trim()
        }
      }
    });

    /**
     * A hook event that fires after the item data for a scroll is created but before the item is returned.
     * @function dnd5e.createScrollFromSpell
     * @memberof hookEvents
     * @param {Item5e} spell                     The spell or item data to be made into a scroll.
     * @param {object} spellScrollData           The final item data used to make the scroll.
     * @param {SpellScrollConfiguration} config  Configuration options for scroll creation.
     */
    Hooks.callAll("dnd5e.createScrollFromSpell", spell, spellScrollData, config);

    return new this(spellScrollData);
  }

  /* -------------------------------------------- */

  /**
   * Create the description for a spell scroll.
   * @param {Item5e} scroll                         Base spell scroll.
   * @param {Item5e|object} spell                   Spell being added to the scroll.
   * @param {string} [spellDescription]             Description from the spell being added.
   * @param {SpellScrollConfiguration} [config={}]  Configuration options for scroll creation.
   * @returns {string}
   * @protected
   */
  static _createScrollDescription(scroll, spell, spellDescription, config={}) {
    spellDescription ??= spell.system.description.value;
    const isConc = spell.system.properties[spell instanceof Item5e ? "has" : "includes"]("concentration");
    const level = spell.system.level;
    switch ( config.explanation ) {
      case "full":
        // Split the scroll description into an intro paragraph and the remaining details
        const scrollDescription = scroll.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);
        return [
          scrollDetails ? scrollIntro : null,
          `<h3>${spell.name} (${game.i18n.format("DND5E.LevelNumber", { level })})</h3>`,
          isConc ? `<p><em>${game.i18n.localize("DND5E.Scroll.RequiresConcentration")}</em></p>` : null,
          spellDescription,
          `<h3>${game.i18n.localize("DND5E.Scroll.Details")}</h3>`,
          scrollDetails || scrollIntro
        ].filterJoin("");
      case "reference":
        return [
          "<p><em>",
          CONFIG.DND5E.spellLevels[level] ?? level,
          " &Reference[Spell Scroll]",
          isConc ? `, ${game.i18n.localize("DND5E.Scroll.RequiresConcentration")}` : null,
          "</em></p>",
          spellDescription
        ].filterJoin("");
    }
    return spellDescription;
  }

  /* -------------------------------------------- */

  /** @override */
  static async createDialog(data={}, createOptions={}, dialogOptions={}) {
    CreateDocumentDialog.migrateOptions(createOptions, dialogOptions);
    return CreateDocumentDialog.prompt(this, data, createOptions, dialogOptions);
  }

  /* -------------------------------------------- */

  /**
   * Prepare default list of types if none are specified.
   * @param {Actor5e} parent  Parent document within which this Item will be created.
   * @returns {string[]}
   * @protected
   */
  static _createDialogTypes(parent) {
    return this.TYPES.filter(t => t !== "backpack");
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static getDefaultArtwork(itemData={}) {
    const { type } = itemData;
    const { img } = super.getDefaultArtwork(itemData);
    return { img: CONFIG.DND5E.defaultArtwork.Item[type] ?? img };
  }
}

/**
 * Activity for enchanting items.
 */
class EnchantActivity extends ActivityMixin(BaseEnchantActivityData) {
  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static LOCALIZATION_PREFIXES = [...super.LOCALIZATION_PREFIXES, "DND5E.ENCHANT"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static metadata = Object.freeze(
    foundry.utils.mergeObject(super.metadata, {
      type: "enchant",
      img: "systems/dnd5e/icons/svg/activity/enchant.svg",
      title: "DND5E.ENCHANT.Title",
      hint: "DND5E.ENCHANT.Hint",
      sheetClass: EnchantSheet,
      usage: {
        dialog: EnchantUsageDialog
      }
    }, { inplace: false })
  );

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * List of item types that are enchantable.
   * @type {Set<string>}
   */
  get enchantableTypes() {
    return Object.entries(CONFIG.Item.dataModels).reduce((set, [k, v]) => {
      if ( v.metadata?.enchantable ) set.add(k);
      return set;
    }, new Set());
  }

  /* -------------------------------------------- */

  /**
   * Existing enchantment applied by this activity on this activity's item.
   * @type {ActiveEffect5e}
   */
  get existingEnchantment() {
    return this.enchant.self
      ? this.item.effects.find(e => e.isAppliedEnchantment && (e.origin === this.uuid)) : undefined;
  }

  /* -------------------------------------------- */
  /*  Activation                                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _prepareUsageConfig(config) {
    config = super._prepareUsageConfig(config);
    const existingProfile = this.existingEnchantment?.flags.dnd5e?.enchantmentProfile;
    config.enchantmentProfile ??= this.item.effects.has(existingProfile) ? existingProfile
      : this.availableEnchantments[0]?._id;
    return config;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _requiresConfigurationDialog(config) {
    return super._requiresConfigurationDialog(config) || (this.availableEnchantments.length > 1);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _finalizeMessageConfig(usageConfig, messageConfig, results) {
    super._finalizeMessageConfig(usageConfig, messageConfig, results);

    // Store selected enchantment profile in message flag
    if ( usageConfig.enchantmentProfile ) foundry.utils.setProperty(
      messageConfig, "data.flags.dnd5e.use.enchantmentProfile", usageConfig.enchantmentProfile
    );

    // Don't display message if just auto-disabling existing enchantment
    if ( this.existingEnchantment?.flags.dnd5e?.enchantmentProfile === usageConfig.enchantmentProfile ) {
      messageConfig.create = false;
    }
  }

  /* -------------------------------------------- */

  /** @override */
  async _triggerSubsequentActions(config, results) {
    if ( !this.enchant.self ) return;

    // If enchantment from this activity already exists, remove it
    const existingEnchantment = this.existingEnchantment;
    if ( existingEnchantment ) await existingEnchantment?.delete({ chatMessageOrigin: results.message?.id });

    // If no existing enchantment, or existing enchantment profile doesn't match provided one, create new enchantment
    if ( !existingEnchantment || (existingEnchantment.flags.dnd5e?.enchantmentProfile !== config.enchantmentProfile) ) {
      const concentration = results.effects.find(e => e.statuses.has(CONFIG.specialStatusEffects.CONCENTRATING));
      this.applyEnchantment(config.enchantmentProfile, this.item, {
        chatMessage: results.message, concentration, strict: false
      });
    }
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Apply an enchantment to the provided item.
   * @param {string} profile                  ID of the enchantment profile to apply.
   * @param {Item5e} item                     Item to which to apply the enchantment.
   * @param {object} [options={}]
   * @param {ChatMessage5e} [options.chatMessage]     Chat message used to make the enchantment, if applicable.
   * @param {ActiveEffect5e} [options.concentration]  Concentration active effect to associate with this enchantment.
   * @param {boolean} [options.strict]        Display UI errors and prevent creation if enchantment isn't allowed.
   * @returns {Promise<ActiveEffect5e|null>}  Created enchantment effect if the process was successful.
   */
  async applyEnchantment(profile, item, { chatMessage, concentration, strict=true }={}) {
    const effect = this.item.effects.get(profile);
    if ( !effect ) return null;

    // Validate against the enchantment's restraints on the origin item
    if ( strict ) {
      const errors = this.canEnchant(item);
      if ( errors?.length ) {
        errors.forEach(err => ui.notifications.error(err.message, { console: false }));
        return null;
      }
    }

    // If concentration is required, ensure it is still being maintained & GM is present
    if ( !game.user.isGM && concentration && !concentration.isOwner ) {
      if ( strict ) {
        ui.notifications.error("DND5E.EffectApplyWarningConcentration", { console: false, localize: true });
        return null;
      } else {
        concentration = null;
      }
    }

    const flags = { enchantmentProfile: profile };
    if ( concentration ) flags.dependentOn = concentration.uuid;
    const enchantmentData = effect.clone({ origin: this.uuid, "flags.dnd5e": flags }).toObject();

    /**
     * Hook that fires before an enchantment is applied to an item.
     * @function dnd5e.preApplyEnchantment
     * @memberof hookEvents
     * @param {Item5e} item                Item to which the enchantment will be applied.
     * @param {object} enchantmentData     Data for the enchantment effect that will be created.
     * @param {object} options
     * @param {Activity} options.activity  Enchant activity applied the enchantment.
     * @returns {boolean}                  Explicitly return `false` to prevent enchantment from being applied.
     */
    if ( Hooks.call("dnd5e.preApplyEnchantment", item, enchantmentData, { activity: this }) === false ) return null;

    // For compendium items, create on actor
    if ( item.inCompendium ) {
      const actor = this.actor.isOwner ? this.actor : (getSceneTargets()[0]?.actor ?? game.user.character);
      if ( !actor ) {
        ui.notifications.warn("DND5E.ENCHANT.Warning.NoTargetActor", { localize: true });
        return null;
      }
      enchantmentData._id = foundry.utils.randomID();
      const toCreate = await Item5e.createWithContents([item], {
        transformAll: item => item.clone({ "flags.dnd5e.dependentOn": `.ActiveEffect.${enchantmentData._id}` })
      });
      [item] = await Item5e.createDocuments(toCreate, { keepId: true, parent: actor });
    }

    const enchantment = await ActiveEffect.create(enchantmentData, {
      parent: item, keepId: true, keepOrigin: true, chatMessageOrigin: chatMessage?.id
    });

    /**
     * Hook that fires after an enchantment has been applied to an item.
     * @function dnd5e.applyEnchantment
     * @memberof hookEvents
     * @param {Item5e} item                 Item to which the enchantment was be applied.
     * @param {ActiveEffect5e} enchantment  The enchantment effect that was be created.
     * @param {object} options
     * @param {Activity} options.activity   Enchant activity applied the enchantment.
     */
    Hooks.callAll("dnd5e.applyEnchantment", item, enchantment, { activity: this });

    return enchantment;
  }

  /* -------------------------------------------- */

  /**
   * Determine whether the provided item can be enchanted based on this enchantment's restrictions.
   * @param {Item5e} item  Item that might be enchanted.
   * @returns {true|EnchantmentError[]}
   */
  canEnchant(item) {
    const errors = [];

    if ( !this.restrictions.allowMagical && item.system.properties?.has("mgc")
      && ("quantity" in item.system) ) {
      errors.push(new EnchantmentError$1(game.i18n.localize("DND5E.ENCHANT.Warning.NoMagicalItems")));
    }

    if ( this.restrictions.type && (item.type !== this.restrictions.type) ) {
      errors.push(new EnchantmentError$1(game.i18n.format("DND5E.ENCHANT.Warning.WrongType", {
        incorrectType: game.i18n.localize(CONFIG.Item.typeLabels[item.type]),
        allowedType: game.i18n.localize(CONFIG.Item.typeLabels[this.restrictions.type])
      })));
    }

    if ( this.restrictions.categories.size && !this.restrictions.categories.has(item.system.type?.value) ) {
      const getLabel = key => {
        const config = CONFIG.Item.dataModels[this.restrictions.type]?.itemCategories[key];
        if ( !config ) return key;
        if ( foundry.utils.getType(config) === "string" ) return config;
        return config.label;
      };
      errors.push(new EnchantmentError$1(game.i18n.format(
        `DND5E.ENCHANT.Warning.${item.system.type?.value ? "WrongType" : "NoSubtype"}`,
        {
          allowedType: game.i18n.getListFormatter({ type: "disjunction" }).format(
            Array.from(this.restrictions.categories).map(c => getLabel(c).toLowerCase())
          ),
          incorrectType: getLabel(item.system.type?.value)
        }
      )));
    }

    if ( this.restrictions.properties.size
      && !this.restrictions.properties.intersection(item.system.properties ?? new Set()).size ) {
      errors.push(new EnchantmentError$1(game.i18n.format("DND5E.Enchantment.Warning.MissingProperty", {
        validProperties: game.i18n.getListFormatter({ type: "disjunction" }).format(
          Array.from(this.restrictions.properties).map(p => CONFIG.DND5E.itemProperties[p]?.label ?? p)
        )
      })));
    }

    /**
     * A hook event that fires while validating whether an enchantment can be applied to a specific item.
     * @function dnd5e.canEnchant
     * @memberof hookEvents
     * @param {EnchantActivity} activity   The activity performing the enchanting.
     * @param {Item5e} item                Item to which the enchantment will be applied.
     * @param {EnchantmentError[]} errors  List of errors containing failed restrictions. The item will be enchanted
     *                                     so long as no errors are listed, otherwise the provided errors will be
     *                                     displayed to the user.
     */
    Hooks.callAll("dnd5e.canEnchant", this, item, errors);

    return errors.length ? errors : true;
  }
}

/**
 * Error to throw when an item cannot be enchanted.
 */
let EnchantmentError$1 = class EnchantmentError extends Error {
  constructor(...args) {
    super(...args);
    this.name = "EnchantmentError";
  }
};

/**
 * Sheet for the forward activity.
 */
class ForwardSheet extends ActivitySheet {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["forward-activity"]
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  static PARTS = {
    ...super.PARTS,
    activation: {
      template: "systems/dnd5e/templates/activity/forward-activation.hbs",
      templates: [
        "systems/dnd5e/templates/activity/parts/activity-consumption.hbs"
      ]
    },
    effect: {
      template: "systems/dnd5e/templates/activity/forward-effect.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareActivationContext(context, options) {
    context = await super._prepareActivationContext(context, options);
    context.showConsumeSpellSlot = false;
    context.showScaling = true;
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareEffectContext(context, options) {
    context = await super._prepareEffectContext(context, options);
    context.activityOptions = [
      { value: "", label: "" },
      ...this.item.system.activities.contents
        .filter(a => (a.type !== "forward") && (CONFIG.DND5E.activityTypes[a.type] !== false))
        .map(activity => ({ value: activity.id, label: activity.name }))
    ];
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareIdentityContext(context, options) {
    context = await super._prepareIdentityContext(context, options);
    context.behaviorFields = [];
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare the tab information for the sheet.
   * @returns {Record<string, Partial<ApplicationTab>>}
   * @protected
   */
  _getTabs() {
    return this._markTabs({
      identity: {
        id: "identity", group: "sheet", icon: "fa-solid fa-tag",
        label: "DND5E.ACTIVITY.SECTIONS.Identity"
      },
      activation: {
        id: "activation", group: "sheet", icon: "fa-solid fa-boxes-stacked",
        label: "DND5E.CONSUMPTION.FIELDS.consumption.label"
      },
      effect: {
        id: "effect", group: "sheet", icon: "fa-solid fa-sun",
        label: "DND5E.ACTIVITY.SECTIONS.Effect"
      }
    });
  }
}

const { DocumentIdField: DocumentIdField$8, SchemaField: SchemaField$D } = foundry.data.fields;

/**
 * @import { ForwardActivityData } from "./_types.mjs";
 */

/**
 * Data model for a Forward activity.
 * @extends {BaseActivityData<ForwardActivityData>}
 * @mixes ForwardActivityData
 */
class BaseForwardActivityData extends BaseActivityData {
  /** @inheritDoc */
  static defineSchema() {
    const schema = super.defineSchema();
    delete schema.duration;
    delete schema.effects;
    delete schema.range;
    delete schema.target;
    return {
      ...schema,
      activity: new SchemaField$D({
        id: new DocumentIdField$8()
      })
    };
  }
}

/**
 * Activity for triggering another activity with modified consumption.
 */
class ForwardActivity extends ActivityMixin(BaseForwardActivityData) {
  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static LOCALIZATION_PREFIXES = [...super.LOCALIZATION_PREFIXES, "DND5E.FORWARD"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static metadata = Object.freeze(
    foundry.utils.mergeObject(super.metadata, {
      type: "forward",
      img: "systems/dnd5e/icons/svg/activity/forward.svg",
      title: "DND5E.FORWARD.Title",
      hint: "DND5E.FORWARD.Hint",
      sheetClass: ForwardSheet
    }, { inplace: false })
  );

  /* -------------------------------------------- */
  /*  Activation                                  */
  /* -------------------------------------------- */

  /** @override */
  async use(usage={}, dialog={}, message={}) {
    const usageConfig = foundry.utils.mergeObject({
      cause: {
        activity: this.relativeUUID
      },
      consume: {
        resources: false,
        spellSlot: false
      }
    }, usage);

    const activity = this.item.system.activities.get(this.activity.id);
    if ( !activity ) ui.notifications.error("DND5E.FORWARD.Warning.NoActivity", { localize: true });
    return activity?.use(usageConfig, dialog, message);
  }
}

/**
 * Sheet for the healing activity.
 */
class HealSheet extends ActivitySheet {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["heal-activity"]
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  static PARTS = {
    ...super.PARTS,
    effect: {
      template: "systems/dnd5e/templates/activity/heal-effect.hbs",
      templates: [
        ...super.PARTS.effect.templates,
        "systems/dnd5e/templates/activity/parts/damage-part.hbs",
        "systems/dnd5e/templates/activity/parts/heal-healing.hbs"
      ]
    }
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareEffectContext(context, options) {
    context = await super._prepareEffectContext(context, options);
    context.typeOptions = Object.entries(CONFIG.DND5E.healingTypes).map(([value, config]) => ({
      value, label: config.label, selected: context.activity.healing.types.has(value)
    }));
    const scaleKey = (this.item.type === "spell" && this.item.system.level === 0) ? "labelCantrip" : "label";
    context.scalingOptions = [
      { value: "", label: game.i18n.localize("DND5E.DAMAGE.Scaling.None") },
      ...Object.entries(CONFIG.DND5E.damageScalingModes).map(([value, { [scaleKey]: label }]) => ({ value, label }))
    ];
    return context;
  }
}

/**
 * @import { HealActivityData } from "./_types.mjs";
 */

/**
 * Data model for an heal activity.
 * @extends {BaseActivityData<HealActivityData>}
 * @mixes HealActivityData
 */
class BaseHealActivityData extends BaseActivityData {
  /** @inheritDoc */
  static defineSchema() {
    return {
      ...super.defineSchema(),
      healing: new DamageField()
    };
  }

  /* -------------------------------------------- */
  /*  Data Migration                              */
  /* -------------------------------------------- */

  /** @override */
  static transformTypeData(source, activityData, options) {
    return foundry.utils.mergeObject(activityData, {
      healing: this.transformDamagePartData(source, source.system.damage?.parts?.[0] ?? ["", ""])
    });
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareFinalData(rollData) {
    rollData ??= this.getRollData({ deterministic: true });
    super.prepareFinalData(rollData);
    this.prepareDamageLabel(rollData);
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /** @override */
  getDamageConfig(config={}) {
    if ( !this.healing.formula ) return foundry.utils.mergeObject({ rolls: [] }, config);

    const rollConfig = foundry.utils.mergeObject({ critical: { allow: false } }, config);
    const rollData = this.getRollData();
    rollConfig.rolls = [this._processDamagePart(this.healing, rollConfig, rollData)].concat(config.rolls ?? []);

    return rollConfig;
  }
}

/**
 * Activity for rolling healing.
 */
class HealActivity extends ActivityMixin(BaseHealActivityData) {
  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static LOCALIZATION_PREFIXES = [...super.LOCALIZATION_PREFIXES, "DND5E.HEAL"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static metadata = Object.freeze(
    foundry.utils.mergeObject(super.metadata, {
      type: "heal",
      img: "systems/dnd5e/icons/svg/activity/heal.svg",
      title: "DND5E.HEAL.Title",
      hint: "DND5E.HEAL.Hint",
      sheetClass: HealSheet,
      usage: {
        actions: {
          rollHealing: HealActivity.#rollHealing
        }
      }
    }, { inplace: false })
  );

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  get damageFlavor() {
    return game.i18n.localize("DND5E.HealingRoll");
  }

  /* -------------------------------------------- */
  /*  Activation                                  */
  /* -------------------------------------------- */

  /** @override */
  _usageChatButtons(message) {
    if ( !this.healing.formula ) return super._usageChatButtons(message);
    return [{
      label: game.i18n.localize("DND5E.Healing"),
      icon: '<i class="dnd5e-icon" data-src="systems/dnd5e/icons/svg/damage/healing.svg"></i>',
      dataset: {
        action: "rollHealing"
      }
    }].concat(super._usageChatButtons(message));
  }

  /* -------------------------------------------- */

  /** @override */
  async _triggerSubsequentActions(config, results) {
    this.rollDamage({ event: config.event }, {}, { data: { "flags.dnd5e.originatingMessage": results.message?.id } });
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle performing a healing roll.
   * @this {HealActivity}
   * @param {PointerEvent} event     Triggering click event.
   * @param {HTMLElement} target     The capturing HTML element which defined a [data-action].
   * @param {ChatMessage5e} message  Message associated with the activation.
   */
  static #rollHealing(event, target, message) {
    this.rollDamage({ event });
  }
}

const { DocumentIdField: DocumentIdField$7, FilePathField: FilePathField$1, StringField: StringField$T } = foundry.data.fields;

/**
 * @import { OrderActivityData } from "./_types.mjs";
 */

/**
 * Data model for an order activity.
 * @extends {BaseActivityData<OrderActivityData>}
 * @mixes OrderActivityData
 */
class BaseOrderActivityData extends BaseActivityData {
  /** @override */
  static defineSchema() {
    return {
      _id: new DocumentIdField$7({ initial: () => foundry.utils.randomID() }),
      type: new StringField$T({
        blank: false, required: true, readOnly: true, initial: () => this.metadata.type
      }),
      name: new StringField$T({ initial: undefined }),
      img: new FilePathField$1({ initial: undefined, categories: ["IMAGE"], base64: false }),
      order: new StringField$T({ required: true, blank: false, nullable: false })
    };
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareData() {
    super.prepareData();
    this.img = CONFIG.DND5E.facilities.orders[this.order]?.icon || this.metadata?.img;
  }
}

const { BooleanField: BooleanField$z, DocumentUUIDField: DocumentUUIDField$8, NumberField: NumberField$w, StringField: StringField$S } = foundry.data.fields;

/**
 * Dialog for configuring the usage of an order activity.
 */
class OrderUsageDialog extends ActivityUsageDialog {
  /** @override */
  static DEFAULT_OPTIONS = {
    actions: {
      deleteOccupant: OrderUsageDialog.#onDeleteOccupant,
      removeCraft: OrderUsageDialog.#onRemoveCraft
    }
  };

  /** @override */
  static PARTS = {
    order: {
      template: "systems/dnd5e/templates/activity/order-usage.hbs"
    },
    footer: {
      template: "templates/generic/form-footer.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /**
   * Prepare render context for the build section.
   * @param {ApplicationRenderContext} context  Render context.
   * @param {HandlebarsRenderOptions} options   Render options.
   * @protected
   */
  _prepareBuildContext(context, options) {
    context.build = {
      choices: CONFIG.DND5E.facilities.sizes,
      field: new StringField$S({ nullable: false, blank: false, label: "DND5E.FACILITY.FIELDS.size.label" }),
      name: "building.size",
      value: this.config.building?.size ?? "cramped"
    };
  }

  /* -------------------------------------------- */

  /**
   * Prepare render context for the costs section.
   * @param {ApplicationRenderContext} context  Render context.
   * @param {HandlebarsRenderOptions} options   Render options.
   * @param {number} options.days               The cost in days.
   * @param {number} options.gold               The cost in gold.
   * @protected
   */
  _prepareCostsContext(context, { days, gold }) {
    const { duration } = game.settings.get("dnd5e", "bastionConfiguration");
    context.costs = {
      days: {
        field: new NumberField$w({ nullable: true, integer: true, min: 0, label: "DND5E.TimeDay" }),
        name: "costs.days",
        value: this.config.costs?.days ?? days ?? duration
      },
      gold: {
        field: new NumberField$w({ nullable: true, integer: true, min: 0, label: "DND5E.CurrencyGP" }),
        name: "costs.gold",
        value: this.config.costs?.gold ?? gold ?? 0
      }
    };
  }

  /* -------------------------------------------- */

  /**
   * Prepare render context for the craft section.
   * @param {ApplicationRenderContext} context  Render context.
   * @param {HandlebarsRenderOptions} options   Render options.
   * @protected
   */
  async _prepareCraftContext(context, options) {
    const { craft } = this.item.system;
    context.craft = {
      legend: game.i18n.localize(`DND5E.FACILITY.Orders.${this.activity.order}.present`),
      item: {
        field: new DocumentUUIDField$8(),
        name: "craft.item",
        value: this.config.craft?.item ?? ""
      }
    };

    if ( this.activity.order === "harvest" ) {
      context.craft.isHarvesting = true;
      context.craft.item.value = this.config.craft?.item ?? craft.item ?? "";
      context.craft.quantity = {
        field: new NumberField$w({ nullable: false, integer: true, positive: true }),
        name: "craft.quantity",
        value: this.config.craft?.quantity ?? craft.quantity ?? 1
      };
    } else {
      context.craft.baseItem = {
        field: new BooleanField$z({
          label: "DND5E.FACILITY.Craft.BaseItem.Label",
          hint: "DND5E.FACILITY.Craft.BaseItem.Hint"
        }),
        name: "craft.buyBaseItem",
        value: this.config.craft?.buyBaseItem ?? false
      };
    }

    if ( context.craft.item.value ) {
      const item = await fromUuid(context.craft.item.value);
      context.craft.value = {
        img: item.img,
        name: item.name,
        contentLink: item.toAnchor().outerHTML
      };
    }
  }

  /* -------------------------------------------- */

  /**
   * Prepare render context for the enlarge order.
   * @param {ApplicationRenderContext} context  Render context.
   * @param {HandlebarsRenderOptions} options   Render options.
   * @returns {{ days: number, gold: number }}  The costs associated with performing this order.
   * @protected
   */
  _prepareEnlargeContext(context, options) {
    const sizes = Object.entries(CONFIG.DND5E.facilities.sizes).sort((a, b) => a.value - b.value);
    const index = sizes.findIndex(([size]) => size === this.item.system.size);
    const [, current] = sizes[index];
    const [, target] = sizes[index + 1];
    context.description = `
      <span class="current">${current.label}</span>
      <span class="separator">➡</span>
      <span class="target">${target.label}</span>
    `;
    const days = this.item.system.type.value === "basic" ? target.days - current.days : 0;
    return { days, gold: target.value - current.value };
  }

  /* -------------------------------------------- */

  /** @override */
  async _prepareFooterContext(context, options) {
    context.buttons = [{
      action: "use",
      type: "button",
      icon: "fas fa-hand-point-right",
      label: "DND5E.FACILITY.Order.Execute"
    }];
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare render context for orders.
   * @param {ApplicationRenderContext} context  Render context.
   * @param {HandlebarsRenderOptions} options   Render options.
   * @protected
   */
  async _prepareOrderContext(context, options) {
    if ( this.activity.order === "enlarge" ) {
      const { days, gold } = this._prepareEnlargeContext(context, options);
      this._prepareCostsContext(context, { ...options, days, gold });
      return;
    }

    if ( this.activity.order === "build" ) {
      const { days, value: gold } = CONFIG.DND5E.facilities.sizes.cramped;
      this._prepareBuildContext(context, options);
      this._prepareCostsContext(context, { ...options, days, gold });
      return;
    }

    let { duration } = game.settings.get("dnd5e", "bastionConfiguration");
    if ( (this.activity.order === "craft") || (this.activity.order === "harvest") ) {
      await this._prepareCraftContext(context, options);
    }
    else if ( this.activity.order === "trade" ) await this._prepareTradeContext(context, options);
    else {
      const config = CONFIG.DND5E.facilities.orders[this.activity.order];
      if ( config?.duration ) duration = config.duration;
    }

    this._prepareCostsContext(context, { ...options, days: duration });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    switch ( partId ) {
      case "order": await this._prepareOrderContext(context, options); break;
    }
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare render context for the trade order.
   * @param {ApplicationRenderContext} context  Render context.
   * @param {HandlebarsRenderOptions} options   Render options.
   * @protected
   */
  async _prepareTradeContext(context, options) {
    const { trade } = this.item.system;
    if ( !trade.creatures.max && !trade.stock.max ) {
      context.trade = {
        stocked: {
          field: new BooleanField$z({
            label: "DND5E.FACILITY.Trade.Stocked.Label",
            hint: "DND5E.FACILITY.Trade.Stocked.Hint"
          }),
          name: "trade.stock.stocked",
          value: this.config.trade?.stock?.stocked ?? false
        }
      };
    } else {
      const isSelling = this.config.trade?.sell ?? false;
      context.trade = {
        sell: {
          field: new BooleanField$z({ label: "DND5E.FACILITY.Trade.Sell.Label" }),
          name: "trade.sell",
          value: isSelling
        }
      };

      if ( trade.stock.max ) {
        const max = isSelling ? trade.stock.value || 0 : trade.stock.max - (trade.stock.value ?? 0);
        const label = `DND5E.FACILITY.Trade.Stock.${isSelling ? "Sell" : "Buy"}`;
        context.trade.stock = {
          field: new NumberField$w({ label, max, min: 0, step: 1, nullable: false }),
          name: "trade.stock.value",
          value: this.config.trade?.stock?.value ?? 0
        };
      } else if ( trade.creatures.max ) {
        const sell = await Promise.all(trade.creatures.value.map(async (uuid, i) => {
          const doc = await fromUuid(uuid);
          return {
            contentLink: doc.toAnchor().outerHTML,
            field: new BooleanField$z(),
            name: "trade.creatures.sell",
            value: this.config.trade?.creatures?.sell?.[i] ?? false
          };
        }));
        const buy = await Promise.all(Array.fromRange(trade.creatures.max).map(async i => {
          let removable = true;
          let uuid = trade.creatures.value[i];
          if ( uuid ) removable = false;
          else uuid = this.config.trade?.creatures?.buy?.[i];
          const doc = await fromUuid(uuid);
          if ( doc ) return { removable, uuid, img: doc.img, name: doc.name };
          return { empty: true };
        }));
        context.trade.creatures = {
          buy, sell,
          hint: "DND5E.FACILITY.Trade.Creatures.Buy",
          price: {
            field: new NumberField$w({
              nullable: false, min: 0, integer: true,
              label: "DND5E.FACILITY.Trade.Price.Label",
              hint: "DND5E.FACILITY.Trade.Price.Hint"
            }),
            name: "trade.creatures.price",
            value: this.config.trade?.creatures?.price ?? 0
          }
        };
      }
    }
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _attachFrameListeners() {
    super._attachFrameListeners();
    this.element.addEventListener("drop", this._onDrop.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * Handle drops onto the dialog.
   * @param {DragEvent} event  The drag-drop event.
   * @protected
   */
  _onDrop(event) {
    const data = foundry.applications.ux.TextEditor.implementation.getDragEventData(event);
    if ( (data.type !== "Actor") || !data.uuid ) return;
    const { trade } = this.item.system;
    if ( !this.config.trade?.creatures?.buy ) {
      this.config.trade ??= {};
      this.config.trade.creatures ??= {};
      this.config.trade.creatures.buy = [];
    }
    const index = Math.max(trade.creatures.value.length, this.config.trade.creatures.buy.length);
    if ( index + 1 > trade.creatures.max ) return;
    this.config.trade.creatures.buy[index] = data.uuid;
    this.render();
  }

  /* -------------------------------------------- */

  /**
   * Prepare submission data for build orders.
   * @param {object} submitData  Submission data.
   * @protected
   */
  _prepareBuildData(submitData) {
    if ( (this.config.building?.size ?? "cramped") !== submitData.building?.size ) {
      const { days, value: gold } = CONFIG.DND5E.facilities.sizes[submitData.building.size];
      Object.assign(submitData.costs, { days, gold });
    }
  }

  /* -------------------------------------------- */

  /**
   * Prepare submission data for craft orders.
   * @param {object} submitData  Submission data.
   * @returns {Promise<void>}
   * @protected
   */
  async _prepareCraftData(submitData) {
    let recalculateCosts = submitData.craft.item !== this.config.craft?.item;
    recalculateCosts ||= submitData.craft.buyBaseItem !== this.config.craft?.buyBaseItem;
    if ( (this.activity.order === "craft") && recalculateCosts ) {
      const item = await fromUuid(submitData.craft.item);
      const { days, gold } = await item.system.getCraftCost({
        baseItem: submitData.craft.buyBaseItem ? "buy" : "craft"
      });
      Object.assign(submitData.costs, { days, gold });
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareSubmitData(event, formData) {
    const submitData = await super._prepareSubmitData(event, formData);
    if ( "building" in submitData ) this._prepareBuildData(submitData);
    if ( submitData.craft?.item ) await this._prepareCraftData(submitData);
    if ( "trade" in submitData ) await this._prepareTradeData(submitData);
    return submitData;
  }

  /* -------------------------------------------- */

  /**
   * Prepare submission data for trade orders.
   * @param {object} submitData  Submission data.
   * @returns {Promise<void>}
   * @protected
   */
  async _prepareTradeData(submitData) {
    // Clear data when toggling trade mode.
    if ( ("trade" in this.config) && (submitData.trade.sell !== this.config.trade?.sell) ) {
      delete this.config.trade.stock;
      delete this.config.trade.creatures;
      submitData.costs.gold = 0;
    }

    if ( ("stock" in submitData.trade) && !submitData.trade.sell ) {
      submitData.costs.gold = submitData.trade.stock.value;
    }

    if ( "creatures" in submitData.trade && !submitData.trade.sell ) {
      const buy = [];
      const { creatures } = submitData.trade;
      Object.keys(creatures.buy ?? {}).forEach(k => buy[k] = creatures.buy[k]);
      submitData.trade.creatures.buy = buy;
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle removing a configured occupant.
   * @this {OrderUsageDialog}
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The event target.
   */
  static #onDeleteOccupant(event, target) {
    const { index } = target.closest("[data-index]")?.dataset ?? {};
    this.config.trade.creatures.buy.splice(index, 1);
    this.render();
  }

  /* -------------------------------------------- */

  /**
   * Handle clearing the currently configured item for crafting.
   * @this {OrderUsageDialog}
   */
  static #onRemoveCraft() {
    delete this.config.craft.item;
    this.render();
  }
}

/**
 * @import { AwardOptions } from "./_types.mjs";
 */

/**
 * Application for awarding XP and currency to players.
 */
class Award extends Application5e {

  /** @override */
  static DEFAULT_OPTIONS = {
    award: {
      currency: null,
      each: false,
      savedDestinations: new Set(),
      xp: null
    },
    classes: ["award", "standard-form"],
    form: {
      handler: Award.#handleFormSubmission
    },
    origin: null,
    position: {
      width: 350
    },
    tag: "form",
    window: {
      title: "DND5E.Award.Title"
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    award: {
      template: "systems/dnd5e/templates/apps/award.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Award options.
   * @type {AwardOptions}
   */
  get award() {
    return this.options.award;
  }

  /* -------------------------------------------- */

  /**
   * Group actor from which this award is being granted.
   * @type {Actor5e}
   */
  get origin() {
    return this.options.origin;
  }

  /* -------------------------------------------- */

  /**
   * Destinations to which XP & currency can be awarded.
   * @type {Actor5e[]}
   */
  get transferDestinations() {
    if ( this.isPartyAward ) return this.origin.system.transferDestinations ?? [];
    if ( !game.user.isGM ) return [];
    const primaryParty = game.actors.party;
    return primaryParty
      ? [primaryParty, ...primaryParty.system.transferDestinations]
      : game.users.map(u => u.character).filter(c => c);
  }

  /* -------------------------------------------- */

  /**
   * Is this award coming from a party group actor rather than the /award command?
   * @type {boolean}
   */
  get isPartyAward() {
    return this.origin?.type === "group";
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);

    context.currency = Object.entries(CONFIG.DND5E.currencies).reduce((obj, [k, { label, icon }]) => {
      obj[k] = {
        label, icon,
        value: this.award.currency ? this.award.currency[k] : this.origin?.system.currency[k]
      };
      return obj;
    }, {});
    context.destinations = Award.prepareDestinations(this.transferDestinations, this.award.savedDestinations);
    context.each = this.award.each ?? false;
    context.hideXP = game.settings.get("dnd5e", "levelingMode") === "noxp";
    context.noPrimaryParty = !game.actors.party && !this.isPartyAward;
    context.xp = this.award.xp ?? this.origin?.system.details?.xp?.value;

    return context;
  }

  /* -------------------------------------------- */

  /**
   * Apply type icons to transfer destinations and prepare them for display in the list.
   * @param {Document[]} destinations          Destination documents to prepare.
   * @param {Set<string>} [savedDestinations]  IDs of targets to pre-check.
   * @returns {{doc: Document, icon: string}[]}
   */
  static prepareDestinations(destinations, savedDestinations) {
    const icons = {
      container: '<dnd5e-icon class="fa-fw" src="systems/dnd5e/icons/svg/backpack.svg"></dnd5e-icon>',
      group: '<i class="fa-solid fa-people-group"></i>',
      vehicle: '<i class="fa-solid fa-sailboat"></i>'
    };
    return destinations.map(doc => ({
      doc, checked: savedDestinations?.has(doc.id), icon: icons[doc.type] ?? '<i class="fa-solid fa-fw fa-user"></i>'
    }));
  }

  /* -------------------------------------------- */
  /*  Life-Cycle Handlers                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onRender(context, options) {
    super._onRender(context, options);
    this._validateForm();
  }

  /* -------------------------------------------- */
  /*  Form Handling                               */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onChangeForm(formConfig, event) {
    super._onChangeForm(formConfig, event);
    this._validateForm();
  }

  /* -------------------------------------------- */

  /**
   * Ensure the award form is in a valid form to be submitted.
   * @protected
   */
  _validateForm() {
    const formData = new foundry.applications.ux.FormDataExtended(this.element);
    const data = foundry.utils.expandObject(formData.object);
    let valid = true;
    if ( !filteredKeys(data.amount ?? {}).length && !data.xp ) valid = false;
    if ( !filteredKeys(data.destination ?? {}).length ) valid = false;
    this.element.querySelector('button[name="transfer"]').disabled = !valid;
  }

  /* -------------------------------------------- */

  /**
   * Handle submitting the award form.
   * @this {Award}
   * @param {Event|SubmitEvent} event    The form submission event.
   * @param {HTMLFormElement} form       The submitted form.
   * @param {FormDataExtended} formData  Data from the dialog.
   */
  static async #handleFormSubmission(event, form, formData) {
    const data = foundry.utils.expandObject(formData.object);
    const destinations = this.transferDestinations.filter(d => data.destination[d.id]);
    const each = data.each;
    this._saveDestinations(destinations);
    const results = new Map();
    await this.constructor.awardCurrency(data.amount, destinations, { each, origin: this.origin, results });
    await this.constructor.awardXP(data.xp, destinations, { each, origin: this.origin, results });
    this.constructor.displayAwardMessages(results);
    this.close();
  }

  /* -------------------------------------------- */

  /**
   * Save the selected destination IDs to either the current group's flags or the user's flags.
   * @param {Actor5e[]} destinations  Selected destinations to save.
   * @protected
   */
  _saveDestinations(destinations) {
    const target = this.isPartyAward ? this.origin : game.user;
    target.setFlag("dnd5e", "awardDestinations", destinations);
  }

  /* -------------------------------------------- */
  /*  Awarding Methods                            */
  /* -------------------------------------------- */

  /**
   * Award currency, optionally transferring between one document and another.
   * @param {Record<string, number>} amounts   Amount of each denomination to transfer.
   * @param {(Actor5e|Item5e)[]} destinations  Documents that should receive the currency.
   * @param {object} [config={}]
   * @param {boolean} [config.each=false]      Award the specified amount to each player, rather than splitting it.
   * @param {Actor5e|Item5e} [config.origin]   Document from which to move the currency, if not a freeform award.
   * @param {Map<Actor5e|Item5e, object>} [config.results]  Results of the award operation.
   */
  static async awardCurrency(amounts, destinations, { each=false, origin, results=new Map() }={}) {
    if ( !destinations.length ) return;
    const originCurrency = origin ? foundry.utils.deepClone(origin.system.currency) : null;

    for ( const k of Object.keys(amounts) ) {
      if ( each ) amounts[k] = amounts[k] * destinations.length;
      if ( origin ) amounts[k] = Math.min(amounts[k], originCurrency[k] ?? 0);
    }

    let remainingDestinations = destinations.length;
    for ( const destination of destinations ) {
      const destinationUpdates = {};
      if ( !results.has(destination) ) results.set(destination, {});
      const result = results.get(destination).currency ??= {};

      for ( let [key, amount] of Object.entries(amounts) ) {
        if ( !amount ) continue;
        amount = Math.clamp(
          // Divide amount between remaining destinations
          Math.floor(amount / remainingDestinations),
          // Ensure negative amounts aren't more than is contained in destination
          -destination.system.currency[key],
          // Ensure positive amounts aren't more than is contained in origin
          originCurrency ? originCurrency[key] : Infinity
        );
        amounts[key] -= amount;
        if ( originCurrency ) originCurrency[key] -= amount;
        destinationUpdates[`system.currency.${key}`] = destination.system.currency[key] + amount;
        result[key] = amount;
      }

      await destination.update(destinationUpdates);
      remainingDestinations -= 1;
    }

    if ( origin ) await origin.update({ "system.currency": originCurrency });
  }

  /* -------------------------------------------- */

  /**
   * Award XP split across the provided destination actors.
   * @param {number} amount            Amount of XP to award.
   * @param {Actor5e[]} destinations   Actors that should receive the XP.
   * @param {object} [config={}]
   * @param {boolean} [config.each=false]      Award the specified amount to each player, rather than splitting it.
   * @param {Actor5e} [config.origin]  Group actor from which to transfer the XP.
   * @param {Map<Actor5e|Item5e, object>} [config.results]  Results of the award operation.
   */
  static async awardXP(amount, destinations, { each=false, origin, results=new Map() }={}) {
    destinations = destinations.filter(d => ["character", "group"].includes(d.type));
    if ( !amount || !destinations.length ) return;

    const xp = origin?.system.details?.xp;
    let originUpdate = origin ? (xp?.value ?? 0) : Infinity;
    if ( each ) amount = amount * destinations.length;
    const perDestination = Math.floor(Math.min(amount, originUpdate) / destinations.length);
    originUpdate -= amount;
    for ( const destination of destinations ) {
      await destination.update({ "system.details.xp.value": destination.system.details.xp.value + perDestination });
      if ( !results.has(destination) ) results.set(destination, {});
      const result = results.get(destination);
      result.xp = perDestination;
    }

    if ( Number.isFinite(originUpdate) ) await origin.update({ "system.details.xp.value": originUpdate });
  }

  /* -------------------------------------------- */

  /**
   * Display chat messages for awarded currency and XP.
   * @param {Map<Actor5e|Item5e, object>} results  Results of any award operations.
   */
  static async displayAwardMessages(results) {
    const cls = getDocumentClass("ChatMessage");
    const messages = [];
    for ( const [destination, result] of results ) {
      const entries = [];
      for ( const [key, amount] of Object.entries(result.currency ?? {}) ) {
        const label = CONFIG.DND5E.currencies[key].label;
        entries.push(`
          <span class="award-entry">
            ${formatNumber(amount)} <i class="currency ${key}" data-tooltip aria-label="${label}"></i>
          </span>
        `);
      }
      if ( result.xp ) entries.push(`
        <span class="award-entry">
          ${formatNumber(result.xp)} ${game.i18n.localize("DND5E.ExperiencePoints.Abbreviation")}
        </span>
      `);
      if ( !entries.length ) continue;

      const content = game.i18n.format("DND5E.Award.Message", {
        name: destination.name, award: `<span class="dnd5e2">${game.i18n.getListFormatter().format(entries)}</span>`
      });

      const whisperTargets = game.users.filter(user => destination.testUserPermission(user, "OWNER"));
      const whisper = whisperTargets.length !== game.users.size;
      const messageData = {
        content,
        whisper: whisper ? whisperTargets : []
      };
      messages.push(messageData);
    }
    if ( messages.length ) cls.createDocuments(messages);
  }

  /* -------------------------------------------- */
  /*  Chat Command                                */
  /* -------------------------------------------- */

  /**
   * Regular expression used to match the /award command in chat messages.
   * @type {RegExp}
   */
  static COMMAND_PATTERN = new RegExp(/^\/award(?:\s|$)/i);

  /* -------------------------------------------- */

  /**
   * Regular expression used to split currency & xp values from their labels.
   * @type {RegExp}
   */
  static VALUE_PATTERN = new RegExp(/^(.+?)(\D+)$/);

  /* -------------------------------------------- */

  /**
   * Use the `chatMessage` hook to determine if an award command was typed.
   * @param {string} message   Text of the message being posted.
   * @returns {boolean|void}   Returns `false` to prevent the message from continuing to parse.
   */
  static chatMessage(message) {
    if ( !this.COMMAND_PATTERN.test(message) ) return;
    this.handleAward(message);
    return false;
  }

  /* -------------------------------------------- */

  /**
   * Parse the award command and grant an award.
   * @param {string} message  Award command typed in chat.
   */
  static async handleAward(message) {
    if ( !game.user.isGM ) {
      ui.notifications.error("DND5E.Award.NotGMError", { localize: true });
      return;
    }

    try {
      const { currency, xp, party, each } = this.parseAwardCommand(message);

      for ( const [key, formula] of Object.entries(currency) ) {
        const roll = new Roll(formula);
        await roll.evaluate();
        currency[key] = roll.total;
      }

      // If the party command is set, a primary party is set, and the award isn't empty, skip the UI
      const primaryParty = game.actors.party;
      if ( party && primaryParty && (xp || filteredKeys(currency).length) ) {
        const destinations = each ? primaryParty.system.playerCharacters : [primaryParty];
        const results = new Map();
        await this.awardCurrency(currency, destinations, { each, results });
        await this.awardXP(xp, destinations, { each, results });
        this.displayAwardMessages(results);
      }

      // Otherwise show the UI with defaults
      else {
        const savedDestinations = game.user.getFlag("dnd5e", "awardDestinations");
        const app = new Award({ award: { currency, xp, each, savedDestinations } });
        app.render({ force: true });
      }
    } catch(err) {
      ui.notifications.warn(err.message);
    }
  }

  /* -------------------------------------------- */

  /**
   * Parse the award command.
   * @param {string} message  Award command typed in chat.
   * @returns {{currency: Record<string, number>, xp: number, party: boolean}}
   */
  static parseAwardCommand(message) {
    const command = message.replace(this.COMMAND_PATTERN, "").toLowerCase();

    const currency = {};
    let each = false;
    let party = false;
    let xp;
    const unrecognized = [];
    for ( const part of command.split(" ") ) {
      if ( !part ) continue;
      let [, amount, label] = part.match(this.VALUE_PATTERN) ?? [];
      label = label?.toLowerCase();
      try {
        new Roll(amount);
        if ( label in CONFIG.DND5E.currencies ) currency[label] = amount;
        else if ( label === "xp" ) xp = Number(amount);
        else if ( part === "each" ) each = true;
        else if ( part === "party" ) party = true;
        else throw new Error();
      } catch(err) {
        unrecognized.push(part);
      }
    }

    // Display warning about an unrecognized commands
    if ( unrecognized.length ) throw new Error(game.i18n.format("DND5E.Award.UnrecognizedWarning", {
      commands: game.i18n.getListFormatter().format(unrecognized.map(u => `"${u}"`))
    }));

    return { currency, xp, each, party };
  }
}

/**
 * Application for performing currency conversions & transfers.
 */
class CurrencyManager extends Application5e {

  /** @override */
  static DEFAULT_OPTIONS = {
    actions: {
      setAll: CurrencyManager.#setTransferValue,
      setHalf: CurrencyManager.#setTransferValue
    },
    classes: ["currency-manager", "standard-form"],
    document: null,
    form: {
      closeOnSubmit: true,
      handler: CurrencyManager.#handleFormSubmission
    },
    position: {
      width: 350
    },
    tag: "form",
    window: {
      title: "DND5E.CurrencyManager.Title"
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    tabs: {
      template: "templates/generic/tab-navigation.hbs"
    },
    convert: {
      template: "systems/dnd5e/templates/apps/currency-manager-convert.hbs"
    },
    transfer: {
      template: "systems/dnd5e/templates/apps/currency-manager-transfer.hbs"
    }
  };

  /* -------------------------------------------- */

  /** @override */
  tabGroups = {
    primary: "transfer"
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Document for which the currency is being managed.
   * @type {Actor5e|Item5e}
   */
  get document() {
    return this.options.document;
  }

  /* -------------------------------------------- */

  /**
   * Destinations to which currency can be transferred.
   * @type {(Actor5e|Item5e)[]}
   */
  get transferDestinations() {
    const destinations = [];
    const actor = this.document instanceof Actor ? this.document : this.document.parent;
    if ( actor && (actor !== this.document) ) destinations.push(actor);
    destinations.push(...(actor?.system.transferDestinations ?? []));
    destinations.push(...(actor?.itemTypes.container.filter(b => b !== this.document) ?? []));
    if ( game.user.isGM ) {
      const primaryParty = game.actors.party;
      if ( primaryParty && (this.document !== primaryParty) && !destinations.includes(primaryParty) ) {
        destinations.push(primaryParty);
      }
    }
    return destinations;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);

    context.currency = this.document.system.currency;
    context.destinations = Award.prepareDestinations(this.transferDestinations);
    context.tabs = this._getTabs();

    return context;
  }

  /* -------------------------------------------- */

  /** @override */
  async _preparePartContext(partId, context) {
    context = await super._preparePartContext(partId, context);
    context.tab = context.tabs[partId];
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare the tab information for the sheet.
   * @returns {Record<string, Partial<ApplicationTab>>}
   * @protected
   */
  _getTabs() {
    return {
      convert: {
        id: "convert", group: "primary", icon: "fa-solid fa-arrow-up-short-wide",
        label: "DND5E.CurrencyManager.Convert.Label",
        active: this.tabGroups.primary === "convert",
        cssClass: this.tabGroups.primary === "convert" ? "active" : ""
      },
      transfer: {
        id: "transfer", group: "primary", icon: "fa-solid fa-reply-all fa-flip-horizontal",
        label: "DND5E.CurrencyManager.Transfer.Label",
        active: this.tabGroups.primary === "transfer",
        cssClass: this.tabGroups.primary === "transfer" ? "active" : ""
      }
    };
  }

  /* -------------------------------------------- */
  /*  Event Handling                              */
  /* -------------------------------------------- */

  /**
   * Handle setting the transfer amount based on the buttons.
   * @this {CurrencyManager}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   * @protected
   */
  static #setTransferValue(event, target) {
    for ( let [key, value] of Object.entries(this.document.system.currency) ) {
      if ( target.dataset.action === "setHalf" ) value = Math.floor(value / 2);
      const input = this.element.querySelector(`[name="amount.${key}"]`);
      if ( input && value ) input.value = value;
    }
    this._validateForm();
  }

  /* -------------------------------------------- */
  /*  Form Handling                               */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onChangeForm(formConfig, event) {
    super._onChangeForm(formConfig, event);
    this._validateForm();
  }

  /* -------------------------------------------- */

  /**
   * Ensure the transfer form is in a valid form to be submitted.
   * @protected
   */
  _validateForm() {
    const formData = new foundry.applications.ux.FormDataExtended(this.element);
    const data = foundry.utils.expandObject(formData.object);
    let valid = true;
    if ( !filteredKeys(data.amount ?? {}).length ) valid = false;
    if ( !filteredKeys(data.destination ?? {}).length ) valid = false;
    this.element.querySelector('button[name="transfer"]').disabled = !valid;
  }

  /* -------------------------------------------- */

  /**
   * Handle submitting the currency manager form.
   * @this {Award}
   * @param {Event|SubmitEvent} event    The form submission event.
   * @param {HTMLFormElement} form       The submitted form.
   * @param {FormDataExtended} formData  Data from the dialog.
   */
  static async #handleFormSubmission(event, form, formData) {
    const data = foundry.utils.expandObject(formData.object);
    switch ( event.submitter?.name ) {
      case "convert":
        await this.constructor.convertCurrency(this.document);
        break;
      case "transfer":
        const destinations = this.transferDestinations.filter(d => data.destination[d.id]);
        await this.constructor.transferCurrency(this.document, destinations, data.amount);
        break;
    }
  }

  /* -------------------------------------------- */
  /*  Currency Operations                         */
  /* -------------------------------------------- */

  /**
   * Convert all carried currency to the highest possible denomination using configured conversion rates.
   * See CONFIG.DND5E.currencies for configuration.
   * @param {Actor5e|Item5e} doc  Actor or container item to convert.
   * @returns {Promise<Actor5e|Item5e>}
   */
  static convertCurrency(doc) {
    const currency = foundry.utils.deepClone(doc.system.currency);

    const currencies = Object.entries(CONFIG.DND5E.currencies)
      .filter(([, c]) => c.conversion)
      .sort((a, b) => a[1].conversion - b[1].conversion);

    // Convert all currently to smallest denomination
    const smallestConversion = currencies.at(-1)[1].conversion;
    let amount = currencies.reduce((amount, [denomination, config]) =>
      amount + (currency[denomination] * (smallestConversion / config.conversion))
    , 0);

    // Convert base units into the highest denomination possible
    for ( const [denomination, config] of currencies) {
      const ratio = smallestConversion / config.conversion;
      currency[denomination] = Math.floor(amount / ratio);
      amount -= currency[denomination] * ratio;
    }

    // Save the updated currency object
    return doc.update({ "system.currency": currency });
  }

  /* -------------------------------------------- */

  /**
   * Deduct a certain amount of currency from a given Actor.
   * @param {Actor5e} actor                          The actor.
   * @param {number} amount                          The amount of currency.
   * @param {string} denomination                    The currency's denomination.
   * @param {object} [options]
   * @param {boolean} [options.recursive=false]      Deduct currency from containers as well as the base Actor. TODO
   * @param {"high"|"low"} [options.priority="low"]  Prioritize higher denominations before lower, or vice-versa.
   * @param {boolean} [options.exact=true]           Prioritize deducting the requested denomination first.
   * @throws {Error} If the Actor does not have sufficient currency.
   * @returns {Promise<Actor5e>|void}
   */
  static deductActorCurrency(actor, amount, denomination, options={}) {
    if ( amount <= 0 ) return;
    // eslint-disable-next-line no-unused-vars
    const { item, remainder, ...updates } = this.getActorCurrencyUpdates(actor, amount, denomination, options);
    if ( remainder ) throw new Error(game.i18n.format("DND5E.CurrencyManager.Error.InsufficientFunds", {
      denomination,
      amount: new Intl.NumberFormat(game.i18n.lang).format(amount),
      name: actor.name
    }));
    return actor.update(updates);
  }

  /* -------------------------------------------- */

  /**
   * Determine model updates for deducting a certain amount of currency from a given Actor.
   * @param {Actor5e} actor                          The actor.
   * @param {number} amount                          The amount of currency.
   * @param {string} denomination                    The currency's denomination.
   * @param {object} [options]
   * @param {boolean} [options.recursive=false]      Deduct currency from containers as well as the base Actor. TODO
   * @param {"high"|"low"} [options.priority="low"]  Prioritize higher denominations before lower, or vice-versa.
   * @param {boolean} [options.exact=true]           Prioritize deducting the requested denomination first.
   * @returns {{ item: object[], remainder: number, [p: string]: any }}
   */
  static getActorCurrencyUpdates(actor, amount, denomination, { recursive=false, priority="low", exact=true }={}) {
    const { currency } = actor.system;
    const updates = { system: { currency: { ...currency } }, remainder: amount, item: [] };
    if ( amount <= 0 ) return updates;

    const currencies = Object.entries(CONFIG.DND5E.currencies).map(([denom, { conversion }]) => {
      return [denom, conversion];
    }).sort(([, a], [, b]) => priority === "high" ? a - b : b - a);
    const baseConversion = CONFIG.DND5E.currencies[denomination].conversion;

    if ( exact ) currencies.unshift([denomination, baseConversion]);
    for ( const [denom, conversion] of currencies ) {
      const multiplier = conversion / baseConversion;
      const deduct = Math.min(updates.system.currency[denom], Math.floor(updates.remainder * multiplier));
      updates.remainder -= deduct / multiplier;
      updates.system.currency[denom] -= deduct;
      if ( !updates.remainder ) return updates;
    }

    return updates;
  }

  /* -------------------------------------------- */

  /**
   * Transfer currency between one document and another.
   * @param {Actor5e|Item5e} origin       Document from which to move the currency.
   * @param {Document[]} destinations     Documents that should receive the currency.
   * @param {object[]} amounts            Amount of each denomination to transfer.
   */
  static async transferCurrency(origin, destinations, amounts) {
    Award.awardCurrency(amounts, destinations, { origin });
  }
}

/**
 * @import { OrderUseConfiguration } from "./_types.mjs";
 */

/**
 * An activity for issuing an order to a facility.
 */
class OrderActivity extends ActivityMixin(BaseOrderActivityData) {
  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static metadata = Object.freeze(foundry.utils.mergeObject(super.metadata, {
    type: "order",
    img: "systems/dnd5e/icons/svg/activity/order.svg",
    title: "DND5E.FACILITY.Order.Issue",
    usage: {
      actions: {
        pay: OrderActivity.#onPayOrder
      },
      chatCard: "systems/dnd5e/templates/chat/order-activity-card.hbs",
      dialog: OrderUsageDialog
    }
  }, { inplace: false }));

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  get canUse() {
    return super.canUse
      // Don't allow usage if facility is already executing the same order or has been disabled by attack
      && !this.inProgress && !this.item.system.disabled
      // Enlarge order cannot be executed if facility is already maximum size
      && ((this.order !== "enlarge") || (this.parent.size !== "vast"));
  }

  /* -------------------------------------------- */

  /**
   * Is this order currently in the process of being executed by its facility?
   * @type {boolean}
   */
  get inProgress() {
    if ( this.parent.progress.order !== this.order ) return false;
    // TODO: Ideally this would also check to see if the order has already been paid,
    // but that information is only part of the chat message and there isn't a clean
    // way to retrieve it at the moment
    return this.parent.progress.value > 0;
  }

  /* -------------------------------------------- */
  /*  Activation                                  */
  /* -------------------------------------------- */

  /**
   * Update building configuration.
   * @param {OrderUseConfiguration} usageConfig  Order configuration.
   * @param {object} updates                     Item updates.
   * @protected
   */
  _finalizeBuild(usageConfig, updates) {
    updates["system.building.size"] = usageConfig.building.size;
  }

  /* -------------------------------------------- */

  /**
   * Update costs.
   * @param {OrderUseConfiguration} usageConfig  Order configuration.
   * @param {object} updates                     Item updates.
   * @protected
   */
  _finalizeCosts(usageConfig, updates) {
    const { costs } = usageConfig;
    if ( costs.days ) updates["system.progress"] = { value: 0, max: costs.days, order: this.order };
  }

  /* -------------------------------------------- */

  /**
   * Update crafting configuration.
   * @param {OrderUseConfiguration} usageConfig  Order configuration.
   * @param {object} updates                     Item updates.
   * @protected
   */
  _finalizeCraft(usageConfig, updates) {
    const { craft } = usageConfig;
    updates["system.craft"] = { item: craft.item, quantity: 1 };
    if ( this.order === "harvest" ) updates["system.craft"].quantity = craft.quantity;
  }

  /* -------------------------------------------- */

  /**
   * Update facility size.
   * @param {OrderUseConfiguration} usageConfig  Order configuration.
   * @param {object} updates                     Item updates.
   * @protected
   */
  _finalizeEnlarge(usageConfig, updates) {
    // Special facilities enlarge immediately.
    if ( (this.item.system.type.value !== "special") || (this.item.system.size === "vast") ) return;
    const sizes = Object.entries(CONFIG.DND5E.facilities.sizes).sort((a, b) => a.value - b.value);
    const index = sizes.findIndex(([size]) => size === this.item.system.size);
    updates["system.size"] = sizes[index + 1][0];
  }

  /* -------------------------------------------- */

  /**
   * Update trading configuration.
   * @param {OrderUseConfiguration} usageConfig  Order configuration.
   * @param {object} updates                     Item updates.
   * @protected
   */
  _finalizeTrade(usageConfig, updates) {
    const { costs, trade } = usageConfig;
    const { system } = this.item;
    updates["system.trade.pending.operation"] = trade.sell ? "sell" : "buy";
    updates["system.trade.pending.creatures"] = [];
    updates["system.trade.pending.value"] = null;
    if ( trade.stock ) {
      if ( "stocked" in trade.stock ) {
        updates["system.trade.pending.stocked"] = trade.stock.stocked;
        updates["system.trade.pending.operation"] = trade.stock.stocked ? "buy" : null;
      }
      else updates["system.trade.pending.value"] = trade.stock.value;
    }
    if ( trade.creatures ) {
      let creatures = (trade.creatures.buy ?? []).filter(_ => _);
      if ( trade.sell ) {
        creatures = [];
        for ( let i = 0; i < trade.creatures.sell?.length ?? 0; i++ ) {
          const sold = trade.creatures.sell[i];
          if ( sold ) creatures.push(system.trade.creatures.value[i]);
        }
      }
      updates["system.trade.pending.value"] = trade.sell ? (trade.creatures.price ?? 0) : costs.gold;
      updates["system.trade.pending.creatures"] = creatures;

      // Sold livestock are removed immediately. Bought livestock remain pending until the order is complete.
      if ( trade.sell ) {
        updates["system.trade.creatures.value"] = system.trade.creatures.value.filter((_, i) => {
          return !trade.creatures.sell[i];
        });
      }
    }
  }

  /* -------------------------------------------- */

  /** @override */
  async _finalizeUsage(usageConfig, results) {
    const updates = {};
    switch ( this.order ) {
      case "build": this._finalizeBuild(usageConfig, updates); break;
      case "craft":
      case "harvest":
        this._finalizeCraft(usageConfig, updates);
        break;
      case "enlarge": this._finalizeEnlarge(usageConfig, updates); break;
      case "trade": this._finalizeTrade(usageConfig, updates); break;
    }
    this._finalizeCosts(usageConfig, updates);
    return this.item.update(updates);
  }

  /* -------------------------------------------- */

  /** @override */
  _prepareUsageConfig(config) {
    config.consume = false;
    return config;
  }

  /* -------------------------------------------- */

  /** @override */
  _prepareUsageScaling(usageConfig, messageConfig, item) {
    // FIXME: No scaling happening here, but this is the only context we have both usageConfig and messageConfig.
    const { costs, craft, trade } = usageConfig;
    messageConfig.data.flags.dnd5e.order = { costs, craft, trade };
  }

  /* -------------------------------------------- */

  /** @override */
  _requiresConfigurationDialog(config) {
    return true;
  }

  /* -------------------------------------------- */

  /** @override */
  _usageChatButtons(message) {
    const { costs } = message.data.flags.dnd5e.order;
    if ( !costs.gold || costs.paid ) return [];
    return [{
      label: game.i18n.localize("DND5E.FACILITY.Costs.Automatic"),
      icon: '<i class="fas fa-coins"></i>',
      dataset: { action: "pay", method: "automatic" }
    }, {
      label: game.i18n.localize("DND5E.FACILITY.Costs.Manual"),
      icon: '<i class="fas fa-clipboard-check"></i>',
      dataset: { action: "pay", method: "manual" }
    }];
  }

  /* -------------------------------------------- */

  /** @override */
  async _usageChatContext(message) {
    const { costs, craft, trade } = message.data.flags.dnd5e.order;
    const { type } = this.item.system;
    const supplements = [];
    if ( costs.days ) supplements.push(`
      <strong>${game.i18n.localize("DND5E.DurationTime")}</strong>
      ${game.i18n.format("DND5E.FACILITY.Costs.Days", { days: costs.days })}
    `);
    if ( costs.gold ) supplements.push(`
      <strong>${game.i18n.localize("DND5E.CurrencyGP")}</strong>
      ${formatNumber(costs.gold)}
      (${game.i18n.localize(`DND5E.FACILITY.Costs.${costs.paid ? "Paid" : "Unpaid"}`)})
    `);
    if ( craft?.item ) {
      const item = await fromUuid(craft.item);
      supplements.push(`
        <strong>${game.i18n.localize("DOCUMENT.Items")}</strong>
        ${craft.quantity > 1 ? `${craft.quantity}&times;` : ""}
        ${item.toAnchor().outerHTML}
      `);
    }
    if ( trade?.stock?.value && trade.sell ) supplements.push(`
      <strong>${game.i18n.localize("DND5E.FACILITY.Trade.Sell.Supplement")}</strong>
      ${formatNumber(trade.stock.value)}
      ${CONFIG.DND5E.currencies.gp?.abbreviation ?? ""}
    `);
    if ( trade?.creatures ) {
      const creatures = [];
      if ( trade.sell ) {
        for ( let i = 0; i < trade.creatures.sell.length; i++ ) {
          const sold = trade.creatures.sell[i];
          if ( sold ) creatures.push(await fromUuid(this.item.system.trade.creatures.value[i]));
        }
      }
      else creatures.push(...await Promise.all(trade.creatures.buy.filter(_ => _).map(uuid => fromUuid(uuid))));
      supplements.push(`
        <strong>${game.i18n.localize(`DND5E.FACILITY.Trade.${trade.sell ? "Sell" : "Buy"}.Supplement`)}</strong>
        ${game.i18n.getListFormatter({ style: "narrow" }).format(creatures.map(a => a.toAnchor().outerHTML))}
      `);
    }
    const facilityType = game.i18n.localize(`DND5E.FACILITY.Types.${type.value.titleCase()}.Label.one`);
    const buttons = this._usageChatButtons(message);
    return {
      supplements,
      buttons: buttons.length ? buttons : null,
      description: game.i18n.format("DND5E.FACILITY.Use.Description", {
        order: game.i18n.localize(`DND5E.FACILITY.Orders.${this.order}.inf`),
        link: this.item.toAnchor().outerHTML,
        facilityType: facilityType.toLocaleLowerCase(game.i18n.lang)
      })
    };
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /**
   * Handle deducting currency for the order.
   * @this {OrderActivity}
   * @param {PointerEvent} event     The triggering event.
   * @param {HTMLElement} target     The button that was clicked.
   * @param {ChatMessage5e} message  The message associated with the activation.
   * @returns {Promise<void>}
   */
  static async #onPayOrder(event, target, message) {
    const { method } = target.dataset;
    const order = message.getFlag("dnd5e", "order");
    const config = foundry.utils.expandObject({ "data.flags.dnd5e.order": order });
    if ( method === "automatic" ) {
      try {
        await CurrencyManager.deductActorCurrency(this.actor, order.costs.gold, "gp", {
          recursive: true,
          priority: "high"
        });
      } catch(err) {
        ui.notifications.error(err.message);
        return;
      }
    }
    foundry.utils.setProperty(config, "data.flags.dnd5e.order.costs.paid", true);
    const context = await this._usageChatContext(config);
    const content = await foundry.applications.handlebars.renderTemplate(this.metadata.usage.chatCard, context);
    await message.update({ content, flags: config.data.flags });
  }
}

/**
 * Sheet for the save activity.
 */
class SaveSheet extends ActivitySheet {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["save-activity"]
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  static PARTS = {
    ...super.PARTS,
    effect: {
      template: "systems/dnd5e/templates/activity/save-effect.hbs",
      templates: [
        ...super.PARTS.effect.templates,
        "systems/dnd5e/templates/activity/parts/damage-part.hbs",
        "systems/dnd5e/templates/activity/parts/damage-parts.hbs",
        "systems/dnd5e/templates/activity/parts/save-damage.hbs",
        "systems/dnd5e/templates/activity/parts/save-details.hbs",
        "systems/dnd5e/templates/activity/parts/save-effect-settings.hbs"
      ]
    }
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @override */
  _prepareAppliedEffectContext(context, effect) {
    effect.additionalSettings = "systems/dnd5e/templates/activity/parts/save-effect-settings.hbs";
    return effect;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareEffectContext(context, options) {
    context = await super._prepareEffectContext(context, options);

    context.abilityOptions = Object.entries(CONFIG.DND5E.abilities).map(([value, config]) => ({
      value, label: config.label
    }));
    context.calculationOptions = [
      { value: "", label: game.i18n.localize("DND5E.SAVE.FIELDS.save.dc.CustomFormula") },
      { rule: true },
      { value: "spellcasting", label: game.i18n.localize("DND5E.SpellAbility") },
      ...Object.entries(CONFIG.DND5E.abilities).map(([value, config]) => ({
        value, label: config.label, group: game.i18n.localize("DND5E.Abilities")
      }))
    ];
    context.onSaveOptions = [
      { value: "none", label: game.i18n.localize("DND5E.SAVE.FIELDS.damage.onSave.None") },
      { value: "half", label: game.i18n.localize("DND5E.SAVE.FIELDS.damage.onSave.Half") },
      { value: "full", label: game.i18n.localize("DND5E.SAVE.FIELDS.damage.onSave.Full") }
    ];

    return context;
  }
}

const { ArrayField: ArrayField$f, BooleanField: BooleanField$y, SchemaField: SchemaField$C, SetField: SetField$t, StringField: StringField$R } = foundry.data.fields;

/**
 * @import { SaveActivityData } from "./_types.mjs";
 */

/**
 * Data model for an save activity.
 * @extends {BaseActivityData<SaveActivityData>}
 * @mixes SaveActivityData
 */
class BaseSaveActivityData extends BaseActivityData {
  /** @inheritDoc */
  static defineSchema() {
    return {
      ...super.defineSchema(),
      damage: new SchemaField$C({
        onSave: new StringField$R({ required: true, blank: false, initial: "half" }),
        parts: new ArrayField$f(new DamageField())
      }),
      effects: new ArrayField$f(new AppliedEffectField({
        onSave: new BooleanField$y()
      })),
      save: new SchemaField$C({
        ability: new SetField$t(new StringField$R()),
        dc: new SchemaField$C({
          calculation: new StringField$R({ initial: "initial" }),
          formula: new FormulaField({ deterministic: true })
        })
      })
    };
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  get ability() {
    if ( this.save.dc.calculation in CONFIG.DND5E.abilities ) return this.save.dc.calculation;
    if ( this.save.dc.calculation === "spellcasting" ) return this.spellcastingAbility;
    return this.save.ability.first() ?? null;
  }

  /* -------------------------------------------- */
  /*  Data Migration                              */
  /* -------------------------------------------- */

  /** @override */
  static migrateData(source) {
    if ( foundry.utils.getType(source.save?.ability) === "string" ) {
      if ( source.save.ability ) source.save.ability = [source.save.ability];
      else source.save.ability = [];
    }
  }

  /* -------------------------------------------- */

  /** @override */
  static transformTypeData(source, activityData, options) {
    let calculation = source.system.save?.scaling;
    if ( calculation === "flat" ) calculation = "";
    else if ( calculation === "spell" ) calculation = "spellcasting";

    return foundry.utils.mergeObject(activityData, {
      damage: {
        onSave: (source.type === "spell") && (source.system.level === 0) ? "none" : "half",
        parts: source.system.damage?.parts?.map(part => this.transformDamagePartData(source, part)) ?? []
      },
      save: {
        ability: [source.system.save?.ability || Object.keys(CONFIG.DND5E.abilities)[0]],
        dc: {
          calculation,
          formula: String(source.system.save?.dc ?? "")
        }
      }
    });
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareData() {
    super.prepareData();
    if ( this.save.dc.calculation === "initial" ) this.save.dc.calculation = this.isSpell ? "spellcasting" : "";
    this.save.dc.bonus = "";
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareFinalData(rollData) {
    rollData ??= this.getRollData({ deterministic: true });
    super.prepareFinalData(rollData);
    this.prepareDamageLabel(rollData);

    const bonus = this.save.dc.bonus ? simplifyBonus(this.save.dc.bonus, rollData) : 0;

    let ability;
    if ( this.save.dc.calculation ) ability = this.ability;
    else this.save.dc.value = simplifyBonus(this.save.dc.formula, rollData);
    this.save.dc.value ??= this.actor?.system.abilities?.[ability]?.dc
      ?? 8 + (this.actor?.system.attributes?.prof ?? 0);
    this.save.dc.value += bonus;

    if ( this.save.dc.value ) this.labels.save = game.i18n.format("DND5E.SaveDC", {
      dc: this.save.dc.value,
      ability: CONFIG.DND5E.abilities[ability]?.label ?? ""
    });
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _preCreate(data) {
    super._preCreate(data);
    if ( !("onSave" in (data.damage ?? {})) && this.isSpell && (this.item.system.level === 0) ) {
      this.updateSource({ "damage.onSave": "none" });
    }
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  getDamageConfig(config={}) {
    const rollConfig = super.getDamageConfig(config);

    rollConfig.critical ??= {};
    rollConfig.critical.allow ??= false;

    return rollConfig;
  }
}

/**
 * Activity for making saving throws and rolling damage.
 */
class SaveActivity extends ActivityMixin(BaseSaveActivityData) {
  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static LOCALIZATION_PREFIXES = [...super.LOCALIZATION_PREFIXES, "DND5E.SAVE"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static metadata = Object.freeze(
    foundry.utils.mergeObject(super.metadata, {
      type: "save",
      img: "systems/dnd5e/icons/svg/activity/save.svg",
      title: "DND5E.SAVE.Title.one",
      hint: "DND5E.SAVE.Hint",
      sheetClass: SaveSheet,
      usage: {
        actions: {
          rollDamage: SaveActivity.#rollDamage,
          rollSave: SaveActivity.#rollSave
        }
      }
    }, { inplace: false })
  );

  /* -------------------------------------------- */
  /*  Activation                                  */
  /* -------------------------------------------- */

  /** @override */
  _usageChatButtons(message) {
    const buttons = [];
    const dc = this.save.dc.value;

    for ( const abilityId of this.save.ability ) {
      const ability = CONFIG.DND5E.abilities[abilityId]?.label ?? "";
      buttons.push({
        label: `
          <span class="visible-dc">${game.i18n.format("DND5E.SavingThrowDC", { dc, ability })}</span>
          <span class="hidden-dc">${game.i18n.format("DND5E.SavePromptTitle", { ability })}</span>
        `,
        icon: '<i class="fa-solid fa-shield-heart" inert></i>',
        dataset: {
          dc,
          ability: abilityId,
          action: "rollSave",
          visibility: "all"
        }
      });
    }

    if ( this.damage.parts.length ) buttons.push({
      label: game.i18n.localize("DND5E.Damage"),
      icon: '<i class="fas fa-burst" inert></i>',
      dataset: {
        action: "rollDamage"
      }
    });
    return buttons.concat(super._usageChatButtons(message));
  }

  /* -------------------------------------------- */
  /*  Rolling                                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async rollDamage(config={}, dialog={}, message={}) {
    message = foundry.utils.mergeObject({
      "data.flags.dnd5e.roll": {
        damageOnSave: this.damage.onSave
      }
    }, message);
    return super.rollDamage(config, dialog, message);
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle performing a damage roll.
   * @this {SaveActivity}
   * @param {PointerEvent} event     Triggering click event.
   * @param {HTMLElement} target     The capturing HTML element which defined a [data-action].
   * @param {ChatMessage5e} message  Message associated with the activation.
   */
  static #rollDamage(event, target, message) {
    this.rollDamage({ event });
  }

  /* -------------------------------------------- */

  /**
   * Handle performing a saving throw.
   * @this {SaveActivity}
   * @param {PointerEvent} event     Triggering click event.
   * @param {HTMLElement} target     The capturing HTML element which defined a [data-action].
   * @param {ChatMessage5e} message  Message associated with the activation.
   */
  static async #rollSave(event, target, message) {
    const targets = getSceneTargets();
    if ( !targets.length && game.user.character ) targets.push(game.user.character);
    if ( !targets.length ) ui.notifications.warn("DND5E.ActionWarningNoToken", { localize: true });
    const dc = parseInt(target.dataset.dc);
    for ( const token of targets ) {
      const actor = token instanceof Actor ? token : token.actor;
      const speaker = ChatMessage.getSpeaker({ actor, scene: canvas.scene, token: token.document });
      await actor.rollSavingThrow({
        event,
        ability: target.dataset.ability ?? this.save.ability.first(),
        target: Number.isFinite(dc) ? dc : this.save.dc.value
      }, {}, { data: { speaker } });
    }
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async getFavoriteData() {
    return foundry.utils.mergeObject(await super.getFavoriteData(), { save: this.save });
  }
}

/**
 * Sheet for the summon activity.
 */
class SummonSheet extends ActivitySheet {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["summon-activity"],
    actions: {
      addProfile: SummonSheet.#addProfile,
      deleteProfile: SummonSheet.#deleteProfile
    }
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  static PARTS = {
    ...super.PARTS,
    effect: {
      template: "systems/dnd5e/templates/activity/summon-effect.hbs",
      templates: [
        ...super.PARTS.effect.templates,
        "systems/dnd5e/templates/activity/parts/summon-changes.hbs",
        "systems/dnd5e/templates/activity/parts/summon-profiles.hbs"
      ]
    }
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  static CLEAN_ARRAYS = [...super.CLEAN_ARRAYS, "profiles"];

  /* -------------------------------------------- */

  /** @override */
  tabGroups = {
    sheet: "identity",
    activation: "time",
    effect: "profiles"
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareEffectContext(context, options) {
    context = await super._prepareEffectContext(context, options);

    context.abilityOptions = [
      {
        value: "", rule: true,
        label: game.i18n.format("DND5E.DefaultSpecific", {
          default: this.activity.isSpell ? game.i18n.localize("DND5E.Spellcasting").toLowerCase()
            : CONFIG.DND5E.abilities[this.activity.ability]?.label.toLowerCase()
              ?? game.i18n.localize("DND5E.None").toLowerCase()
        })
      },
      ...Object.entries(CONFIG.DND5E.abilities).map(([value, { label }]) => ({ value, label }))
    ];
    context.creatureSizeOptions = Object.entries(CONFIG.DND5E.actorSizes).map(([value, config]) => ({
      value, label: config.label, selected: this.activity.creatureSizes.has(value)
    }));
    context.creatureTypeOptions = Object.entries(CONFIG.DND5E.creatureTypes).map(([value, config]) => ({
      value, label: config.label, selected: this.activity.creatureTypes.has(value)
    }));

    context.profileModes = [
      { value: "", label: game.i18n.localize("DND5E.SUMMON.FIELDS.summon.mode.Direct") },
      { value: "cr", label: game.i18n.localize("DND5E.SUMMON.FIELDS.summon.mode.CR") }
    ];
    context.profiles = this.activity.profiles.map((data, index) => ({
      data, index,
      collapsed: this.expandedSections.get(`profiles.${data._id}`) ? "" : "collapsed",
      fields: this.activity.schema.fields.profiles.element.fields,
      prefix: `profiles.${index}.`,
      source: context.source.profiles[index] ?? data,
      document: data.uuid ? fromUuidSync(data.uuid) : null,
      mode: this.activity.summon.mode,
      typeOptions: this.activity.summon.mode === "cr" ? context.creatureTypeOptions.map(t => ({
        ...t, selected: data.types.has(t.value)
      })) : null
    })).sort((lhs, rhs) =>
      (lhs.name || lhs.document?.name || "").localeCompare(rhs.name || rhs.document?.name || "", game.i18n.lang)
    );

    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareIdentityContext(context, options) {
    context = await super._prepareIdentityContext(context, options);
    context.behaviorFields.push({
      field: context.fields.summon.fields.prompt,
      value: context.source.summon.prompt,
      input: context.inputs.createCheckboxInput
    });
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _getTabs() {
    const tabs = super._getTabs();
    tabs.effect.label = "DND5E.SUMMON.SECTIONS.Summoning";
    tabs.effect.icon = "fa-solid fa-spaghetti-monster-flying";
    tabs.effect.tabs = this._markTabs({
      profiles: {
        id: "profiles", group: "effect", icon: "fa-solid fa-address-card",
        label: "DND5E.SUMMON.SECTIONS.Profiles"
      },
      changes: {
        id: "changes", group: "effect", icon: "fa-solid fa-sliders",
        label: "DND5E.SUMMON.SECTIONS.Changes"
      }
    });
    return tabs;
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onRender() {
    super._onRender();
    this.element.querySelector(".activity-profiles").addEventListener("drop", this.#onDrop.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * Handle adding a new entry to the summoning profiles list.
   * @this {SummonSheet}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static #addProfile(event, target) {
    this.activity.update({ profiles: [...this.activity.toObject().profiles, {}] });
  }

  /* -------------------------------------------- */

  /**
   * Handle removing an entry from the summoning profiles list.
   * @this {SummonSheet}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static #deleteProfile(event, target) {
    const profiles = this.activity.toObject().profiles;
    profiles.splice(target.closest("[data-index]").dataset.index, 1);
    this.activity.update({ profiles });
  }

  /* -------------------------------------------- */
  /*  Drag & Drop                                 */
  /* -------------------------------------------- */

  /**
   * Handle dropping actors onto the sheet.
   * @param {Event} event  Triggering drop event.
   */
  async #onDrop(event) {
    // Try to extract the data
    const data = foundry.applications.ux.TextEditor.implementation.getDragEventData(event);

    // Handle dropping linked items
    if ( data?.type !== "Actor" ) return;
    const actor = await Actor.implementation.fromDropData(data);

    // If dropped onto existing profile, add or replace link
    const profileId = event.target.closest("[data-profile-id]")?.dataset.profileId;
    if ( profileId ) {
      const profiles = this.activity.toObject().profiles;
      const profile = profiles.find(p => p._id === profileId);
      profile.uuid = actor.uuid;
      this.activity.update({ profiles });
    }

    // Otherwise create a new profile
    else this.activity.update({ profiles: [...this.activity.toObject().profiles, { uuid: actor.uuid }] });
  }
}

const { BooleanField: BooleanField$x, StringField: StringField$Q } = foundry.data.fields;

/**
 * Dialog for configuring the usage of the summon activity.
 */
class SummonUsageDialog extends ActivityUsageDialog {

  /** @inheritDoc */
  static PARTS = {
    ...super.PARTS,
    creation: {
      template: "systems/dnd5e/templates/activity/summon-usage-creation.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareCreationContext(context, options) {
    context = await super._prepareCreationContext(context, options);

    const profiles = this.activity.availableProfiles;
    if ( this._shouldDisplay("create.summons") && (profiles.length || (this.activity.creatureSizes.size > 1)
      || (this.activity.creatureTypes.size > 1)) ) {
      context.hasCreation = true;
      context.summonsFields = [];

      if ( !foundry.utils.hasProperty(this.options.display, "create.summons") ) context.summonsFields.push({
        field: new BooleanField$x({ label: game.i18n.localize("DND5E.SUMMON.Action.Place") }),
        name: "create.summons",
        value: this.config.create?.summons,
        input: context.inputs.createCheckboxInput
      });

      if ( this.config.create?.summons ) {
        const rollData = this.activity.getRollData();
        if ( profiles.length > 1 ) {
          let options = profiles.map(profile => ({
            value: profile._id, label: this.getProfileLabel(profile, rollData)
          }));
          if ( options.every(o => o.label.startsWith("1 × ")) ) {
            options = options.map(({ value, label }) => ({ value, label: label.replace("1 × ", "") }));
          }
          context.summonsFields.push({
            field: new StringField$Q({
              required: true, blank: false, label: game.i18n.localize("DND5E.SUMMON.Profile.Label")
            }),
            name: "summons.profile",
            value: this.config.summons?.profile,
            options
          });
        } else context.summonsProfile = profiles[0]._id;

        if ( this.activity.creatureSizes.size > 1 ) context.summonsFields.push({
          field: new StringField$Q({ label: game.i18n.localize("DND5E.Size") }),
          name: "summons.creatureSize",
          value: this.config.summons?.creatureSize,
          options: Array.from(this.activity.creatureSizes)
            .map(value => ({ value, label: CONFIG.DND5E.actorSizes[value]?.label }))
            .filter(k => k)
        });

        if ( this.activity.creatureTypes.size > 1 ) context.summonsFields.push({
          field: new StringField$Q({ label: game.i18n.localize("DND5E.CreatureType") }),
          name: "summons.creatureType",
          value: this.config.summons?.creatureType,
          options: Array.from(this.activity.creatureTypes)
            .map(value => ({ value, label: CONFIG.DND5E.creatureTypes[value]?.label }))
            .filter(k => k)
        });
      }
    }

    return context;
  }

  /* -------------------------------------------- */

  /**
   * Determine the label for a profile in the ability use dialog.
   * @param {SummonsProfile} profile  Profile for which to generate the label.
   * @param {object} rollData         Roll data used to prepare the count.
   * @returns {string}
   */
  getProfileLabel(profile, rollData) {
    let label;
    if ( profile.name ) label = profile.name;
    else {
      switch ( this.activity.summon.mode ) {
        case "cr":
          const cr = simplifyBonus(profile.cr, rollData);
          label = game.i18n.format("DND5E.SUMMON.Profile.ChallengeRatingLabel", { cr: formatCR(cr) });
          break;
        default:
          const doc = fromUuidSync(profile.uuid);
          if ( doc ) label = doc.name;
          break;
      }
    }
    label ??= "—";

    let count = simplifyRollFormula(Roll.replaceFormulaData(profile.count ?? "1", rollData));
    if ( Number.isNumeric(count) ) count = parseInt(count);
    if ( count ) label = `${count} × ${label}`;

    return label;
  }
}

/**
 * @import { FilterDescription } from "./_types.mjs";
 */

/**
 * Check some data against a filter to determine if it matches.
 * @param {object} data                                   Data to check.
 * @param {FilterDescription|FilterDescription[]} filter  Filter to compare against.
 * @returns {boolean}
 * @throws
 */
function performCheck(data, filter=[]) {
  if ( foundry.utils.getType(filter) === "Array" ) return AND(data, filter);
  return _check(data, filter.k, filter.v, filter.o);
}

/* -------------------------------------------- */

/**
 * Determine the unique keys referenced by a set of filters.
 * @param {FilterDescription[]} filter  Filter to examine.
 * @returns {Set<string>}
 */
function uniqueKeys(filter=[]) {
  const keys = new Set();
  const _uniqueKeys = filters => {
    for ( const f of filters ) {
      const operator = f.o in OPERATOR_FUNCTIONS;
      if ( operator && (foundry.utils.getType(f.v) === "Array") ) _uniqueKeys(f.v);
      else if ( f.o === "NOT" ) _uniqueKeys([f.v]);
      else if ( !operator ) keys.add(f.k);
    }
  };
  _uniqueKeys(filter);
  return keys;
}

/* -------------------------------------------- */

/**
 * Internal check implementation.
 * @param {object} data             Data to check.
 * @param {string} [keyPath]        Path to individual piece within data to check.
 * @param {*} value                 Value to compare against or additional filters.
 * @param {string} [operation="_"]  Checking function to use.
 * @returns {boolean}
 * @internal
 * @throws
 */
function _check(data, keyPath, value, operation="_") {
  const operator = OPERATOR_FUNCTIONS[operation];
  if ( operator ) return operator(data, value);

  const comparison = COMPARISON_FUNCTIONS[operation];
  if ( !comparison ) throw new Error(`Comparison function "${operation}" could not be found.`);
  return comparison(foundry.utils.getProperty(data, keyPath), value);
}

/* -------------------------------------------- */
/*  Operator Functions                          */
/* -------------------------------------------- */

/**
 * Operator functions.
 * @enum {Function}
 */
const OPERATOR_FUNCTIONS = {
  AND, NAND, OR, NOR, XOR, NOT
};

/* -------------------------------------------- */

/**
 * Perform an AND check against all filters.
 * @param {object} data                 Data to check.
 * @param {FilterDescription[]} filter  Filter to compare against.
 * @returns {boolean}
 */
function AND(data, filter) {
  return filter.every(({k, v, o}) => _check(data, k, v, o));
}

/* -------------------------------------------- */

/**
 * Perform an NAND check against all filters.
 * @param {object} data                 Data to check.
 * @param {FilterDescription[]} filter  Filter to compare against.
 * @returns {boolean}
 */
function NAND(data, filter) {
  return !filter.every(({k, v, o}) => _check(data, k, v, o));
}

/* -------------------------------------------- */

/**
 * Perform an OR check against all filters.
 * @param {object} data                 Data to check.
 * @param {FilterDescription[]} filter  Filter to compare against.
 * @returns {boolean}
 */
function OR(data, filter) {
  return filter.some(({k, v, o}) => _check(data, k, v, o));
}

/* -------------------------------------------- */

/**
 * Perform an NOR check against all filters.
 * @param {object} data                 Data to check.
 * @param {FilterDescription[]} filter  Filter to compare against.
 * @returns {boolean}
 */
function NOR(data, filter) {
  return !filter.some(({k, v, o}) => _check(data, k, v, o));
}

/* -------------------------------------------- */

/**
 * Perform an XOR check against all filters.
 * @param {object} data                 Data to check.
 * @param {FilterDescription[]} filter  Filter to compare against.
 * @returns {boolean}
 */
function XOR(data, filter) {
  if ( !filter.length ) return false;
  let currentResult = _check(data, filter[0].k, filter[0].v, filter[0].o);
  for ( let i = 1; i < filter.length; i++ ) {
    const { k, v, o } = filter[i];
    currentResult ^= _check(data, k, v, o);
  }
  return Boolean(currentResult);
}

/* -------------------------------------------- */

/**
 * Invert the result of a nested check,
 * @param {object} data               Data to check.
 * @param {FilterDescription} filter  Filter to compare against.
 * @returns {boolean}
 */
function NOT(data, filter) {
  const { k, v, o } = filter;
  return !_check(data, k, v, o);
}

/* -------------------------------------------- */
/*  Comparison Functions                        */
/* -------------------------------------------- */

/**
 * Currently supported comparison functions.
 * @enum {Function}
 */
const COMPARISON_FUNCTIONS = {
  _: exact, exact, contains, icontains, startswith, istartswith, endswith,
  has, hasany, hasall, in: in_, gt, gte, lt, lte
};

/* -------------------------------------------- */

/**
 * Check for an exact match. The default comparison mode if none is provided.
 * @param {*} data
 * @param {*} value
 * @returns {boolean}
 */
function exact(data, value) {
  return data === value;
}

/* -------------------------------------------- */

/**
 * Check that data contains value.
 * @param {*} data
 * @param {*} value
 * @returns {boolean}
 */
function contains(data, value) {
  return String(data).includes(String(value));
}

/* -------------------------------------------- */

/**
 * Case-insensitive check that data contains value.
 * @param {*} data
 * @param {*} value
 * @returns {boolean}
 */
function icontains(data, value) {
  return contains(String(data).toLocaleLowerCase(game.i18n.lang), String(value).toLocaleLowerCase(game.i18n.lang));
}

/* -------------------------------------------- */

/**
 * Check that data starts with value.
 * @param {*} data
 * @param {*} value
 * @returns {boolean}
 */
function startswith(data, value) {
  return String(data).startsWith(String(value));
}

/* -------------------------------------------- */

/**
 * Case-insensitive check that data starts with value.
 * @param {*} data
 * @param {*} value
 * @returns {boolean}
 */
function istartswith(data, value) {
  return startswith(String(data).toLocaleLowerCase(game.i18n.lang), String(value).toLocaleLowerCase(game.i18n.lang));
}

/* -------------------------------------------- */

/**
 * Check that data ends with value.
 * @param {*} data
 * @param {*} value
 * @returns {boolean}
 */
function endswith(data, value) {
  return String(data).endsWith(String(value));
}

/* -------------------------------------------- */

/**
 * Check that the data collection has the provided value.
 * @param {*} data
 * @param {*} value
 * @returns {boolean}
 */
function has(data, value) {
  // If the value is another filter description, apply that check against each member of the collection
  if ( foundry.utils.getType(value) === "Object" ) {
    switch ( foundry.utils.getType(data) ) {
      case "Array":
      case "Set": return !!data.find(d => performCheck(d, value));
      default: return false;
    }
  } else return in_(value, data);
}

/* -------------------------------------------- */

/**
 * Check that the data collection has any of the provided values.
 * @param {*} data
 * @param {*} value
 * @returns {boolean}
 */
function hasany(data, value) {
  return Array.from(value).some(v => has(data, v));
}

/* -------------------------------------------- */

/**
 * Check that the data collection has all of the provided values.
 * @param {*} data
 * @param {*} value
 * @returns {boolean}
 */
function hasall(data, value) {
  return Array.from(value).every(v => has(data, v));
}

/* -------------------------------------------- */

/**
 * Check that data matches one of the provided values.
 * @param {*} data
 * @param {*} value
 * @returns {boolean}
 */
function in_(data, value) {
  switch ( foundry.utils.getType(value) ) {
    case "Array": return value.includes(data);
    case "Set": return value.has(data);
    default: return false;
  }
}

/* -------------------------------------------- */

/**
 * Check that value is greater than data.
 * @param {*} data
 * @param {*} value
 * @returns {boolean}
 */
function gt(data, value) {
  return data > value;
}

/* -------------------------------------------- */

/**
 * Check that value is greater than or equal to data.
 * @param {*} data
 * @param {*} value
 * @returns {boolean}
 */
function gte(data, value) {
  return data >= value;
}

/* -------------------------------------------- */

/**
 * Check that value is less than data.
 * @param {*} data
 * @param {*} value
 * @returns {boolean}
 */
function lt(data, value) {
  return data < value;
}

/* -------------------------------------------- */

/**
 * Check that value is less than or equal to data.
 * @param {*} data
 * @param {*} value
 * @returns {boolean}
 */
function lte(data, value) {
  return data <= value;
}

var Filter = /*#__PURE__*/Object.freeze({
  __proto__: null,
  AND: AND,
  COMPARISON_FUNCTIONS: COMPARISON_FUNCTIONS,
  NAND: NAND,
  NOR: NOR,
  NOT: NOT,
  OPERATOR_FUNCTIONS: OPERATOR_FUNCTIONS,
  OR: OR,
  XOR: XOR,
  contains: contains,
  endswith: endswith,
  exact: exact,
  gt: gt,
  gte: gte,
  has: has,
  hasall: hasall,
  hasany: hasany,
  icontains: icontains,
  in_: in_,
  istartswith: istartswith,
  lt: lt,
  lte: lte,
  performCheck: performCheck,
  startswith: startswith,
  uniqueKeys: uniqueKeys
});

/**
 * @import { CompendiumSourcePackageConfig5e, CompendiumSourcePackGroup5e } from "../../data/settings/_types.mjs";
 * @import { CompendiumBrowserSourceConfiguration } from "./_types.mjs";
 */

/**
 * An application for configuring which compendium packs contribute their content to the compendium browser.
 * @extends Application5e<CompendiumBrowserSourceConfiguration>
 */
class CompendiumBrowserSettingsConfig extends Application5e {
  constructor(options) {
    super(options);
    this.#selected = this.options.selected;
  }

  /* -------------------------------------------- */

  /** @override */
  static DEFAULT_OPTIONS = {
    id: "compendium-browser-source-config",
    classes: ["dialog-lg"],
    tag: "form",
    window: {
      title: "DND5E.CompendiumBrowser.Sources.Label",
      icon: "fas fa-book-open-reader",
      resizable: true
    },
    position: {
      width: 800,
      height: 650
    },
    actions: {
      clearFilter: CompendiumBrowserSettingsConfig.#onClearPackageFilter,
      selectPackage: CompendiumBrowserSettingsConfig.#onSelectPackage
    },
    selected: "system"
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    sidebar: {
      id: "sidebar",
      template: "systems/dnd5e/templates/compendium/sources-sidebar.hbs"
    },
    packs: {
      id: "packs",
      template: "systems/dnd5e/templates/compendium/sources-packs.hbs"
    }
  };

  /* -------------------------------------------- */

  /**
   * The number of milliseconds to delay between user keypresses before executing the package filter.
   * @type {number}
   */
  static FILTER_DELAY = 200;

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The current package filter.
   * @type {string}
   */
  #filter = "";

  /* -------------------------------------------- */

  /**
   * The currently selected package.
   * @type {string}
   */
  #selected;

  /* -------------------------------------------- */

  _debouncedFilter = foundry.utils.debounce(this._onFilterPackages.bind(this), this.constructor.FILTER_DELAY);

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const sources = this.constructor.collateSources();
    const byPackage = { world: new Set(), system: new Set() };

    for ( const { collection, documentName, metadata } of game.packs ) {
      if ( (documentName !== "Actor") && (documentName !== "Item") ) continue;
      let entry;
      if ( (metadata.packageType === "world") || (metadata.packageType === "system") ) {
        entry = byPackage[metadata.packageType];
      }
      else entry = byPackage[`module.${metadata.packageName}`] ??= new Set();
      entry.add(collection);
    }

    const packages = { };
    packages.world = this._preparePackageContext("world", game.world, byPackage.world, sources);
    packages.system = this._preparePackageContext("system", game.system, byPackage.system, sources);

    const modules = Object.entries(byPackage).reduce((arr, [k, packs]) => {
      if ( (k === "world") || (k === "system") ) return arr;
      const id = k.slice(7);
      const module = game.modules.get(id);
      arr.push(this._preparePackageContext(k, module, packs, sources));
      return arr;
    }, []);
    modules.sort((a, b) => a.title.localeCompare(b.title, game.i18n.lang));
    packages.modules = Object.fromEntries(modules.map(m => [m.id, m]));

    const packs = { actors: {}, items: {} };
    [["actors", "Actor"], ["items", "Item"]].forEach(([p, type]) => {
      packs[p] = this._preparePackGroupContext(type, byPackage[this.#selected], sources);
    });

    return {
      ...await super._prepareContext(options),
      packages, packs,
      filter: this.#filter
    };
  }

  /* -------------------------------------------- */

  /**
   * Prepare render context for packages.
   * @param {string} id            The package identifier.
   * @param {ClientPackage} pkg    The package.
   * @param {Set<string>} packs    The packs belonging to this package.
   * @param {Set<string>} sources  The packs currently selected for inclusion.
   * @returns {CompendiumSourcePackageConfig5e}
   * @protected
   */
  _preparePackageContext(id, pkg, packs, sources) {
    const { title } = pkg;
    const all = packs.isSubsetOf(sources);
    const indeterminate = !all && packs.intersects(sources);
    return {
      id, title, indeterminate,
      checked: indeterminate || all,
      count: packs.size,
      active: this.#selected === id,
      filter: title.replace(/[^\p{L} ]/gu, "").toLocaleLowerCase(game.i18n.lang)
    };
  }

  /* -------------------------------------------- */

  /**
   * Prepare render context for pack groups.
   * @param {string} documentType    The pack group's Document type.
   * @param {Set<string>} packs      The packs provided by the selected package.
   * @param {Set<string>} sources    The packs currently selected for inclusion.
   * @returns {CompendiumSourcePackGroup5e}
   * @protected
   */
  _preparePackGroupContext(documentType, packs, sources) {
    packs = packs.filter(id => {
      const pack = game.packs.get(id);
      return pack.documentName === documentType;
    });
    const all = packs.isSubsetOf(sources);
    const indeterminate = !all && packs.intersects(sources);
    return {
      indeterminate,
      checked: indeterminate || all,
      entries: Array.from(packs.map(id => {
        const { collection, title, metadata } = game.packs.get(id);
        const { packageName, flags } = metadata;
        let tag = "";
        // Special case handling for D&D SRD.
        if ( packageName === "dnd5e" ) {
          tag = flags?.dnd5e?.sourceBook?.replace("SRD ", "");
        }
        return {
          tag, title,
          id: collection,
          checked: sources.has(id)
        };
      })).sort((a, b) => {
        return a.tag?.localeCompare(b.tag) || a.title.localeCompare(b.title, game.i18n.lang);
      })
    };
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _attachFrameListeners() {
    super._attachFrameListeners();
    this.element.addEventListener("keydown", this._debouncedFilter, { passive: true });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _attachPartListeners(partId, htmlElement, options) {
    super._attachPartListeners(partId, htmlElement, options);
    if ( partId === "sidebar" ) this._filterPackages();
  }

  /* -------------------------------------------- */

  /**
   * Execute the package list filter.
   * @protected
   */
  _filterPackages() {
    const query = this.#filter.replace(/[^\p{L} ]/gu, "").toLocaleLowerCase(game.i18n.lang);
    this.element.querySelectorAll(".package-list.modules > li").forEach(item => {
      item.toggleAttribute("hidden", query && !item.dataset.filter.includes(query));
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onChangeForm(formConfig, event) {
    super._onChangeForm(formConfig, event);
    if ( event.target.dataset.type ) this._onToggleSource(event.target);
  }

  /* -------------------------------------------- */

  /**
   * Handle filtering the package sidebar.
   * @param {KeyboardEvent} event  The triggering event.
   * @protected
   */
  _onFilterPackages(event) {
    if ( !event.target.matches("search > input") ) return;
    this.#filter = event.target.value;
    this._filterPackages();
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling a compendium browser source pack.
   * @param {CheckboxElement} target  The element that was toggled.
   * @returns {Record<string, boolean>}
   * @protected
   */
  _onTogglePack(target) {
    const packs = {};
    const { name, checked, indeterminate } = target;
    if ( (name === "all-items") || (name === "all-actors") ) {
      const [, documentType] = name.split("-");
      const pkg = this.#selected === "world"
        ? game.world
        : this.#selected === "system"
          ? game.system
          : game.modules.get(this.#selected.slice(7));
      for ( const { id, type } of pkg.packs ) {
        if ( game[documentType].documentName === type ) packs[id] = indeterminate ? false : checked;
      }
    }
    else packs[name] = checked;
    return packs;
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling a compendium browser source package.
   * @param {CheckboxElement} target  The element that was toggled.
   * @returns {Record<string, boolean>}
   * @protected
   */
  _onTogglePackage(target) {
    const packs = {};
    const { name, checked, indeterminate } = target;
    const pkg = name === "world" ? game.world : name === "system" ? game.system : game.modules.get(name.slice(7));
    for ( const { id } of pkg.packs ) packs[id] = indeterminate ? false : checked;
    return packs;
  }

  /* -------------------------------------------- */

  /**
   * Toggle a compendium browser source.
   * @param {CheckboxElement} target  The element that was toggled.
   * @protected
   */
  async _onToggleSource(target) {
    let packs;
    switch ( target.dataset.type ) {
      case "pack": packs = this._onTogglePack(target); break;
      case "package": packs = this._onTogglePackage(target); break;
      default: return;
    }
    const setting = { ...game.settings.get("dnd5e", "packSourceConfiguration"), ...packs };
    await game.settings.set("dnd5e", "packSourceConfiguration", setting);
    this.render();
  }

  /* -------------------------------------------- */

  /**
   * Handle clearing the package filter.
   * @this {CompendiumBrowserSettingsConfig}
   * @param {PointerEvent} event  The originating click event.
   * @param {HTMLElement} target  The target of the click event.
   */
  static #onClearPackageFilter(event, target) {
    const input = target.closest("search").querySelector(":scope > input");
    input.value = this.#filter = "";
    this._filterPackages();
  }

  /* -------------------------------------------- */

  /**
   * Handle selecting a package.
   * @this {CompendiumBrowserSettingsConfig}
   * @param {PointerEvent} event  The originating click event.
   * @param {HTMLElement} target  The target of the click event.
   */
  static #onSelectPackage(event, target) {
    const { packageId } = target.closest("[data-package-id]")?.dataset ?? {};
    if ( !packageId ) return;
    this.#selected = packageId;
    this.render();
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Collate sources for inclusion in the compendium browser.
   * @returns {Set<string>}  The set of packs that should be included in the compendium browser.
   */
  static collateSources() {
    const sources = new Set();
    const setting = game.settings.get("dnd5e", "packSourceConfiguration");
    for ( const { collection, documentName } of game.packs ) {
      if ( (documentName !== "Actor") && (documentName !== "Item") ) continue;
      if ( setting[collection] !== false ) sources.add(collection);
    }
    return sources;
  }
}

/**
 * @import { FilterDescription } from "../_types.mjs";
 * @import {
 *   CompendiumBrowserConfiguration, CompendiumBrowserFilterDefinition, CompendiumBrowserFilters
 * } from "./_types.mjs";
 */

/**
 * Application for browsing, filtering, and searching for content between multiple compendiums.
 * @extends Application5e
 * @template CompendiumBrowserConfiguration
 */
class CompendiumBrowser extends Application5e {
  constructor(...args) {
    super(...args);

    this.#filters = this.options.filters?.initial ?? {};

    if ( "mode" in this.options ) {
      this._mode = this.options.mode;
      this._applyModeFilters(this.options.mode);
    }

    if ( foundry.utils.isEmpty(this.options.filters.locked) ) {
      const isAdvanced = this._mode === this.constructor.MODES.ADVANCED;
      const tab = this.constructor.TABS.find(t => t.tab === this.options.tab);
      if ( !tab || (!!tab.advanced !== isAdvanced) ) this.options.tab = isAdvanced ? "actors" : "classes";
      this._applyTabFilters(this.options.tab, { keepFilters: true });
    }
  }

  /* -------------------------------------------- */

  /** @override */
  static DEFAULT_OPTIONS = {
    id: "compendium-browser-{id}",
    classes: ["compendium-browser", "vertical-tabs", "dialog-lg"],
    tag: "form",
    window: {
      title: "DND5E.CompendiumBrowser.Title",
      minimizable: true,
      resizable: true
    },
    actions: {
      configureSources: CompendiumBrowser.#onConfigureSources,
      clearName: CompendiumBrowser.#onClearName,
      openLink: CompendiumBrowser.#onOpenLink,
      setFilter: CompendiumBrowser.#onSetFilter,
      setType: CompendiumBrowser.#onSetType,
      toggleMode: CompendiumBrowser.#onToggleMode
    },
    form: {
      handler: CompendiumBrowser.#onHandleSubmit,
      closeOnSubmit: true
    },
    hint: null,
    position: {
      width: 850,
      height: 700
    },
    filters: {
      locked: {},
      initial: {
        documentClass: "Item",
        types: new Set(["class"])
      }
    },
    selection: {
      min: null,
      max: null
    },
    tab: "classes"
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    header: {
      id: "header",
      classes: ["header"],
      template: "systems/dnd5e/templates/compendium/browser-header.hbs"
    },
    search: {
      id: "sidebar-search",
      classes: ["filter-element"],
      container: { id: "sidebar", classes: ["sidebar", "flexcol"] },
      template: "systems/dnd5e/templates/compendium/browser-sidebar-search.hbs"
    },
    types: {
      id: "sidebar-types",
      container: { id: "sidebar", classes: ["sidebar", "flexcol"] },
      template: "systems/dnd5e/templates/compendium/browser-sidebar-types.hbs"
    },
    filters: {
      id: "sidebar-filters",
      container: { id: "sidebar", classes: ["sidebar", "flexcol"] },
      template: "systems/dnd5e/templates/compendium/browser-sidebar-filters.hbs",
      templates: ["systems/dnd5e/templates/compendium/browser-sidebar-filter-set.hbs"]
    },
    results: {
      id: "results",
      classes: ["results"],
      template: "systems/dnd5e/templates/compendium/browser-results.hbs",
      templates: ["systems/dnd5e/templates/compendium/browser-entry.hbs"],
      scrollable: [""]
    },
    footer: {
      id: "footer",
      classes: ["footer"],
      template: "systems/dnd5e/templates/compendium/browser-footer.hbs"
    },
    tabs: {
      id: "tabs",
      classes: ["tabs", "tabs-left"],
      template: "systems/dnd5e/templates/compendium/browser-tabs.hbs"
    }
  };

  /* -------------------------------------------- */

  /**
   * Application tabs.
   * @type {CompendiumBrowserTabDescriptor5e[]}
   */
  static TABS = [
    {
      tab: "classes",
      label: "TYPES.Item.classPl",
      svg: "systems/dnd5e/icons/svg/items/class.svg",
      documentClass: "Item",
      types: ["class"]
    },
    {
      tab: "subclasses",
      label: "TYPES.Item.subclassPl",
      svg: "systems/dnd5e/icons/svg/items/subclass.svg",
      documentClass: "Item",
      types: ["subclass"]
    },
    {
      tab: "races",
      label: "TYPES.Item.racePl",
      svg: "systems/dnd5e/icons/svg/items/race.svg",
      documentClass: "Item",
      types: ["race"]
    },
    {
      tab: "feats",
      label: "DND5E.CompendiumBrowser.Tabs.Feat.other",
      icon: "fas fa-star",
      documentClass: "Item",
      types: ["feat"]
    },
    {
      tab: "backgrounds",
      label: "TYPES.Item.backgroundPl",
      svg: "systems/dnd5e/icons/svg/items/background.svg",
      documentClass: "Item",
      types: ["background"]
    },
    {
      tab: "physical",
      label: "DND5E.CompendiumBrowser.Tabs.Item.other",
      svg: "systems/dnd5e/icons/svg/backpack.svg",
      documentClass: "Item",
      types: ["physical"]
    },
    {
      tab: "spells",
      label: "TYPES.Item.spellPl",
      icon: "fas fa-book",
      documentClass: "Item",
      types: ["spell"]
    },
    {
      tab: "monsters",
      label: "DND5E.CompendiumBrowser.Tabs.Monster.other",
      svg: "systems/dnd5e/icons/svg/actors/npc.svg",
      documentClass: "Actor",
      types: ["npc"]
    },
    {
      tab: "vehicles",
      label: "TYPES.Actor.vehiclePl",
      svg: "systems/dnd5e/icons/svg/actors/vehicle.svg",
      documentClass: "Actor",
      types: ["vehicle"]
    },
    {
      tab: "actors",
      label: "DOCUMENT.Actors",
      svg: "systems/dnd5e/icons/svg/actors/npc.svg",
      documentClass: "Actor",
      advanced: true
    },
    {
      tab: "items",
      label: "DOCUMENT.Items",
      svg: "systems/dnd5e/icons/svg/backpack.svg",
      documentClass: "Item",
      advanced: true
    }
  ];

  /* -------------------------------------------- */

  /**
   * Available filtering modes.
   * @enum {number}
   */
  static MODES = {
    BASIC: 1,
    ADVANCED: 2
  };

  /* -------------------------------------------- */

  /**
   * Batching configuration.
   * @type {Record<string, number>}
   */
  static BATCHING = {
    /**
     * The number of pixels before reaching the end of the scroll container to begin loading additional entries.
     */
    MARGIN: 50,

    /**
     * The number of entries to load per batch.
     */
    SIZE: 50
  };

  /* -------------------------------------------- */

  /**
   * The number of milliseconds to delay between user keypresses before executing a search.
   * @type {number}
   */
  static SEARCH_DELAY = 200;

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Should the selection controls be displayed?
   * @type {boolean}
   */
  get displaySelection() {
    return !!this.options.selection.min || !!this.options.selection.max;
  }

  /* -------------------------------------------- */

  /**
   * Currently defined filters.
   */
  #filters;

  /**
   * Current filters selected.
   * @type {CompendiumBrowserFilters}
   */
  get currentFilters() {
    const filters = foundry.utils.mergeObject(
      this.#filters,
      this.options.filters.locked,
      { inplace: false }
    );
    filters.documentClass ??= "Item";
    if ( filters.additional?.source ) {
      filters.additional.source = Object.entries(filters.additional.source).reduce((obj, [k, v]) => {
        obj[k.slugify({ strict: true })] = v;
        return obj;
      }, {});
    }
    return filters;
  }

  /* -------------------------------------------- */

  /**
   * Fetched results.
   * @type {Promise<object[]|Document[]>|object[]|Document[]}
   */
  #results;

  /* -------------------------------------------- */

  /**
   * The index of the next result to render as part of batching.
   * @type {number}
   */
  #resultIndex = -1;

  /* -------------------------------------------- */

  /**
   * Whether rendering is currently throttled.
   * @type {boolean}
   */
  #renderThrottle = false;

  /* -------------------------------------------- */

  /**
   * UUIDs of currently selected documents.
   * @type {Set<string>}
   */
  #selected = new Set();

  get selected() {
    return this.#selected;
  }

  /* -------------------------------------------- */

  /**
   * Suffix used for localization selection messages based on min and max values.
   * @type {string|null}
   */
  get #selectionLocalizationSuffix() {
    const max = this.options.selection.max;
    const min = this.options.selection.min;
    if ( !min && !max ) return null;
    if ( !min && max ) return "Max";
    if ( min && !max ) return "Min";
    if ( min !== max ) return "Range";
    return "Single";
  }

  /* -------------------------------------------- */

  /**
   * The cached set of available sources to filter on.
   * @type {Record<string, string>}
   */
  #sources;

  /* -------------------------------------------- */

  /**
   * The mode the browser is currently in.
   * @type {CompendiumBrowser.MODES}
   */
  _mode = this.constructor.MODES.BASIC;

  /* -------------------------------------------- */

  /**
   * The function to invoke when searching results by name.
   * @type {Function}
   */
  _debouncedSearch = foundry.utils.debounce(this._onSearchName.bind(this), this.constructor.SEARCH_DELAY);

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _configureRenderOptions(options) {
    super._configureRenderOptions(options);
    if ( options.isFirstRender ) {
      const tab = this.constructor.TABS.find(t => t.tab === this.options.tab);
      if ( tab ) foundry.utils.setProperty(options, "dnd5e.browser.types", tab.types);
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    context.filters = this.currentFilters;

    let dataModels = Object.entries(CONFIG[context.filters.documentClass].dataModels);
    if ( context.filters.types?.size ) dataModels = dataModels.filter(([type]) => context.filters.types.has(type));
    context.filterDefinitions = dataModels
      .map(([, d]) => d.compendiumBrowserFilters ?? new Map())
      .reduce((final, second) => CompendiumBrowser.intersectFilters(second, final, context.filters), null) ?? new Map();
    context.filterDefinitions.set("source", {
      label: "DND5E.SOURCE.FIELDS.source.label",
      type: "set",
      config: {
        keyPath: "system.source.slug",
        choices: foundry.utils.mergeObject(
          this.#sources ?? {},
          Object.fromEntries(Object.keys(this.options.filters?.locked?.additional?.source ?? {}).map(k => {
            return [k.slugify({ strict: true }), CONFIG.DND5E.sourceBooks[k] ?? k];
          })), { inplace: false }
        )
      }
    });
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    await super._preparePartContext(partId, context, options);
    switch ( partId ) {
      case "documentClass":
      case "search":
      case "types":
      case "filters": return this._prepareSidebarContext(partId, context, options);
      case "results": return this._prepareResultsContext(context, options);
      case "footer": return this._prepareFooterContext(context, options);
      case "tabs": return this._prepareTabsContext(context, options);
      case "header": return this._prepareHeaderContext(context, options);
    }
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare the footer context.
   * @param {ApplicationRenderContext} context     Shared context provided by _prepareContext.
   * @param {HandlebarsRenderOptions} options      Options which configure application rendering behavior.
   * @returns {Promise<ApplicationRenderContext>}  Context data for a specific part.
   * @protected
   */
  async _prepareFooterContext(context, options) {
    const value = this.#selected.size;
    const { max, min } = this.options.selection;

    context.displaySelection = this.displaySelection;
    context.invalid = (value < (min || -Infinity)) || (value > (max || Infinity));
    const suffix = this.#selectionLocalizationSuffix;
    context.summary = suffix ? game.i18n.format(
      `DND5E.CompendiumBrowser.Selection.Summary.${suffix}`, { max, min, value }
    ) : value;
    const pr = getPluralRules();
    context.invalidTooltip = game.i18n.format(`DND5E.CompendiumBrowser.Selection.Warning.${suffix}`, {
      max, min, value,
      document: game.i18n.localize(`DND5E.CompendiumBrowser.Selection.Warning.Document.${pr.select(max || min)}`)
    });
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare the header context.
   * @param {ApplicationRenderContext} context  Shared context provided by _prepareContext.
   * @param {HandlebarsRenderOptions} options   Options which configure rendering behavior.
   * @returns {Promise<ApplicationRenderContext>}
   * @protected
   */
  async _prepareHeaderContext(context, options) {
    context.showModeToggle = foundry.utils.isEmpty(this.options.filters.locked);
    context.isAdvanced = this._mode === this.constructor.MODES.ADVANCED;
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare the sidebar context.
   * @param {string} partId                        The part being rendered.
   * @param {ApplicationRenderContext} context     Shared context provided by _prepareContext.
   * @param {HandlebarsRenderOptions} options      Options which configure application rendering behavior.
   * @returns {Promise<ApplicationRenderContext>}  Context data for a specific part.
   * @protected
   */
  async _prepareSidebarContext(partId, context, options) {
    context.isLocked = {};
    context.isLocked.filters = ("additional" in this.options.filters.locked);
    context.isLocked.types = ("types" in this.options.filters.locked) || context.isLocked.filters;
    context.isLocked.documentClass = ("documentClass" in this.options.filters.locked) || context.isLocked.types;
    const types = foundry.utils.getProperty(options, "dnd5e.browser.types") ?? [];

    if ( partId === "search" ) {
      context.name = this.#filters.name;
    }

    else if ( partId === "types" ) {
      context.showTypes = (types.length !== 1) || (types[0] === "physical");
      context.types = CONFIG[context.filters.documentClass].documentClass.compendiumBrowserTypes({
        chosen: context.filters.types
      });

      // Special case handling for 'Items' tab in basic mode.
      if ( types[0] === "physical" ) context.types = context.types.physical.children;

      if ( context.isLocked.types ) {
        for ( const [key, value] of Object.entries(context.types) ) {
          if ( !value.children && !value.chosen ) delete context.types[key];
          else if ( value.children ) {
            for ( const [k, v] of Object.entries(value.children) ) {
              if ( !v.chosen ) delete value.children[k];
            }
            if ( foundry.utils.isEmpty(value.children) ) delete context.types[key];
          }
        }
      }
    }

    else if ( partId === "filters" ) {
      context.additional = Array.from(context.filterDefinitions?.entries() ?? []).reduce((arr, [key, data]) => {
        // Special case handling for 'Feats' tab in basic mode.
        if ( (types[0] === "feat") && (key === "category") ) return arr;

        let sort = 0;
        switch ( data.type ) {
          case "boolean": sort = 1; break;
          case "range": sort = 2; break;
          case "set": sort = 3; break;
        }

        const generateLocked = data => {
          if ( foundry.utils.getType(data) === "Object" ) {
            return Object.fromEntries(Object.entries(data).map(([k, v]) => [k, generateLocked(v)]));
          }
          return data !== undefined;
        };

        const pushFilter = data => arr.push(foundry.utils.mergeObject(data, {
          key, sort,
          value: context.filters.additional?.[key],
          locked: generateLocked(this.options.filters.locked?.additional?.[key])
        }, { inplace: false }));

        data.expandId = key;

        if ( data.type === "set" ) {
          const groups = Object.entries(data.config.choices).reduce((groups, [k, v]) => {
            groups[v.group] ??= {};
            groups[v.group][k] = v;
            return groups;
          }, {});
          if ( Object.keys(groups).length > 1 ) Object.entries(groups).forEach(([group, choices], index) => pushFilter({
            ...data,
            expandId: `${key}-${group}`,
            expanded: this.expandedSections.get(`${key}-${group}`) ?? !data.config.collapseGroup?.(group),
            label: game.i18n.format("DND5E.CompendiumBrowser.Filters.Grouped", {
              type: game.i18n.localize(data.label), group
            }),
            config: { ...data.config, choices }
          }));

          else pushFilter({
            ...data, expanded: this.expandedSections.get(data.expandId) ?? !data.collapseGroup?.(null)
          });
        }
        else pushFilter(data);

        return arr;
      }, []);

      context.additional.sort((a, b) => a.sort - b.sort);
    }

    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare the results context.
   * @param {ApplicationRenderContext} context     Shared context provided by _prepareContext.
   * @param {HandlebarsRenderOptions} options      Options which configure application rendering behavior.
   * @returns {Promise<ApplicationRenderContext>}  Context data for a specific part.
   * @protected
   */
  async _prepareResultsContext(context, options) {
    // TODO: Determine if new set of results need to be fetched, otherwise use old results and re-sort as necessary
    // Sorting changes alone shouldn't require a re-fetch, but any change to filters will
    const filters = CompendiumBrowser.applyFilters(context.filterDefinitions, context.filters);
    // Add the name & arbitrary filters
    if ( this.#filters.name?.length ) filters.push({ k: "name", o: "icontains", v: this.#filters.name });
    if ( context.filters.arbitrary?.length ) filters.push(...context.filters.arbitrary);
    this.#results = CompendiumBrowser.fetch(CONFIG[context.filters.documentClass].documentClass, {
      filters,
      types: context.filters.types,
      indexFields: new Set(["system.source"])
    });
    context.displaySelection = this.displaySelection;
    context.hint = this.options.hint;
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare the tabs context.
   * @param {ApplicationRenderContext} context  Shared context provided by _prepareContext.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {Promise<ApplicationRenderContext>}
   * @protected
   */
  async _prepareTabsContext(context, options) {
    // If we are locked to a particular filter, do not show tabs.
    if ( !foundry.utils.isEmpty(this.options.filters.locked) ) {
      context.tabs = [];
      return context;
    }

    const advanced = this._mode === this.constructor.MODES.ADVANCED;
    context.tabs = foundry.utils.deepClone(this.constructor.TABS.filter(t => !!t.advanced === advanced));
    const tab = options.isFirstRender ? this.options.tab : this.tabGroups.primary;
    const activeTab = context.tabs.find(t => t.tab === tab) ?? context.tabs[0];
    activeTab.active = true;

    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _renderFrame(options) {
    const frame = await super._renderFrame(options);
    if ( game.user.isGM ) {
      frame.querySelector('[data-action="close"]').insertAdjacentHTML("beforebegin", `
        <button type="button" class="header-control fas fa-cog icon" data-action="configureSources"
                data-tooltip aria-label="${game.i18n.localize("DND5E.CompendiumBrowser.Sources.Label")}"></button>
      `);
    }
    return frame;
  }

  /* -------------------------------------------- */

  /**
   * Render a single result entry.
   * @param {object|Document} entry  The entry.
   * @param {string} documentClass   The entry's Document class.
   * @returns {Promise<HTMLElement>}
   * @protected
   */
  async _renderResult(entry, documentClass) {
    const { img, name, type, uuid, system } = entry;
    // TODO: Provide more useful subtitles.
    const subtitle = CONFIG[documentClass].typeLabels[type] ?? "";
    const source = system?.source?.value ?? "";
    const context = {
      entry: { img, name, subtitle, uuid, source },
      displaySelection: this.displaySelection,
      selected: this.#selected.has(uuid)
    };
    const html = await foundry.applications.handlebars.renderTemplate(
      "systems/dnd5e/templates/compendium/browser-entry.hbs", context
    );
    const template = document.createElement("template");
    template.innerHTML = html;
    const element = template.content.firstElementChild;
    if ( documentClass !== "Item" ) return element;
    element.dataset.tooltip = `
      <section class="loading" data-uuid="${uuid}">
        <i class="fa-solid fa-spinner fa-spin-pulse" inert></i>
      </section>
    `;
    element.dataset.tooltipClass = "dnd5e2 dnd5e-tooltip item-tooltip";
    element.dataset.tooltipDirection ??= "RIGHT";
    return element;
  }

  /* -------------------------------------------- */

  /**
   * Render results once loaded to avoid holding up initial app display.
   * @protected
   */
  async _renderResults() {
    let rendered = [];
    const { documentClass } = this.currentFilters;
    const results = await this.#results;
    this.#results = results;
    const batchEnd = Math.min(this.constructor.BATCHING.SIZE, results.length);
    for ( let i = 0; i < batchEnd; i++ ) {
      rendered.push(this._renderResult(results[i], documentClass));
    }
    this.element.querySelector(".results-loading").hidden = true;
    this.element.querySelector('[data-application-part="results"] .item-list')
      .replaceChildren(...(await Promise.all(rendered)));
    this.#resultIndex = batchEnd;
  }

  /* -------------------------------------------- */

  /**
   * Show a list of applicable source filters for the available results.
   * @protected
   */
  async _renderSourceFilters() {
    const sources = [];
    for ( const result of this.#results ) {
      const source = foundry.utils.getProperty(result, "system.source");
      if ( foundry.utils.getType(source) !== "Object" ) continue;
      const { slug, value } = source;
      sources.push({ slug, value: CONFIG.DND5E.sourceBooks[value] ?? value });
    }
    sources.sort((a, b) => a.value.localeCompare(b.value, game.i18n.lang));
    this.#sources = Object.fromEntries(sources.map(({ slug, value }) => [slug, value]));
    const filters = this.element.querySelector('[data-application-part="filters"]');
    filters.querySelector('[data-filter-id="source"]')?.remove();
    if ( !sources.length ) return;
    const locked = Object.entries(this.options.filters?.locked?.additional?.source ?? {}).reduce((obj, [k, v]) => {
      obj[k.slugify({ strict: true })] = v;
      return obj;
    }, {});
    const filter = await foundry.applications.handlebars.renderTemplate(
      "systems/dnd5e/templates/compendium/browser-sidebar-filter-set.hbs",
      {
        locked,
        value: locked,
        key: "source",
        expandId: "source",
        label: "DND5E.SOURCE.FIELDS.source.label",
        config: { choices: this.#sources },
        partId: `${this.id}-filters`
      }
    );
    filters.insertAdjacentHTML("beforeend", filter);
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @inheritDoc */
  changeTab(tab, group, options={}) {
    super.changeTab(tab, group, options);
    const target = this.element.querySelector(`nav.tabs [data-group="${group}"][data-tab="${tab}"]`);
    let { types } = target.dataset;
    types = types ? types.split(",") : [];
    this._applyTabFilters(tab);
    this.render({ parts: ["results", "filters", "types"], dnd5e: { browser: { types } }, changedTab: true });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _attachFrameListeners() {
    super._attachFrameListeners();
    this.element.addEventListener("scroll", this._onScrollResults.bind(this), { capture: true, passive: true });
    this.element.addEventListener("dragstart", this._onDragStart.bind(this));
    this.element.addEventListener("keydown", this._debouncedSearch, { passive: true });
    this.element.addEventListener("keydown", this._onKeyAction.bind(this), { passive: true });
    this.element.addEventListener("pointerdown", event => {
      if ( (event.button === 1) && document.getElementById("tooltip")?.classList.contains("active") ) {
        event.preventDefault();
      }
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _attachPartListeners(partId, htmlElement, options) {
    super._attachPartListeners(partId, htmlElement, options);
    if ( partId === "results" ) this._renderResults().then(() => {
      if ( options.isFirstRender || options.changedTab ) this._renderSourceFilters();
    });
    else if ( partId === "types" ) this.#adjustCheckboxStates(htmlElement);
  }

  /* -------------------------------------------- */

  /**
   * Apply filters based on the compendium browser's mode.
   * @param {CompendiumBrowser.MODES} mode  The mode.
   * @protected
   */
  _applyModeFilters(mode) {
    const isAdvanced = mode === this.constructor.MODES.ADVANCED;
    delete this.#filters.types;
    delete this.#filters.additional;
    if ( isAdvanced ) this.#filters.documentClass = "Actor";
    else {
      this.#filters.documentClass = "Item";
      this.#filters.types = new Set(["class"]);
    }
  }

  /* -------------------------------------------- */

  /**
   * Apply filters based on the selected tab.
   * @param {string} id                           The tab ID.
   * @param {object} [options]                    Additional options
   * @param {boolean} [options.keepFilters=false] Whether to keep the existing additional filters
   * @protected
   */
  _applyTabFilters(id, { keepFilters=false }={}) {
    const tab = this.constructor.TABS.find(t => t.tab === id);
    if ( !tab ) return;
    const { documentClass, types } = tab;
    if ( !keepFilters ) delete this.#filters.additional;
    this.#filters.documentClass = documentClass;
    this.#filters.types = new Set(types);

    // Special case handling for 'Items' tab in basic mode.
    if ( id === "physical" ) {
      const physical = Item.implementation.compendiumBrowserTypes().physical.children;
      Object.keys(physical).forEach(this.#filters.types.add, this.#filters.types);
    }

    // Special case handling for 'Feats' tab in basic mode.
    if ( id === "feats" ) {
      this.#filters.additional ??= {};
      foundry.utils.mergeObject(this.#filters.additional, { category: { feat: 1 } });
    }
  }

  /* -------------------------------------------- */

  /**
   * Adjust the states of group checkboxes to make then indeterminate if only some of their children are selected.
   * @param {HTMLElement} htmlElement  Element within which to find groups.
   */
  #adjustCheckboxStates(htmlElement) {
    for ( const groupArea of htmlElement.querySelectorAll(".type-group") ) {
      const group = groupArea.querySelector(".type-group-header dnd5e-checkbox");
      const children = groupArea.querySelectorAll(".wrapper dnd5e-checkbox");
      if ( Array.from(children).every(e => e.checked) ) {
        group.checked = true;
        group.indeterminate = false;
      } else {
        group.checked = group.indeterminate = Array.from(children).some(e => e.checked);
      }
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _onChangeForm(formConfig, event) {
    if ( event.target.name === "selected" ) {
      if ( event.target.checked ) this.#selected.add(event.target.value);
      else this.#selected.delete(event.target.value);
      event.target.closest(".item").classList.toggle("selected", event.target.checked);
      this.render({ parts: ["footer"] });
    }
    if ( event.target.name?.startsWith("additional.") ) CompendiumBrowser.#onSetFilter.call(this, event, event.target);
  }

  /* -------------------------------------------- */

  /**
   * Handle dragging an entry.
   * @param {DragEvent} event  The drag event.
   * @protected
   */
  _onDragStart(event) {
    const { uuid } = event.target.closest("[data-uuid]")?.dataset ?? {};
    try {
      const { type } = foundry.utils.parseUuid(uuid);
      event.dataTransfer.setData("text/plain", JSON.stringify({ type, uuid }));
    } catch(e) {
      console.error(e);
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle triggering an action via keyboard.
   * @param {KeyboardEvent} event  The originating event.
   * @protected
   */
  _onKeyAction(event) {
    const target = event.target.closest("[data-action]");
    if ( (event.key !== " ") || !target ) return;
    const { action } = target.dataset;
    const handler = this.options.actions[action];
    if ( handler ) handler.call(this, event, target);
  }

  /* -------------------------------------------- */

  /**
   * Handle rendering a new batch of results when the user scrolls to the bottom of the list.
   * @param {Event} event  The originating scroll event.
   * @protected
   */
  async _onScrollResults(event) {
    if ( this.#renderThrottle || !event.target.matches('[data-application-part="results"]') ) return;
    if ( (this.#results instanceof Promise) || (this.#resultIndex >= this.#results.length) ) return;
    const { scrollTop, scrollHeight, clientHeight } = event.target;
    if ( scrollTop + clientHeight < scrollHeight - this.constructor.BATCHING.MARGIN ) return;
    this.#renderThrottle = true;
    const { documentClass } = this.currentFilters;
    const rendered = [];
    const batchStart = this.#resultIndex;
    const batchEnd = Math.min(batchStart + this.constructor.BATCHING.SIZE, this.#results.length);
    for ( let i = batchStart; i < batchEnd; i++ ) {
      rendered.push(this._renderResult(this.#results[i], documentClass));
    }
    this.element.querySelector('[data-application-part="results"] .item-list').append(...(await Promise.all(rendered)));
    this.#resultIndex = batchEnd;
    this.#renderThrottle = false;
  }

  /* -------------------------------------------- */

  /**
   * Handle searching for a Document by name.
   * @param {KeyboardEvent} event  The triggering event.
   * @protected
   */
  _onSearchName(event) {
    if ( !event.target.matches("search > input") ) return;
    this.#filters.name = event.target.value;
    this.render({ parts: ["results"] });
  }

  /* -------------------------------------------- */

  /**
   * Handle configuring compendium browser sources.
   * @this {CompendiumBrowser}
   */
  static #onConfigureSources() {
    new CompendiumBrowserSettingsConfig().render({ force: true });
  }

  /* -------------------------------------------- */

  /**
   * Handle clearing the name filter.
   * @this {CompendiumBrowser}
   * @param {PointerEvent} event  The originating click event.
   * @param {HTMLElement} target  The target of the click event.
   */
  static async #onClearName(event, target) {
    const input = target.closest("search").querySelector(":scope > input");
    input.value = this.#filters.name = "";
    this.render({ parts: ["results"] });
  }

  /* -------------------------------------------- */

  /**
   * Handle form submission with selection.
   * @this {CompendiumBrowser}
   * @param {SubmitEvent} event          The form submission event.
   * @param {HTMLFormElement} form       The submitted form element.
   * @param {FormDataExtended} formData  The data from the submitted form.
   */
  static async #onHandleSubmit(event, form, formData) {
    if ( !this.displaySelection ) return;

    const value = this.#selected.size;
    const { max, min } = this.options.selection;
    if ( (value < (min || -Infinity)) || (value > (max || Infinity)) ) {
      const suffix = this.#selectionLocalizationSuffix;
      const pr = getPluralRules();
      throw new Error(game.i18n.format(`DND5E.CompendiumBrowser.Selection.Warning.${suffix}`, {
        max, min, value,
        document: game.i18n.localize(`DND5E.CompendiumBrowser.Selection.Warning.Document.${pr.select(max || min)}`)
      }));
    }

    /**
     * Hook event that fires when a compendium browser is submitted with selected items.
     * @function dnd5e.compendiumBrowserSelection
     * @memberof hookEvents
     * @param {CompendiumBrowser} browser  Compendium Browser application being submitted.
     * @param {Set<string>} selected       Set of document UUIDs that are selected.
     */
    Hooks.callAll("dnd5e.compendiumBrowserSelection", this, this.#selected);
  }

  /* -------------------------------------------- */

  /**
   * Handle opening a link to an item.
   * @this {CompendiumBrowser}
   * @param {PointerEvent} event  The originating click event.
   * @param {HTMLElement} target  The capturing HTML element which defined a [data-action].
   */
  static async #onOpenLink(event, target) {
    (await fromUuid(target.closest("[data-uuid]")?.dataset.uuid))?.sheet?.render(true);
  }

  /* -------------------------------------------- */

  /**
   * Handle setting the document class or a filter.
   * @this {CompendiumBrowser}
   * @param {PointerEvent} event  The originating click event.
   * @param {HTMLElement} target  The capturing HTML element which defined a [data-action].
   */
  static async #onSetFilter(event, target) {
    const name = target.name;
    const value = target.value;
    const existingValue = foundry.utils.getProperty(this.#filters, name);
    if ( value === existingValue ) return;
    foundry.utils.setProperty(this.#filters, name, value === "" ? undefined : value);

    if ( target.tagName === "BUTTON" ) for ( const button of this.element.querySelectorAll(`[name="${name}"]`) ) {
      button.ariaPressed = button.value === value;
    }

    const activeTab = this.constructor.TABS.find(t => t.tab === this.tabGroups.primary);
    this.render({ parts: ["filters", "results"], dnd5e: { browser: { types: activeTab?.types } } });
  }

  /* -------------------------------------------- */

  /**
   * Handle setting a type restriction.
   * @this {CompendiumBrowser}
   * @param {PointerEvent} event  The originating click event.
   * @param {HTMLElement} target  The capturing HTML element which defined a [data-action].
   */
  static async #onSetType(event, target) {
    this.#filters.types ??= new Set();

    if ( target.defaultValue ) {
      if ( target.checked ) this.#filters.types.add(target.defaultValue);
      else this.#filters.types.delete(target.defaultValue);
      this.#adjustCheckboxStates(target.closest(".sidebar"));
    }

    else {
      target.indeterminate = false;
      for ( const child of target.closest(".type-group").querySelectorAll("dnd5e-checkbox[value]") ) {
        child.checked = target.checked;
        if ( target.checked ) this.#filters.types.add(child.defaultValue);
        else this.#filters.types.delete(child.defaultValue);
      }
    }

    this.render({ parts: ["filters", "results"] });
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling the compendium browser mode.
   * @this {CompendiumBrowser}
   * @param {PointerEvent} event  The originating click event.
   * @param {HTMLElement} target  The element that was clicked.
   */
  static #onToggleMode(event, target) {
    // TODO: Consider persisting this choice in a client setting.
    this._mode = target.checked ? this.constructor.MODES.ADVANCED : this.constructor.MODES.BASIC;
    const tabs = foundry.utils.deepClone(this.constructor.TABS.filter(t => !!t.advanced === target.checked));
    const activeTab = tabs.find(t => t.tab === this.tabGroups.primary) ?? tabs[0];
    const types = target.checked ? [] : (activeTab?.types ?? ["class"]);
    this._applyModeFilters(this._mode);
    this._applyTabFilters(activeTab?.tab);
    this.render({ parts: ["results", "filters", "types", "tabs"], dnd5e: { browser: { types } }, changedTab: true });
  }

  /* -------------------------------------------- */
  /*  Database Access                             */
  /* -------------------------------------------- */

  /**
   * Retrieve a listing of documents from all compendiums for a specific Document type, with additional filters
   * optionally applied.
   * @param {typeof Document} documentClass  Document type to fetch (e.g. Actor or Item).
   * @param {object} [options={}]
   * @param {Set<string>} [options.types]    Individual document subtypes to filter upon (e.g. "loot", "class", "npc").
   * @param {FilterDescription[]} [options.filters]  Filters to provide further filters.
   * @param {boolean} [options.index=true]   Should only the index for each document be returned, or the whole thing?
   * @param {Set<string>} [options.indexFields]  Key paths for fields to index.
   * @param {boolean|string|Function} [options.sort=true]  Should the contents be sorted? By default sorting will be
   *                                         performed using document names, but a key path can be provided to sort on
   *                                         a specific property or a function to provide more advanced sorting.
   * @returns {object[]|Document[]}
   */
  static async fetch(documentClass, { types=new Set(), filters=[], index=true, indexFields=new Set(), sort=true }={}) {
    // Nothing within containers should be shown
    filters.push({ k: "system.container", o: "in", v: [null, undefined] });

    // If filters are provided, merge their keys with any other fields needing to be indexed
    if ( filters.length ) indexFields = indexFields.union(uniqueKeys(filters));

    // Do not attempt to index derived fields as this will throw an error server-side.
    indexFields.delete("system.source.slug");

    // Collate compendium sources.
    const sources = CompendiumBrowserSettingsConfig.collateSources();

    // Iterate over all packs
    let documents = game.packs

      // Skip packs that have the wrong document class
      .filter(p => (p.metadata.type === documentClass.metadata.name)

        // Do not show entries inside compendia that are not visible to the current user.
        && p.visible

        && sources.has(p.collection)

        // If types are set and specified in compendium flag, only include those that include the correct types
        && (!types.size || !p.metadata.flags.dnd5e?.types || new Set(p.metadata.flags.dnd5e.types).intersects(types)))

      // Generate an index based on the needed fields
      .map(async p => await Promise.all((await p.getIndex({ fields: Array.from(indexFields) })

        // Apply module art to the new index
        .then(index => game.dnd5e.moduleArt.apply(index)))

        // Derive source values
        .map(i => {
          const source = foundry.utils.getProperty(i, "system.source");
          if ( (foundry.utils.getType(source) === "Object") && i.uuid ) SourceField.prepareData.call(source, i.uuid);
          return i;
        })

        // Remove any documents that don't match the specified types or the provided filters
        .filter(i =>
          (!types.size || (types.has(i.type)
            && (!p.metadata.flags.dnd5e?.types || p.metadata.flags.dnd5e.types.includes(i.type))))
            && (!filters.length || performCheck(i, filters))
        )

        // If full documents are required, retrieve those, otherwise stick with the indices
        .map(async i => index ? i : await fromUuid(i.uuid))
      ));

    // Wait for everything to finish loading and flatten the arrays
    documents = (await Promise.all(documents)).flat();

    if ( sort ) {
      if ( sort === true ) sort = "name";
      const sortFunc = foundry.utils.getType(sort) === "function" ? sort : (lhs, rhs) =>
        String(foundry.utils.getProperty(lhs, sort))
          .localeCompare(String(foundry.utils.getProperty(rhs, sort)), game.i18n.lang);
      documents.sort(sortFunc);
    }

    return documents;
  }

  /* -------------------------------------------- */
  /*  Factory Methods                             */
  /* -------------------------------------------- */

  /**
   * Factory method used to spawn a compendium browser and wait for the results of a selection.
   * @param {Partial<CompendiumBrowserConfiguration>} [options]
   * @returns {Promise<Set<string>|null>}
   */
  static async select(options={}) {
    return new Promise((resolve, reject) => {
      const browser = new CompendiumBrowser(options);
      browser.addEventListener("close", event => {
        resolve(browser.selected?.size ? browser.selected : null);
      }, { once: true });
      browser.render({ force: true });
    });
  }

  /* -------------------------------------------- */

  /**
   * Factory method used to spawn a compendium browser and return a single selected item or null if canceled.
   * @param {Partial<CompendiumBrowserConfiguration>} [options]
   * @returns {Promise<string|null>}
   */
  static async selectOne(options={}) {
    const result = await this.select(
      foundry.utils.mergeObject(options, { selection: { min: 1, max: 1 } }, { inplace: false })
    );
    return result?.size ? result.first() : null;
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Transform filter definition and additional filters values into the final filters to apply.
   * @param {CompendiumBrowserFilterDefinition} definition  Filter definition provided by type.
   * @param {CompendiumBrowserFilters} currentFilters       Values of currently selected filters.
   * @returns {FilterDescription[]}
   */
  static applyFilters(definition, currentFilters) {
    const filters = [];
    for ( const [key, value] of Object.entries(currentFilters.additional ?? {}) ) {
      const def = definition.get(key);
      if ( !def ) continue;
      if ( foundry.utils.getType(def.createFilter) === "function" ) {
        def.createFilter(filters, value, def);
        continue;
      }
      switch ( def.type ) {
        case "boolean":
          if ( value ) filters.push({ k: def.config.keyPath, v: value === 1 });
          break;
        case "range":
          const min = Number(value.min);
          const max = Number(value.max);
          if ( Number.isFinite(min) ) filters.push({ k: def.config.keyPath, o: "gte", v: min });
          if ( Number.isFinite(max) ) filters.push({ k: def.config.keyPath, o: "lte", v: max });
          break;
        case "set":
          const choices = foundry.utils.getType(def.config.choices) === "function"
            ? def.config.choices(currentFilters) : foundry.utils.deepClone(def.config.choices);
          if ( def.config.blank ) choices._blank = "";
          const [positive, negative] = Object.entries(value ?? {}).reduce(([positive, negative], [k, v]) => {
            if ( k in choices ) {
              if ( k === "_blank" ) k = "";
              if ( v === 1 ) positive.push(k);
              else if ( v === -1 ) negative.push(k);
            }
            return [positive, negative];
          }, [[], []]);
          if ( positive.length ) filters.push(
            { k: def.config.keyPath, o: def.config.multiple ? "hasall" : "in", v: positive }
          );
          if ( negative.length ) filters.push(
            { o: "NOT", v: { k: def.config.keyPath, o: def.config.multiple ? "hasany" : "in", v: negative } }
          );
          break;
        default:
          console.warn(`Filter type ${def.type} not handled.`);
          break;
      }
    }
    return filters;
  }

  /* -------------------------------------------- */

  /**
   * Inject the compendium browser button into the compendium sidebar.
   * @param {HTMLElement} html  HTML of the sidebar being rendered.
   */
  static injectSidebarButton(html) {
    const button = document.createElement("button");
    button.type = "button";
    button.classList.add("open-compendium-browser");
    button.innerHTML = `
      <i class="fa-solid fa-book-open-reader" inert></i>
      ${game.i18n.localize("DND5E.CompendiumBrowser.Action.Open")}
    `;
    button.addEventListener("click", event => (new CompendiumBrowser()).render({ force: true }));

    let headerActions = html.querySelector(".header-actions");
    // FIXME: Workaround for 336 bug. Remove when 337 released.
    if ( !headerActions ) {
      headerActions = document.createElement("div");
      headerActions.className = "header-actions action-buttons flexrow";
      html.querySelector(":scope > header").insertAdjacentElement("afterbegin", headerActions);
    }
    headerActions.append(button);
  }

  /* -------------------------------------------- */

  /**
   * Take two filter sets and find only the filters that match between the two.
   * @param {CompendiumBrowserFilterDefinition} first
   * @param {CompendiumBrowserFilterDefinition} [second]
   * @param {CompendiumBrowserFilters} [currentFilters]
   * @returns {CompendiumBrowserFilterDefinition}
   */
  static intersectFilters(first, second, currentFilters) {
    const final = new Map();

    // Iterate over all keys in first map
    for ( const [key, firstConfig] of first.entries() ) {
      const secondConfig = second?.get(key);
      if ( secondConfig?.type && (firstConfig.type !== secondConfig.type) ) continue;
      const finalConfig = foundry.utils.deepClone(firstConfig);
      if ( foundry.utils.getType(finalConfig.config?.choices) === "function" ) {
        finalConfig.config.choices = finalConfig.config.choices(currentFilters);
      }

      switch ( secondConfig?.type ) {
        case "range":
          if ( ("min" in firstConfig.config) || ("min" in secondConfig.config) ) {
            if ( !("min" in firstConfig.config) || !("min" in secondConfig.config) ) continue;
            finalConfig.config.min = Math.max(firstConfig.config.min, secondConfig.config.min);
          }
          if ( ("max" in firstConfig.config) || ("max" in secondConfig.config) ) {
            if ( !("max" in firstConfig.config) || !("max" in secondConfig.config) ) continue;
            finalConfig.config.max = Math.min(firstConfig.config.max, secondConfig.config.max);
          }
          if ( ("min" in finalConfig.config) && ("max" in finalConfig.config)
            && (finalConfig.config.min > finalConfig.config.max) ) continue;
          break;
        case "set":
          const choices = foundry.utils.getType(secondConfig.config.choices) === "function"
            ? secondConfig.config.choices(currentFilters) : secondConfig.config.choices;
          Object.keys(finalConfig.config.choices).forEach(k => {
            if ( !(k in choices) ) delete finalConfig.config.choices[k];
          });
          if ( foundry.utils.isEmpty(finalConfig.config.choices) ) continue;
          break;
      }

      final.set(key, finalConfig);
    }
    return final;
  }
}

/**
 * @import { TokenPlacementConfiguration, TokenPlacementData } from "./types.mjs";
 */

/**
 * Class responsible for placing one or more tokens onto the scene.
 * @param {TokenPlacementConfiguration} config  Configuration information for placement.
 */
class TokenPlacement {
  constructor(config) {
    this.config = config;
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Configuration information for the placements.
   * @type {TokenPlacementConfiguration}
   */
  config;

  /* -------------------------------------------- */

  /**
   * Index of the token configuration currently being placed in the scene.
   * @param {number}
   */
  #currentPlacement = -1;

  /* -------------------------------------------- */

  /**
   * Track the bound event handlers so they can be properly canceled later.
   * @type {object}
   */
  #events;

  /* -------------------------------------------- */

  /**
   * Track the timestamp when the last mouse move event was captured.
   * @type {number}
   */
  #moveTime = 0;

  /* -------------------------------------------- */

  /**
   * Placements that have been generated.
   * @type {TokenPlacementData[]}
   */
  #placements;

  /* -------------------------------------------- */

  /**
   * Preview tokens. Should match 1-to-1 with placements.
   * @type {Token[]}
   */
  #previews;

  /* -------------------------------------------- */

  /**
   * Is the system currently being throttled to the next animation frame?
   * @type {boolean}
   */
  #throttle = false;

  /* -------------------------------------------- */
  /*  Placement                                   */
  /* -------------------------------------------- */

  /**
   * Perform the placement, asking player guidance when necessary.
   * @param {TokenPlacementConfiguration} config
   * @returns {Promise<TokenPlacementData[]>}
   */
  static place(config) {
    const placement = new this(config);
    return placement.place();
  }

  /**
   * Perform the placement, asking player guidance when necessary.
   * @returns {Promise<TokenPlacementData[]>}
   */
  async place() {
    this.#createPreviews();
    try {
      const placements = [];
      let total = 0;
      const uniqueTokens = new Map();
      while ( this.#currentPlacement < this.config.tokens.length - 1 ) {
        this.#currentPlacement++;
        const obj = canvas.tokens.preview.addChild(this.#previews[this.#currentPlacement].object);
        await obj.draw();
        obj.eventMode = "none";
        const placement = await this.#requestPlacement();
        if ( placement ) {
          const actorId = placement.prototypeToken.parent.id;
          uniqueTokens.set(actorId, (uniqueTokens.get(actorId) ?? -1) + 1);
          placement.index = { total: total++, unique: uniqueTokens.get(actorId) };
          placements.push(placement);
        } else obj.clear();
      }
      return placements;
    } finally {
      this.#destroyPreviews();
    }
  }

  /* -------------------------------------------- */

  /**
   * Create token previews based on the prototype tokens in config.
   */
  #createPreviews() {
    this.#placements = [];
    this.#previews = [];
    for ( const prototypeToken of this.config.tokens ) {
      const tokenData = prototypeToken.toObject();
      tokenData.sight.enabled = false;
      tokenData._id = foundry.utils.randomID();
      if ( tokenData.randomImg ) tokenData.texture.src = prototypeToken.actor.img;
      const cls = getDocumentClass("Token");
      const doc = new cls(tokenData, { parent: canvas.scene });
      this.#placements.push({
        prototypeToken, x: 0, y: 0, elevation: this.config.origin?.elevation ?? 0, rotation: tokenData.rotation ?? 0
      });
      this.#previews.push(doc);
    }
  }

  /* -------------------------------------------- */

  /**
   * Clear any previews from the scene.
   */
  #destroyPreviews() {
    this.#previews.forEach(p => p.object.destroy());
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /**
   * Activate listeners for the placement preview.
   * @returns {Promise<TokenPlacementData|false>}  A promise that resolves with the final placement if created,
   *                                               or false if the placement was skipped.
   */
  #requestPlacement() {
    return new Promise((resolve, reject) => {
      this.#events = {
        confirm: this.#onConfirmPlacement.bind(this),
        move: this.#onMovePlacement.bind(this),
        resolve,
        reject,
        rotate: this.#onRotatePlacement.bind(this),
        skip: this.#onSkipPlacement.bind(this)
      };

      // Activate listeners
      canvas.stage.on("mousemove", this.#events.move);
      canvas.stage.on("mousedown", this.#events.confirm);
      canvas.app.view.oncontextmenu = this.#events.skip;
      canvas.app.view.onwheel = this.#events.rotate;
    });
  }

  /* -------------------------------------------- */

  /**
   * Shared code for when token placement ends by being confirmed or canceled.
   * @param {Event} event  Triggering event that ended the placement.
   */
  async #finishPlacement(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;
  }

  /* -------------------------------------------- */

  /**
   * Move the token preview when the mouse moves.
   * @param {Event} event  Triggering mouse event.
   */
  #onMovePlacement(event) {
    event.stopPropagation();
    if ( this.#throttle ) return;
    this.#throttle = true;
    const idx = this.#currentPlacement;
    const preview = this.#previews[idx];
    const clone = preview.object;
    const local = event.data.getLocalPosition(canvas.tokens);
    local.x = local.x - (clone.w / 2);
    local.y = local.y - (clone.h / 2);
    const dest = !event.shiftKey ? clone.getSnappedPosition(local) : local;
    preview.updateSource({x: dest.x, y: dest.y});
    this.#placements[idx].x = preview.x;
    this.#placements[idx].y = preview.y;
    canvas.tokens.preview.children[this.#currentPlacement]?.refresh();
    requestAnimationFrame(() => this.#throttle = false);
  }

  /* -------------------------------------------- */

  /**
   * Rotate the token 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 preview = this.#previews[this.#currentPlacement];
    this.#placements[this.#currentPlacement].rotation += snap * Math.sign(event.deltaY);
    preview.updateSource({ rotation: this.#placements[this.#currentPlacement].rotation });
    canvas.tokens.preview.children[this.#currentPlacement]?.refresh();
  }

  /* -------------------------------------------- */

  /**
   * Confirm placement when the left mouse button is clicked.
   * @param {Event} event  Triggering mouse event.
   */
  async #onConfirmPlacement(event) {
    await this.#finishPlacement(event);
    this.#events.resolve(this.#placements[this.#currentPlacement]);
  }

  /* -------------------------------------------- */

  /**
   * Skip placement when the right mouse button is clicked.
   * @param {Event} event  Triggering mouse event.
   */
  async #onSkipPlacement(event) {
    await this.#finishPlacement(event);
    this.#events.resolve(false);
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Adjust the appended number on an unlinked token to account for multiple placements.
   * @param {TokenDocument|object} tokenDocument  Document or data object to adjust.
   * @param {TokenPlacementData} placement        Placement data associated with this token document.
   */
  static adjustAppendedNumber(tokenDocument, placement) {
    const regex = new RegExp(/\((\d+)\)$/);
    const match = tokenDocument.name?.match(regex);
    if ( !match ) return;
    const name = tokenDocument.name.replace(regex, `(${Number(match[1]) + placement.index.unique})`);
    if ( tokenDocument instanceof TokenDocument ) tokenDocument.updateSource({ name });
    else tokenDocument.name = name;
  }
}

const {
  ArrayField: ArrayField$e, BooleanField: BooleanField$w, DocumentIdField: DocumentIdField$6, DocumentUUIDField: DocumentUUIDField$7, NumberField: NumberField$v, SchemaField: SchemaField$B, SetField: SetField$s, StringField: StringField$P
} = foundry.data.fields;

/**
 * @import { SummonActivityData, SummonsProfile } from "./_types.mjs";
 */

/**
 * Data model for a summon activity.
 * @extends {BaseActivityData<SummonActivityData>}
 * @mixes SummonActivityData
 */
class BaseSummonActivityData extends BaseActivityData {
  /** @inheritDoc */
  static defineSchema() {
    return {
      ...super.defineSchema(),
      bonuses: new SchemaField$B({
        ac: new FormulaField(),
        hd: new FormulaField(),
        hp: new FormulaField(),
        attackDamage: new FormulaField(),
        saveDamage: new FormulaField(),
        healing: new FormulaField()
      }),
      creatureSizes: new SetField$s(new StringField$P()),
      creatureTypes: new SetField$s(new StringField$P()),
      match: new SchemaField$B({
        ability: new StringField$P(),
        attacks: new BooleanField$w(),
        disposition: new BooleanField$w(),
        proficiency: new BooleanField$w(),
        saves: new BooleanField$w()
      }),
      profiles: new ArrayField$e(new SchemaField$B({
        _id: new DocumentIdField$6({ initial: () => foundry.utils.randomID() }),
        count: new FormulaField(),
        cr: new FormulaField({ deterministic: true }),
        level: new SchemaField$B({
          min: new NumberField$v({ integer: true, min: 0 }),
          max: new NumberField$v({ integer: true, min: 0 })
        }),
        name: new StringField$P(),
        types: new SetField$s(new StringField$P()),
        uuid: new DocumentUUIDField$7()
      })),
      summon: new SchemaField$B({
        mode: new StringField$P(),
        prompt: new BooleanField$w({ initial: true })
      })
    };
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  get ability() {
    return this.match.ability || super.ability || this.item.abilityMod || this.actor?.system.attributes?.spellcasting;
  }

  /* -------------------------------------------- */

  /** @override */
  get actionType() {
    return "summ";
  }

  /* -------------------------------------------- */

  /**
   * Summons that can be performed based on spell/character/class level.
   * @type {SummonsProfile[]}
   */
  get availableProfiles() {
    const level = this.relevantLevel;
    return this.profiles.filter(e => ((e.level.min ?? -Infinity) <= level) && (level <= (e.level.max ?? Infinity)));
  }

  /* -------------------------------------------- */

  /**
   * Creatures summoned by this activity.
   * @type {Actor5e[]}
   */
  get summonedCreatures() {
    if ( !this.actor ) return [];
    return dnd5e.registry.summons.creatures(this.actor)
      .filter(i => i?.getFlag("dnd5e", "summon.origin") === this.uuid);
  }

  /* -------------------------------------------- */
  /*  Data Migration                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static migrateData(source) {
    super.migrateData(source);
    if ( source.summon?.identifier ) {
      foundry.utils.setProperty(source, "visibility.identifier", source.summon.identifier);
      delete source.summon.identifier;
    }
    return source;
  }

  /* -------------------------------------------- */

  /** @override */
  static transformTypeData(source, activityData, options) {
    return foundry.utils.mergeObject(activityData, {
      bonuses: source.system.summons?.bonuses ?? {},
      creatureSizes: source.system.summons?.creatureSizes ?? [],
      creatureTypes: source.system.summons?.creatureTypes ?? [],
      match: {
        ...(source.system.summons?.match ?? {}),
        ability: source.system.ability
      },
      profiles: source.system.summons?.profiles ?? [],
      summon: {
        mode: source.system.summons?.mode ?? "",
        prompt: source.system.summons?.prompt ?? true
      },
      visibility: {
        identifier: source.system.summons?.classIdentifier ?? ""
      }
    });
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _preCreate(data) {
    super._preCreate(data);
    const { match } = data ?? {};
    if ( match && !("disposition" in match) ) this.updateSource({ match: { disposition: true } });
  }
}

/**
 * @import { TokenPlacementData } from "../../canvas/_types.mjs";
 * @import { SummonsProfile } from "../../data/activity/_types.mjs";
 * @import { SummoningConfiguration, SummonUsageResults, SummonUseConfiguration, TokenUpdateData } from "./_types.mjs";
 */

/**
 * Activity for summoning creatures.
 */
class SummonActivity extends ActivityMixin(BaseSummonActivityData) {
  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static LOCALIZATION_PREFIXES = [...super.LOCALIZATION_PREFIXES, "DND5E.SUMMON"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static metadata = Object.freeze(
    foundry.utils.mergeObject(super.metadata, {
      type: "summon",
      img: "systems/dnd5e/icons/svg/activity/summon.svg",
      title: "DND5E.SUMMON.Title",
      hint: "DND5E.SUMMON.Hint",
      sheetClass: SummonSheet,
      usage: {
        actions: {
          placeSummons: SummonActivity.#placeSummons
        },
        dialog: SummonUsageDialog
      }
    }, { inplace: false })
  );

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Does the user have permissions to summon?
   * @type {boolean}
   */
  get canSummon() {
    return game.user.can("TOKEN_CREATE") && (game.user.isGM || game.settings.get("dnd5e", "allowSummoning"));
  }

  /* -------------------------------------------- */
  /*  Activation                                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _prepareUsageConfig(config) {
    config = super._prepareUsageConfig(config);
    const summons = this.availableProfiles;
    if ( config.create !== false ) {
      config.create ??= {};
      config.create.summons ??= this.canSummon && canvas.scene && summons.length && this.summon.prompt;
    }
    config.summons ??= {};
    config.summons.profile ??= summons[0]?._id ?? null;
    config.summons.creatureSize ??= this.creatureSizes.first() ?? null;
    config.summons.creatureType ??= this.creatureTypes.first() ?? null;
    return config;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _finalizeMessageConfig(usageConfig, messageConfig, results) {
    super._finalizeMessageConfig(usageConfig, messageConfig, results);
    delete messageConfig.data.flags?.dnd5e?.use?.effects;
  }

  /* -------------------------------------------- */

  /** @override */
  _usageChatButtons(message) {
    if ( !this.availableProfiles.length ) return super._usageChatButtons(message);
    return [{
      label: game.i18n.localize("DND5E.SUMMON.Action.Summon"),
      icon: '<i class="fa-solid fa-spaghetti-monster-flying" inert></i>',
      dataset: {
        action: "placeSummons"
      }
    }].concat(super._usageChatButtons(message));
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  shouldHideChatButton(button, message) {
    if ( button.dataset.action === "placeSummons" ) return !this.canSummon;
    return super.shouldHideChatButton(button, message);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _finalizeUsage(config, results) {
    await super._finalizeUsage(config, results);
    if ( config.create?.summons ) {
      try {
        results.summoned = await this.placeSummons(config.summons);
      } catch(err) {
        results.summoned = [];
        Hooks.onError("SummonActivity#use", err, { log: "error", notify: "error" });
      }
    }
  }

  /* -------------------------------------------- */
  /*  Summoning                                   */
  /* -------------------------------------------- */

  /**
   * Process for summoning actor to the scene.
   * @param {SummoningConfiguration} options  Configuration data for summoning behavior.
   * @returns {Token5e[]|void}
   */
  async placeSummons(options) {
    if ( !this.canSummon || !canvas.scene ) return;

    const profile = this.profiles.find(p => p._id === options?.profile);
    if ( !profile ) throw new Error(
      game.i18n.format("DND5E.SUMMON.Warning.NoProfile", { profileId: options.profile, item: this.item.name })
    );

    /**
     * A hook event that fires before summoning is performed.
     * @function dnd5e.preSummon
     * @memberof hookEvents
     * @param {SummonActivity} activity         The activity that is performing the summoning.
     * @param {SummonsProfile} profile          Profile used for summoning.
     * @param {SummoningConfiguration} options  Additional summoning options.
     * @returns {boolean}                       Explicitly return `false` to prevent summoning.
     */
    if ( Hooks.call("dnd5e.preSummon", this, profile, options) === false ) return;

    // Fetch the actor that will be summoned
    const summonUuid = this.summon.mode === "cr" ? await this.queryActor(profile) : profile.uuid;
    if ( !summonUuid ) return;
    const actor = await dnd5e.documents.Actor5e.fetchExisting(summonUuid, {
      origin: { key: "flags.dnd5e.summon.origin", value: this.item?.uuid }
    });

    // Verify ownership of actor
    if ( !actor.isOwner ) {
      throw new Error(game.i18n.format("DND5E.SUMMON.Warning.NoOwnership", { actor: actor.name }));
    }

    const tokensData = [];
    const minimized = !this.actor?.sheet._minimized;
    await this.actor?.sheet.minimize();
    try {
      // Figure out where to place the summons
      const placements = await this.getPlacement(actor.prototypeToken, profile, options);

      for ( const placement of placements ) {
        // Prepare changes to actor data, re-calculating per-token for potentially random values
        const tokenUpdateData = {
          actor,
          placement,
          ...(await this.getChanges(actor, profile, options))
        };

        /**
         * A hook event that fires before a specific token is summoned. After placement has been determined but before
         * the final token data is constructed.
         * @function dnd5e.preSummonToken
         * @memberof hookEvents
         * @param {SummonActivity} activity         The activity that is performing the summoning.
         * @param {SummonsProfile} profile          Profile used for summoning.
         * @param {TokenUpdateData} config          Configuration for creating a modified token.
         * @param {SummoningConfiguration} options  Additional summoning options.
         * @returns {boolean}                       Explicitly return `false` to prevent this token from being summoned.
         */
        if ( Hooks.call("dnd5e.preSummonToken", this, profile, tokenUpdateData, options) === false ) continue;

        // Create a token document and apply updates
        const tokenData = await this.getTokenData(tokenUpdateData);

        /**
         * A hook event that fires after token creation data is prepared, but before summoning occurs.
         * @function dnd5e.summonToken
         * @memberof hookEvents
         * @param {SummonActivity} activity         The activity that is performing the summoning.
         * @param {SummonsProfile} profile          Profile used for summoning.
         * @param {object} tokenData                Data for creating a token.
         * @param {SummoningConfiguration} options  Additional summoning options.
         */
        Hooks.callAll("dnd5e.summonToken", this, profile, tokenData, options);

        tokensData.push(tokenData);
      }
    } finally {
      if ( minimized ) this.actor?.sheet.maximize();
    }

    const createdTokens = await canvas.scene.createEmbeddedDocuments("Token", tokensData, {
      dnd5e: { autoRollNPCHP: "no" }
    });

    /**
     * A hook event that fires when summoning is complete.
     * @function dnd5e.postSummon
     * @memberof hookEvents
     * @param {SummonActivity} activity         The activity that is performing the summoning.
     * @param {SummonsProfile} profile          Profile used for summoning.
     * @param {Token5e[]} tokens                Tokens that have been created.
     * @param {SummoningConfiguration} options  Additional summoning options.
     */
    Hooks.callAll("dnd5e.postSummon", this, profile, createdTokens, options);

    return createdTokens;
  }

  /* -------------------------------------------- */

  /**
   * Request a specific actor to summon from the player.
   * @param {SummonsProfile} profile  Profile used for summoning.
   * @returns {Promise<string|null>}  UUID of the concrete actor to summon or `null` if canceled.
   */
  async queryActor(profile) {
    const locked = {
      documentClass: "Actor",
      types: new Set(["npc"]),
      additional: {
        cr: { max: simplifyBonus(profile.cr, this.getRollData({ deterministic: true })) }
      }
    };
    if ( profile.types.size ) locked.additional.type = Array.from(profile.types).reduce((obj, type) => {
      obj[type] = 1;
      return obj;
    }, {});
    return CompendiumBrowser.selectOne({ filters: { locked } });
  }

  /* -------------------------------------------- */

  /**
   * Prepare the updates to apply to the summoned actor and its token.
   * @param {Actor5e} actor                   Actor that will be modified.
   * @param {SummonsProfile} profile          Summoning profile used to summon the actor.
   * @param {SummoningConfiguration} options  Configuration data for summoning behavior.
   * @returns {Promise<{actorChanges: object, tokenChanges: object}>}  Changes that will be applied to the actor,
   *                                                                   its items, and its token.
   */
  async getChanges(actor, profile, options) {
    const actorUpdates = { effects: [], items: [] };
    const tokenUpdates = {};
    const rollData = { ...this.getRollData(), summon: actor.getRollData() };
    const prof = rollData.attributes?.prof ?? 0;

    // Add flags
    actorUpdates["flags.dnd5e.summon"] = {
      level: this.relevantLevel,
      mod: rollData.mod,
      origin: this.item.uuid,
      activity: this.id,
      profile: profile._id
    };

    // Match proficiency
    if ( this.match.proficiency ) {
      const proficiencyEffect = new ActiveEffect({
        _id: staticID("dnd5eMatchProficiency"),
        changes: [{
          key: "system.attributes.prof",
          mode: CONST.ACTIVE_EFFECT_MODES.OVERRIDE,
          value: prof
        }],
        disabled: false,
        icon: "icons/skills/targeting/crosshair-bars-yellow.webp",
        name: game.i18n.localize("DND5E.SUMMON.FIELDS.match.proficiency.label")
      });
      actorUpdates.effects.push(proficiencyEffect.toObject());
    }

    // Match disposition
    if ( this.match.disposition && this.actor ) {
      const { disposition } = this.actor.isToken ? this.actor.token : this.actor.prototypeToken;
      tokenUpdates.disposition = disposition;
    }

    // Add bonus to AC
    if ( this.bonuses.ac ) {
      const acBonus = new Roll(this.bonuses.ac, rollData);
      await acBonus.evaluate();
      if ( acBonus.total ) {
        if ( actor.system.attributes.ac.calc === "flat" ) {
          actorUpdates["system.attributes.ac.flat"] = (actor.system.attributes.ac.flat ?? 0) + acBonus.total;
        } else {
          actorUpdates.effects.push((new ActiveEffect({
            _id: staticID("dnd5eACBonus"),
            changes: [{
              key: "system.attributes.ac.bonus",
              mode: CONST.ACTIVE_EFFECT_MODES.ADD,
              value: acBonus.total
            }],
            disabled: false,
            icon: "icons/magic/defensive/shield-barrier-blue.webp",
            name: game.i18n.localize("DND5E.SUMMON.FIELDS.bonuses.ac.label")
          })).toObject());
        }
      }
    }

    // Add bonus to HD
    if ( this.bonuses.hd && (actor.type === "npc") ) {
      const hdBonus = new Roll(this.bonuses.hd, rollData);
      await hdBonus.evaluate();
      if ( hdBonus.total ) {
        actorUpdates.effects.push((new ActiveEffect({
          _id: staticID("dnd5eHDBonus"),
          changes: [{
            key: "system.attributes.hd.max",
            mode: CONST.ACTIVE_EFFECT_MODES.ADD,
            value: hdBonus.total
          }],
          disabled: false,
          icon: "icons/sundries/gaming/dice-runed-brown.webp",
          name: game.i18n.localize("DND5E.SUMMON.FIELDS.bonuses.hd.label")
        })).toObject());
      }
    }

    // Add bonus to HP
    if ( this.bonuses.hp ) {
      const hpBonus = new Roll(this.bonuses.hp, rollData);
      await hpBonus.evaluate();

      // If non-zero hp bonus, apply as needed for this actor.
      // Note: Only unlinked actors will have their current HP set to their new max HP
      if ( hpBonus.total ) {

        // Helper function for modifying max HP ('bonuses.overall' or 'max')
        const maxHpEffect = hpField => {
          return (new ActiveEffect({
            _id: staticID("dnd5eHPBonus"),
            changes: [{
              key: `system.attributes.hp.${hpField}`,
              mode: CONST.ACTIVE_EFFECT_MODES.ADD,
              value: hpBonus.total
            }],
            disabled: false,
            icon: "icons/magic/life/heart-glowing-red.webp",
            name: game.i18n.localize("DND5E.SUMMON.FIELDS.bonuses.hp.label")
          })).toObject();
        };

        if ( !foundry.utils.isEmpty(actor.classes) && !actor._source.system.attributes.hp.max ) {
          // Actor has classes without a hard-coded max -- apply bonuses to 'overall'
          actorUpdates.effects.push(maxHpEffect("bonuses.overall"));
        } else if ( actor.prototypeToken.actorLink ) {
          // Otherwise, linked actors boost HP via 'max' AE
          actorUpdates.effects.push(maxHpEffect("max"));
        } else {
          // Unlinked actors assumed to always be "fresh" copies with bonus HP added to both
          // Max HP and Current HP
          actorUpdates["system.attributes.hp.max"] = actor.system.attributes.hp.max + hpBonus.total;
          actorUpdates["system.attributes.hp.value"] = actor.system.attributes.hp.value + hpBonus.total;
        }
      }
    }

    // Change creature size
    if ( this.creatureSizes.size ) {
      const size = this.creatureSizes.has(options.creatureSize) ? options.creatureSize : this.creatureSizes.first();
      const config = CONFIG.DND5E.actorSizes[size];
      if ( config ) {
        actorUpdates["system.traits.size"] = size;
        tokenUpdates.width = config.token ?? 1;
        tokenUpdates.height = config.token ?? 1;
      }
    }

    // Change creature type
    if ( this.creatureTypes.size ) {
      const type = this.creatureTypes.has(options.creatureType) ? options.creatureType : this.creatureTypes.first();
      if ( actor.system.details?.race instanceof Item ) {
        actorUpdates.items.push({ _id: actor.system.details.race.id, "system.type.value": type });
      } else {
        actorUpdates["system.details.type.value"] = type;
      }
    }

    const attackDamageBonus = CONFIG.Dice.BasicRoll.replaceFormulaData(this.bonuses.attackDamage ?? "", rollData);
    const saveDamageBonus = CONFIG.Dice.BasicRoll.replaceFormulaData(this.bonuses.saveDamage ?? "", rollData);
    const healingBonus = CONFIG.Dice.BasicRoll.replaceFormulaData(this.bonuses.healing ?? "", rollData);
    for ( const item of actor.items ) {
      if ( !item.system.activities?.size ) continue;
      const changes = [];

      // Match attacks
      if ( this.match.attacks && item.system.hasAttack ) {
        let attack = this.flat?.attack;
        if ( attack === undefined ) {
          const actionType = item.system.activities.getByType("attack")[0].actionType;
          const typeMapping = { mwak: "msak", rwak: "rsak" };
          const parts = [
            rollData.abilities?.[this.ability]?.mod,
            prof,
            CONFIG.Dice.BasicRoll.replaceFormulaData(
              rollData.bonuses?.[typeMapping[actionType] ?? actionType]?.attack ?? "", rollData
            )
          ].filter(p => p);
          attack = parts.join(" + ");
        }
        changes.push({
          key: "activities[attack].attack.bonus",
          mode: CONST.ACTIVE_EFFECT_MODES.OVERRIDE,
          value: attack
        }, {
          key: "activities[attack].attack.flat",
          mode: CONST.ACTIVE_EFFECT_MODES.OVERRIDE,
          value: true
        });
      }

      // Match saves
      if ( this.match.saves && item.hasSave ) {
        changes.push({
          key: "activities[save].save.dc.formula",
          mode: CONST.ACTIVE_EFFECT_MODES.OVERRIDE,
          value: this.flat?.save ?? rollData.abilities?.[this.ability]?.dc ?? rollData.attributes.spell.dc
        }, {
          key: "activities[save].save.dc.calculation",
          mode: CONST.ACTIVE_EFFECT_MODES.OVERRIDE,
          value: ""
        });
      }

      // Damage bonus
      let damageBonus;
      if ( item.hasAttack ) damageBonus = attackDamageBonus;
      else if ( item.hasSave ) damageBonus = saveDamageBonus;
      else if ( item.isHealing ) damageBonus = healingBonus;
      if ( damageBonus && item.system.activities.find(a => a.damage?.parts?.length || a.healing?.formula) ) {
        changes.push({
          key: "system.damage.bonus",
          mode: CONST.ACTIVE_EFFECT_MODES.ADD,
          value: damageBonus
        });
      }

      if ( changes.length ) {
        const effect = (new ActiveEffect({
          _id: staticID("dnd5eItemChanges"),
          changes,
          disabled: false,
          icon: "icons/skills/melee/strike-slashes-orange.webp",
          name: game.i18n.localize("DND5E.SUMMON.ItemChanges.Label"),
          origin: this.uuid,
          type: "enchantment"
        })).toObject();
        actorUpdates.items.push({ _id: item.id, effects: [effect, ...item.effects.map(e => e.toObject())] });
      }
    }

    // Add applied effects
    actorUpdates.effects.push(...this.applicableEffects.map(e => e.toObject()));

    return { actorUpdates, tokenUpdates };
  }

  /* -------------------------------------------- */

  /**
   * Determine where the summons should be placed on the scene.
   * @param {PrototypeToken} token            Token to be placed.
   * @param {SummonsProfile} profile          Profile used for summoning.
   * @param {SummoningConfiguration} options  Additional summoning options.
   * @returns {Promise<TokenPlacementData[]>}
   */
  async getPlacement(token, profile, options) {
    // Ensure the token matches the final size
    if ( this.creatureSizes.size ) {
      const size = this.creatureSizes.has(options.creatureSize) ? options.creatureSize : this.creatureSizes.first();
      const config = CONFIG.DND5E.actorSizes[size];
      if ( config ) token = token.clone({ width: config.token ?? 1, height: config.token ?? 1 });
    }

    const rollData = this.getRollData();
    const count = new Roll(profile.count || "1", rollData);
    await count.evaluate();
    return TokenPlacement.place({
      origin: this.getUsageToken(), tokens: Array(parseInt(count.total)).fill(token)
    });
  }

  /* -------------------------------------------- */

  /**
   * Create token data ready to be summoned.
   * @param {TokenUpdateData} config  Configuration for creating a modified token.
   * @returns {object}
   */
  async getTokenData({ actor, placement, tokenUpdates, actorUpdates }) {
    if ( actor.prototypeToken.randomImg && !game.user.can("FILES_BROWSE") ) {
      tokenUpdates.texture ??= {};
      tokenUpdates.texture.src ??= actor.img;
      ui.notifications.warn("DND5E.SUMMON.Warning.Wildcard", { localize: true });
    }

    delete placement.prototypeToken;
    const tokenDocument = await actor.getTokenDocument(foundry.utils.mergeObject(placement, tokenUpdates));

    // Linked summons require more explicit updates before token creation.
    // Unlinked summons can take actor delta directly.
    if ( tokenDocument.actorLink ) {
      const { effects, items, ...rest } = actorUpdates;
      await tokenDocument.actor.update(rest);
      await tokenDocument.actor.updateEmbeddedDocuments("Item", items);

      const { newEffects, oldEffects } = effects.reduce((acc, curr) => {
        const target = tokenDocument.actor.effects.get(curr._id) ? "oldEffects" : "newEffects";
        acc[target].push(curr);
        return acc;
      }, { newEffects: [], oldEffects: [] });

      await tokenDocument.actor.updateEmbeddedDocuments("ActiveEffect", oldEffects);
      await tokenDocument.actor.createEmbeddedDocuments("ActiveEffect", newEffects, { keepId: true });
    } else {
      tokenDocument.delta.updateSource(actorUpdates);
      if ( actor.prototypeToken.appendNumber ) TokenPlacement.adjustAppendedNumber(tokenDocument, placement);
    }

    return tokenDocument.toObject();
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle placing a summons from the chat card.
   * @this {SummonActivity}
   * @param {PointerEvent} event     Triggering click event.
   * @param {HTMLElement} target     The capturing HTML element which defined a [data-action].
   * @param {ChatMessage5e} message  Message associated with the activation.
   */
  static async #placeSummons(event, target, message) {
    const config = {
      create: { summons: true },
      summons: {}
    };
    let needsConfiguration = false;

    // No profile specified and only one profile on item, use that one
    const profiles = this.availableProfiles;
    if ( profiles.length === 1 ) config.summons.profile = profiles[0]._id;
    else needsConfiguration = true;

    // More than one creature size or type requires configuration
    if ( (this.creatureSizes.size > 1) || (this.creatureTypes.size > 1) ) needsConfiguration = true;

    if ( needsConfiguration ) {
      try {
        await SummonUsageDialog.create(this, config, {
          button: {
            icon: "fa-solid fa-spaghetti-monster-flying",
            label: "DND5E.SUMMON.Action.Summon"
          },
          display: {
            all: false,
            create: { summons: true }
          }
        });
      } catch(err) {
        return;
      }
    }

    try {
      await this.placeSummons(config.summons);
    } catch(err) {
      Hooks.onError("SummonsActivity#placeSummons", err, { log: "error", notify: "error" });
    }
  }

  /* -------------------------------------------- */
  /*  Deprecations                                */
  /* -------------------------------------------- */

  /**
   * @deprecated
   * @since 5.1.0
   * @ignore
   */
  fetchActor(uuid) {
    foundry.utils.logCompatibilityWarning("SummonActivity#fetchActor is deprecated. "
      + "Please use Actor5e.fetchExisting instead.", { since: "DnD5e 5.1", until: "DnD5e 5.3" });
    return dnd5e.documents.Actor5e.fetchExisting(uuid, {
      origin: { key: "flags.dnd5e.summon.origin", value: this.item?.uuid }
    });
  }
}

/**
 * Create a checkbox input for a BooleanField.
 * @param {BooleanField} field               The field.
 * @param {FormInputConfig<boolean>} config  The input configuration.
 * @returns {HTMLElement}
 */
function createCheckboxInput(field, config) {
  const input = document.createElement("dnd5e-checkbox");
  input.name = config.name;
  if ( config.value ) input.checked = true;
  foundry.applications.fields.setInputAttributes(input, config);
  if ( "ariaLabel" in config ) input.ariaLabel = config.ariaLabel;
  if ( "classes" in config ) input.className = config.classes;
  return input;
}

/* -------------------------------------------- */

/**
 * Create a grid of checkboxes.
 * @param {DataField} field         The field.
 * @param {FormInputConfig} config  The input configuration.
 * @returns {HTMLCollection}
 */
function createMultiCheckboxInput(field, config) {
  const template = document.createElement("template");
  for ( const option of config.options || [] ) {
    const { label, value, selected } = option;
    const element = document.createElement("label");
    element.classList.add("checkbox");
    element.innerHTML = `
      <dnd5e-checkbox name="${config.name}.${value}" ${selected ? "checked" : ""}></dnd5e-checkbox>
      <span>${label}</span>
    `;
    template.content.append(element);
  }
  return template.content.children;
}

/* -------------------------------------------- */

/**
 * Create a number input for a NumberField.
 * @param {NumberField} field               The field.
 * @param {FormInputConfig<number>} config  The input configuration.
 * @returns {HTMLElement|HTMLCollection}
 */
function createNumberInput(field, config) {
  delete config.input;
  const input = field.toInput(config);
  if ( "ariaLabel" in config ) input.ariaLabel = config.ariaLabel;
  if ( "classes" in config ) input.className = config.classes;
  return input;
}

/* -------------------------------------------- */

/**
 * Create a text input for a StringField.
 * @param {StringField} field               The field.
 * @param {FormInputConfig<string>} config  The input configuration.
 * @returns {HTMLElement|HTMLCollection}
 */
function createTextInput(field, config) {
  delete config.input;
  const input = field.toInput(config);
  if ( "classes" in config ) input.className = config.classes;
  return input;
}

var fields = /*#__PURE__*/Object.freeze({
  __proto__: null,
  createCheckboxInput: createCheckboxInput,
  createMultiCheckboxInput: createMultiCheckboxInput,
  createNumberInput: createNumberInput,
  createTextInput: createTextInput
});

const { BooleanField: BooleanField$v, SetField: SetField$r, StringField: StringField$O } = foundry.data.fields;

/**
 * @import { TransformationSettingData } from "./_types.mjs";
 */

/**
 * A data model that represents the previous transformation preset.
 * @extends {foundry.abstract.DataModel<TransformationSettingData>}
 * @mixes TransformationSettingData
 */
class TransformationSetting extends foundry.abstract.DataModel {

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.TRANSFORM.Setting"];

  /* -------------------------------------------- */

  /** @override */
  static defineSchema() {
    return {
      effects: new SetField$r(new StringField$O(), { initial: () => TransformationSetting.#initial("effects") }),
      keep: new SetField$r(new StringField$O(), { initial: () => TransformationSetting.#initial("keep") }),
      merge: new SetField$r(new StringField$O(), { initial: () => TransformationSetting.#initial("merge") }),
      minimumAC: new FormulaField({ deterministic: true }),
      other: new SetField$r(new StringField$O(), { initial: () => TransformationSetting.#initial("other") }),
      preset: new StringField$O({ initial: null, nullable: true }),
      spellLists: new SetField$r(new StringField$O()),
      tempFormula: new FormulaField({ determinstic: true }),
      transformTokens: new BooleanField$v({ initial: true })
    };
  }

  /* -------------------------------------------- */

  /**
   * Categories that define sets of booleans.
   * @type {string[]}
   */
  static BOOLEAN_CATEGORIES = Object.seal(["keep", "merge", "effects", "other"]);

  /* -------------------------------------------- */

  /**
   * Populate the initial value for "effects", "keep", "merge", & "other" based on the settings.
   * @param {"effects"|"keep"|"merge"|"other"} category
   * @returns {string[]}
   */
  static #initial(category) {
    return Object.entries(CONFIG.DND5E.transformation[category])
      .filter(([, config]) => config.default)
      .map(([key]) => key);
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Generate form categories populated with data from this settings object.
   * @param {object} [options={}]
   * @param {Actor5e} [options.host]   Actor being transformed. Should only be provided if using the dialog.
   * @param {string} [options.prefix]  Prefix before the field name.
   * @returns {{ category: string, title: string, hint: string, settings: object[] }[]}
   */
  createFormCategories({ host, prefix="" }={}) {
    const disabledFields = TransformationSetting.BOOLEAN_CATEGORIES.reduce((fields, cat) => {
      for ( const value of this[cat] ) {
        for ( const disable of CONFIG.DND5E.transformation[cat][value]?.disables ?? [] ) {
          if ( disable.includes("*") ) Object.keys(CONFIG.DND5E.transformation[disable.replace(".*", "")] ?? {})
            .filter(k => k !== value).forEach(k => fields.add(`${cat}.${k}`));
          else fields.add(disable);
        }
      }
      return fields;
    }, new Set());

    const otherSettings = Object.entries(TransformationSetting.schema.fields)
      .map(([name, field]) => name !== "preset" && !TransformationSetting.BOOLEAN_CATEGORIES.includes(name)
        ? this.createFormField(name, field, { host, prefix }) : null)
      .filter(_ => _);

    return TransformationSetting.BOOLEAN_CATEGORIES.map(cat => ({
      category: cat,
      title: `DND5E.TRANSFORM.Setting.FIELDS.${cat}.label`,
      hint: game.i18n.has(`DND5E.TRANSFORM.Setting.FIELDS.${cat}.hint`)
        ? `DND5E.TRANSFORM.Setting.FIELDS.${cat}.hint` : "",
      settings: [
        ...Object.entries(CONFIG.DND5E.transformation[cat]).map(([name, config]) => ({
          field: new BooleanField$v({ label: config.label, hint: config.hint }),
          disabled: disabledFields.has(`${cat}.${name}`),
          input: createCheckboxInput,
          name: `${prefix}${cat}.${name}`,
          value: disabledFields.has(`${cat}.${name}`) ? undefined : this[cat]?.has(name)
        })),
        ...(cat === "other" ? otherSettings : [])
      ]
    }));
  }

  /* -------------------------------------------- */

  /**
   * Create a field entry for form rendering for a non-boolean field.
   * @param {string} name              Name of the field.
   * @param {DataField} field          Underlying data field.
   * @param {object} options
   * @param {Actor5e} [options.host]   Actor being transformed.
   * @param {string} [options.prefix]  Prefix before the field name.
   * @returns {object}
   */
  createFormField(name, field, { prefix="", host }) {
    const descriptor = {
      field,
      name: `${prefix}${name}`,
      input: field instanceof BooleanField$v ? createCheckboxInput : undefined,
      value: this[name]
    };
    if ( name === "spellLists" ) descriptor.options = dnd5e.registry.spellLists.options.filter(o => {
      if ( !host ) return true;
      const [type, identifier] = o.value.split(":");
      return host.identifiedItems.get(identifier, type)?.size > 0;
    });
    return descriptor;
  }
}

/**
 * Sheet for the summon activity.
 */
class TransformSheet extends ActivitySheet {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["transform-activity"],
    actions: {
      addProfile: TransformSheet.#addProfile,
      deleteProfile: TransformSheet.#deleteProfile
    }
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  static PARTS = {
    ...super.PARTS,
    effect: {
      template: "systems/dnd5e/templates/activity/transform-effect.hbs",
      templates: [
        ...super.PARTS.effect.templates,
        "systems/dnd5e/templates/activity/parts/transform-profiles.hbs",
        "systems/dnd5e/templates/activity/parts/transform-settings.hbs"
      ]
    }
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  static CLEAN_ARRAYS = [...super.CLEAN_ARRAYS, "profiles"];

  /* -------------------------------------------- */

  /** @override */
  tabGroups = {
    sheet: "identity",
    activation: "time",
    effect: "profiles"
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareEffectContext(context, options) {
    context = await super._prepareEffectContext(context, options);

    const settings = new TransformationSetting({
      ...(context.source.transform.customize ? context.source.settings
        : CONFIG.DND5E.transformation.presets[context.source.transform.preset]?.settings ?? {}),
      preset: context.source.transform.preset
    });
    context.categories = settings.createFormCategories({ prefix: "settings." });
    context.presetOptions = [
      { value: "", label: game.i18n.localize("DND5E.TRANSFORM.Preset.Default") },
      { rule: true },
      ...Object.entries(CONFIG.DND5E.transformation.presets)
        .map(([value, { label }]) => ({ value, label }))
    ];

    context.creatureSizeOptions = Object.entries(CONFIG.DND5E.actorSizes)
      .map(([value, { label }]) => ({ value, label }));
    context.creatureTypeOptions = Object.entries(CONFIG.DND5E.creatureTypes)
      .map(([value, { label }]) => ({ value, label }));
    context.movementTypeOptions = Object.entries(CONFIG.DND5E.movementTypes)
      .map(([value, { label }]) => ({ value, label }));

    context.profileModes = [
      { value: "", label: game.i18n.localize("DND5E.TRANSFORM.FIELDS.transform.mode.Direct") },
      { value: "cr", label: game.i18n.localize("DND5E.TRANSFORM.FIELDS.transform.mode.CR") }
    ];
    context.profiles = context.source.profiles.map((data, index) => ({
      data, index,
      collapsed: this.expandedSections.get(`profiles.${data._id}`) ? "" : "collapsed",
      document: data.uuid ? fromUuidSync(data.uuid) : null,
      fields: this.activity.schema.fields.profiles.element.fields,
      prefix: `profiles.${index}.`,
      source: context.source.profiles[index] ?? data
    })).sort((lhs, rhs) => (lhs.name || "").localeCompare(rhs.name || "", game.i18n.lang));

    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _getTabs() {
    const tabs = super._getTabs();
    tabs.effect.label = "DND5E.TRANSFORM.SECTIONS.Transformation";
    tabs.effect.icon = "fa-solid fa-frog";
    tabs.effect.tabs = this._markTabs({
      profiles: {
        id: "profiles", group: "effect", icon: "fa-solid fa-address-card",
        label: "DND5E.TRANSFORM.SECTIONS.Profiles"
      },
      settings: {
        id: "settings", group: "effect", icon: "fa-solid fa-sliders",
        label: "DND5E.TRANSFORM.SECTIONS.Settings"
      }
    });
    return tabs;
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onRender() {
    super._onRender();
    this.element.querySelector(".activity-profiles").addEventListener("drop", this.#onDrop.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * Handle adding a new entry to the transform profiles list.
   * @this {TransformSheet}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static #addProfile(event, target) {
    this.activity.update({ profiles: [...this.activity.toObject().profiles, {}] });
  }

  /* -------------------------------------------- */

  /**
   * Handle removing an entry from the transform profiles list.
   * @this {TransformSheet}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static #deleteProfile(event, target) {
    const profiles = this.activity.toObject().profiles;
    profiles.splice(target.closest("[data-index]").dataset.index, 1);
    this.activity.update({ profiles });
  }

  /* -------------------------------------------- */
  /*  Form Handling                               */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _prepareSubmitData(event, formData) {
    const submitData = super._prepareSubmitData(event, formData);
    if ( submitData.settings ) {
      for ( const category of ["keep", "merge", "effects", "other"] ) {
        submitData.settings[category] = filteredKeys(submitData.settings[category] ?? {});
      }
    }
    return submitData;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _processSubmitData(event, submitData) {
    // If customize is set but no settings set, save defaults
    if ( submitData.transform?.customize && !this.activity._source.settings ) {
      const preset = submitData.transform.preset ?? this.activity.transform.preset;
      submitData.settings = new TransformationSetting(foundry.utils.mergeObject({
        ...(CONFIG.DND5E.transformation.presets[preset]?.settings ?? {}),
        preset: this.activity.transform.preset
      }, submitData.settings ?? {}, { inplace: false })).toObject();
    }

    // If customize is unchecked and settings set, remove settings
    else if ( (submitData.transform?.customize === false) && this.activity.settings ) submitData.settings = null;

    await super._processSubmitData(event, submitData);
  }

  /* -------------------------------------------- */
  /*  Drag & Drop                                 */
  /* -------------------------------------------- */

  /**
   * Handle dropping actors onto the sheet.
   * @param {Event} event  Triggering drop event.
   */
  async #onDrop(event) {
    // Try to extract the data
    const data = foundry.applications.ux.TextEditor.implementation.getDragEventData(event);

    // Handle dropping linked items
    if ( data?.type !== "Actor" ) return;
    const actor = await Actor.implementation.fromDropData(data);

    // If dropped onto existing profile, add or replace link
    const profileId = event.target.closest("[data-profile-id]")?.dataset.profileId;
    if ( profileId ) {
      const profiles = this.activity.toObject().profiles;
      const profile = profiles.find(p => p._id === profileId);
      profile.uuid = actor.uuid;
      this.activity.update({ profiles });
    }

    // Otherwise create a new profile
    else this.activity.update({ profiles: [...this.activity.toObject().profiles, { uuid: actor.uuid }] });
  }
}

const { StringField: StringField$N } = foundry.data.fields;

/**
 * Dialog for configuring the usage of the transform activity.
 */
class TransformUsageDialog extends ActivityUsageDialog {

  /** @inheritDoc */
  static PARTS = {
    ...super.PARTS,
    creation: {
      template: "systems/dnd5e/templates/activity/transform-usage-creation.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareCreationContext(context, options) {
    context = await super._prepareCreationContext(context, options);

    const profiles = this.activity.availableProfiles;
    if ( this._shouldDisplay("create.transform") && (profiles.length > 1) ) {
      const rollData = this.activity.getRollData();
      let options = profiles.map(profile => ({
        value: profile._id, label: this.getProfileLabel(profile, rollData)
      }));
      context.hasCreation = true;
      context.transformFields = [{
        field: new StringField$N({
          required: true, blank: false, label: game.i18n.localize("DND5E.TRANSFORM.Profile.Label")
        }),
        name: "transform.profile",
        value: this.config.transform?.profile,
        options
      }];
    } else if ( profiles.length ) {
      context.transformProfile = profiles[0]._id;
    }

    return context;
  }

  /* -------------------------------------------- */

  /**
   * Determine the label for a profile in the ability use dialog.
   * @param {SummonsProfile} profile  Profile for which to generate the label.
   * @param {object} rollData         Roll data used to prepare the count.
   * @returns {string}
   */
  getProfileLabel(profile, rollData) {
    if ( profile.name ) return profile.name;
    switch ( this.activity.transform.mode ) {
      case "cr":
        const cr = simplifyBonus(profile.cr, rollData);
        return game.i18n.format("DND5E.TRANSFORM.Profile.ChallengeRatingLabel", { cr: formatCR(cr) });
      default:
        const doc = fromUuidSync(profile.uuid);
        if ( doc ) return doc.name;
    }
    return "—";
  }
}

const {
  ArrayField: ArrayField$d, BooleanField: BooleanField$u, DocumentIdField: DocumentIdField$5, DocumentUUIDField: DocumentUUIDField$6, EmbeddedDataField: EmbeddedDataField$4,
  NumberField: NumberField$u, SchemaField: SchemaField$A, SetField: SetField$q, StringField: StringField$M
} = foundry.data.fields;

/**
 * @import { TransformActivityData, TransformProfile } from "./_types.mjs";
 */

/**
 * Data model for a transform activity.
 * @extends {BaseActivityData<TransformActivityData>}
 * @mixes TransformActivityData
 */
class BaseTransformActivityData extends BaseActivityData {
  /** @inheritDoc */
  static defineSchema() {
    return {
      ...super.defineSchema(),
      profiles: new ArrayField$d(new SchemaField$A({
        _id: new DocumentIdField$5({ initial: () => foundry.utils.randomID() }),
        cr: new FormulaField({ deterministic: true }),
        level: new SchemaField$A({
          min: new NumberField$u({ integer: true, min: 0 }),
          max: new NumberField$u({ integer: true, min: 0 })
        }),
        movement: new SetField$q(new StringField$M()),
        name: new StringField$M(),
        sizes: new SetField$q(new StringField$M()),
        types: new SetField$q(new StringField$M()),
        uuid: new DocumentUUIDField$6({ type: "Actor" })
      })),
      settings: new EmbeddedDataField$4(TransformationSetting, { nullable: true, initial: null }),
      transform: new SchemaField$A({
        customize: new BooleanField$u(),
        mode: new StringField$M({ initial: "cr" }),
        preset: new StringField$M()
      })
    };
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  get applicableEffects() {
    return null;
  }

  /* -------------------------------------------- */

  /**
   * Transform profiles that can be performed based on spell/character/class level.
   * @type {TransformProfile[]}
   */
  get availableProfiles() {
    const level = this.relevantLevel;
    return this.profiles.filter(e => ((e.level.min ?? -Infinity) <= level) && (level <= (e.level.max ?? Infinity)));
  }

  /* -------------------------------------------- */
  /*  Data Migrations                             */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static migrateData(source) {
    super.migrateData(source);
    if ( source.transform?.identifier ) {
      foundry.utils.setProperty(source, "visibility.identifier", source.transform.identifier);
      delete source.transform.identifier;
    }
    return source;
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareFinalData(rollData) {
    super.prepareFinalData(rollData);
    if ( this.transform.customize && !this.settings ) {
      this.settings = new TransformationSetting({ preset: this.transform.preset });
    }
    else if ( !this.transform.customize ) this.settings = new TransformationSetting({
      ...(CONFIG.DND5E.transformation.presets[this.transform.preset]?.settings ?? {}),
      preset: this.transform.preset
    });
  }
}

/**
 * @import { TransformProfile } from "../../data/activity/_types.mjs";
 */

/**
 * Activity for transforming an actor into something else.
 */
class TransformActivity extends ActivityMixin(BaseTransformActivityData) {
  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static LOCALIZATION_PREFIXES = [...super.LOCALIZATION_PREFIXES, "DND5E.TRANSFORM"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static metadata = Object.freeze(
    foundry.utils.mergeObject(super.metadata, {
      type: "transform",
      img: "systems/dnd5e/icons/svg/activity/transform.svg",
      title: "DND5E.TRANSFORM.Title",
      hint: "DND5E.TRANSFORM.Hint",
      sheetClass: TransformSheet,
      usage: {
        actions: {
          transformActor: TransformActivity.#transformActor
        },
        dialog: TransformUsageDialog
      }
    }, { inplace: false })
  );

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Does the user have permissions to transform?
   * @type {boolean}
   */
  get canTransform() {
    return game.user.can("ACTOR_CREATE") && (game.user.isGM || game.settings.get("dnd5e", "allowPolymorphing"));
  }

  /* -------------------------------------------- */
  /*  Activation                                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _prepareUsageConfig(config) {
    config = super._prepareUsageConfig(config);
    config.transform ??= {};
    config.transform.profile ??= this.availableProfiles[0]?._id ?? null;
    return config;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _requiresConfigurationDialog(config) {
    return super._requiresConfigurationDialog(config) || (this.availableProfiles.length > 1);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _finalizeMessageConfig(usageConfig, messageConfig, results) {
    await super._finalizeMessageConfig(usageConfig, messageConfig, results);
    if ( usageConfig.transform?.profile ) {
      foundry.utils.setProperty(messageConfig.data, "flags.dnd5e.transform.profile", usageConfig.transform.profile);
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _usageChatButtons(message) {
    if ( !this.availableProfiles.length ) return super._usageChatButtons(message);
    return [{
      label: game.i18n.localize("DND5E.TRANSFORM.Action.Transform"),
      icon: '<i class="fa-solid fa-frog" inert></i>',
      dataset: {
        action: "transformActor"
      }
    }].concat(super._usageChatButtons(message));
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  shouldHideChatButton(button, message) {
    if ( button.dataset.action === "transformActor" ) return !this.canTransform;
    return super.shouldHideChatButton(button, message);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _finalizeUsage(config, results) {
    const profile = this.profiles.find(p => p._id === config.transform?.profile);
    if ( profile ) {
      const uuid = !this.transform.mode ? profile.uuid : await this.queryActor(profile);
      if ( uuid ) {
        if ( results.message instanceof ChatMessage ) results.message.setFlag("dnd5e", "transform.uuid", uuid);
        else foundry.utils.setProperty(results.message, "flags.dnd5e.transform.uuid", uuid);
      }
    }
    await super._finalizeUsage(config, results);
  }

  /* -------------------------------------------- */

  /**
   * Request a specific actor to transform into from the player.
   * @param {TransformProfile} profile  Profile used for transformation.
   * @returns {Promise<string|null>}    UUID of the actor to transform into or `null` if canceled.
   */
  async queryActor(profile) {
    const locked = { documentClass: "Actor", types: new Set(["npc"]), additional: {} };
    if ( profile.cr !== "" ) locked.additional = {
      cr: { max: simplifyBonus(profile.cr, this.getRollData({ deterministic: true })) }
    };
    const makeFilter = (data, key, negative) => locked.additional[key] = Array.from(data).reduce((obj, type) => {
      obj[type] = negative ? -1 : 1;
      return obj;
    }, {});
    if ( profile.sizes.size ) makeFilter(profile.sizes, "size");
    if ( profile.types.size ) makeFilter(profile.types, "type");
    if ( profile.movement.size ) makeFilter(profile.movement, "movement", true);
    return CompendiumBrowser.selectOne({ filters: { locked }});
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle transforming selected actors from the chat card.
   * @this {TransformActivity}
   * @param {PointerEvent} event     Triggering click event.
   * @param {HTMLElement} target     The capturing HTML element which defined a [data-action].
   * @param {ChatMessage5e} message  Message associated with the activation.
   */
  static async #transformActor(event, target, message) {
    const targets = getSceneTargets();
    if ( !targets.length && game.user.character ) targets.push(game.user.character);
    if ( !targets.length ) {
      ui.notifications.warn("DND5E.ActionWarningNoToken", { localize: true });
      return;
    }

    const profileId = message.getFlag("dnd5e", "transform.profile");
    const profile = this.profiles.find(p => p._id === profileId) || this.profiles[0];
    const uuid = message.getFlag("dnd5e", "transform.uuid") ?? await this.queryActor(profile);
    const source = await fromUuid(uuid);
    if ( !source ) {
      ui.notifications.warn("DND5E.TRANSFORM.Warning.SourceActor", { localize: true });
      return;
    }

    for ( const token of targets ) {
      const actor = token instanceof Actor ? token : token.actor;
      await actor.transformInto(source, this.settings);
      // TODO: Create message for transformed actors
    }
  }
}

/**
 * Sheet for the utility activity.
 */
class UtilitySheet extends ActivitySheet {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["utility-activity"]
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  static PARTS = {
    ...super.PARTS,
    effect: {
      template: "systems/dnd5e/templates/activity/utility-effect.hbs",
      templates: super.PARTS.effect.templates
    }
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareIdentityContext(context, options) {
    context = await super._prepareIdentityContext(context, options);
    context.behaviorFields.push({
      field: context.fields.roll.fields.prompt,
      value: context.source.roll.prompt,
      input: context.inputs.createCheckboxInput
    });
    return context;
  }
}

const { BooleanField: BooleanField$t, SchemaField: SchemaField$z, StringField: StringField$L } = foundry.data.fields;

/**
 * @import { UtilityActivityData } from "./_types.mjs";
 */

/**
 * Data model for an utility activity.
 * @extends {BaseActivityData<UtilityActivityData>}
 * @mixes UtilityActivityData
 */
class BaseUtilityActivityData extends BaseActivityData {
  /** @inheritDoc */
  static defineSchema() {
    return {
      ...super.defineSchema(),
      roll: new SchemaField$z({
        formula: new FormulaField(),
        name: new StringField$L(),
        prompt: new BooleanField$t(),
        visible: new BooleanField$t()
      })
    };
  }

  /* -------------------------------------------- */
  /*  Data Migration                              */
  /* -------------------------------------------- */

  /** @override */
  static transformTypeData(source, activityData, options) {
    return foundry.utils.mergeObject(activityData, {
      roll: {
        formula: source.system.formula ?? "",
        name: "",
        prompt: false,
        visible: false
      }
    });
  }
}

/**
 * @import {
 *   BasicRollDialogConfiguration, BasicRollMessageConfiguration, BasicRollProcessConfiguration
 * } from "../../dice/_types.mjs";
 */

/**
 * Generic activity for applying effects and rolling an arbitrary die.
 */
class UtilityActivity extends ActivityMixin(BaseUtilityActivityData) {
  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static LOCALIZATION_PREFIXES = [...super.LOCALIZATION_PREFIXES, "DND5E.UTILITY"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static metadata = Object.freeze(
    foundry.utils.mergeObject(super.metadata, {
      type: "utility",
      img: "systems/dnd5e/icons/svg/activity/utility.svg",
      title: "DND5E.UTILITY.Title",
      hint: "DND5E.UTILITY.Hint",
      sheetClass: UtilitySheet,
      usage: {
        actions: {
          rollFormula: UtilityActivity.#rollFormula
        }
      }
    }, { inplace: false })
  );

  /* -------------------------------------------- */
  /*  Activation                                  */
  /* -------------------------------------------- */

  /** @override */
  _usageChatButtons(message) {
    if ( !this.roll.formula ) return super._usageChatButtons(message);
    return [{
      label: this.roll.name || game.i18n.localize("DND5E.Roll"),
      icon: '<i class="fa-solid fa-dice" inert></i>',
      dataset: {
        action: "rollFormula",
        visibility: this.roll.visible ? "all" : undefined
      }
    }].concat(super._usageChatButtons(message));
  }

  /* -------------------------------------------- */
  /*  Rolling                                     */
  /* -------------------------------------------- */

  /**
   * Roll the formula attached to this utility.
   * @param {BasicRollProcessConfiguration} [config]   Configuration information for the roll.
   * @param {BasicRollDialogConfiguration} [dialog]    Configuration for the roll dialog.
   * @param {BasicRollMessageConfiguration} [message]  Configuration for the roll message.
   * @returns {Promise<BasicRoll[]|void>}              The created Roll instances.
   */
  async rollFormula(config={}, dialog={}, message={}) {
    if ( !this.roll.formula ) {
      console.warn(`No formula defined for the activity ${this.name} on ${this.item.name} (${this.uuid}).`);
      return;
    }

    const rollConfig = foundry.utils.deepClone(config);
    rollConfig.hookNames = [...(config.hookNames ?? []), "formula"];
    rollConfig.rolls = [{ parts: [this.roll.formula], data: this.getRollData() }].concat(config.rolls ?? []);
    rollConfig.subject = this;

    const dialogConfig = foundry.utils.mergeObject({
      configure: this.roll.prompt,
      options: {
        window: {
          title: this.item.name,
          subtitle: "DND5E.RollConfiguration.Title",
          icon: this.item.img
        }
      }
    }, dialog);

    const messageConfig = foundry.utils.mergeObject({
      create: true,
      data: {
        flavor: `${this.item.name} - ${this.roll.label || game.i18n.localize("DND5E.OtherFormula")}`,
        flags: {
          dnd5e: {
            ...this.messageFlags,
            messageType: "roll",
            roll: { type: "generic" }
          }
        }
      }
    }, message);

    const rolls = await CONFIG.Dice.BasicRoll.build(rollConfig, dialogConfig, messageConfig);
    if ( !rolls.length ) return;

    /**
     * A hook event that fires after a formula has been rolled for a Utility activity.
     * @function dnd5e.rollFormula
     * @memberof hookEvents
     * @param {BasicRoll[]} rolls             The resulting rolls.
     * @param {object} data
     * @param {UtilityActivity} data.subject  The Activity that performed the roll.
     */
    Hooks.callAll("dnd5e.rollFormula", rolls, { subject: this });
    Hooks.callAll("dnd5e.rollFormulaV2", rolls, { subject: this });

    return rolls;
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle rolling the formula attached to this utility.
   * @this {UtilityActivity}
   * @param {PointerEvent} event     Triggering click event.
   * @param {HTMLElement} target     The capturing HTML element which defined a [data-action].
   * @param {ChatMessage5e} message  Message associated with the activation.
   */
  static #rollFormula(event, target, message) {
    this.rollFormula({ event });
  }
}

var _module$x = /*#__PURE__*/Object.freeze({
  __proto__: null,
  ActivityMixin: ActivityMixin,
  AttackActivity: AttackActivity,
  CastActivity: CastActivity,
  CheckActivity: CheckActivity,
  DamageActivity: DamageActivity,
  EnchantActivity: EnchantActivity,
  EnchantmentError: EnchantmentError$1,
  ForwardActivity: ForwardActivity,
  HealActivity: HealActivity,
  OrderActivity: OrderActivity,
  SaveActivity: SaveActivity,
  SummonActivity: SummonActivity,
  TransformActivity: TransformActivity,
  UtilityActivity: UtilityActivity
});

const { BooleanField: BooleanField$s } = foundry.data.fields;

/**
 * @import { RestConfiguration } from "../../../documents/_types.mjs";
 */

/**
 * Dialog with shared resting functionality.
 */
class BaseRestDialog extends Dialog5e {
  constructor(options={}) {
    super(options);
    this.actor = options.document;
    this.#config = options.config;
  }

  /* -------------------------------------------- */

  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["rest"],
    config: null,
    document: null,
    form: {
      handler: BaseRestDialog.#handleFormSubmission
    },
    position: {
      width: 380
    },
    templates: ["systems/dnd5e/templates/actors/rest/rest-request.hbs"]
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The actor being rested.
   * @type {Actor5e}
   */
  actor;

  /* -------------------------------------------- */

  /**
   * The rest configuration.
   * @type {RestConfiguration}
   */
  #config;

  get config() {
    return this.#config;
  }

  /* -------------------------------------------- */

  /**
   * Is the resting actor a party?
   * @type {boolean}
   */
  get isPartyGroup() {
    return this.actor.type === "group";
  }

  /* -------------------------------------------- */

  /**
   * Should the user be prompted as to whether a new day has occurred?
   * @type {boolean}
   */
  get promptNewDay() {
    const duration = CONFIG.DND5E.restTypes[this.config.type]
      ?.duration?.[game.settings.get("dnd5e", "restVariant")] ?? 0;
    // Only prompt if rest is longer than 10 minutes and less than 24 hours
    return (duration > 10) && (duration < 1440);
  }

  /* -------------------------------------------- */

  /**
   * Was the rest button pressed?
   * @type {boolean}
   */
  #rested = false;

  get rested() {
    return this.#rested;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = {
      ...await super._prepareContext(options),
      actor: this.actor,
      config: this.config,
      fields: [],
      hitPoints: [],
      result: this.result,
      hd: this.actor.system.attributes?.hd,
      hp: this.actor.system.attributes?.hp,
      isGroup: this.actor.type === "group",
      variant: game.settings.get("dnd5e", "restVariant")
    };
    if ( this.promptNewDay ) context.fields.push({
      disabled: !!this.config.request,
      field: new BooleanField$s({
        label: game.i18n.localize("DND5E.REST.NewDay.Label"),
        hint: game.i18n.localize("DND5E.REST.NewDay.Hint")
      }),
      input: context.inputs.createCheckboxInput,
      name: "newDay",
      value: context.config.newDay
    });

    const rest = CONFIG.DND5E.restTypes[this.config.type];
    if ( "recoverTemp" in rest ) context.hitPoints.push({
      disabled: !!this.config.request,
      field: new BooleanField$s({
        label: game.i18n.localize("DND5E.REST.RecoverTempHP.Label")
      }),
      input: context.inputs.createCheckboxInput,
      name: "recoverTemp",
      value: context.config.recoverTemp
    });
    if ( "recoverTempMax" in rest ) context.hitPoints.push({
      disabled: !!this.config.request,
      field: new BooleanField$s({
        label: game.i18n.localize("DND5E.REST.RecoverTempMaxHP.Label"),
        hint: game.i18n.localize("DND5E.REST.RecoverTempMaxHP.Hint")
      }),
      input: context.inputs.createCheckboxInput,
      name: "recoverTempMax",
      value: context.config.recoverTempMax
    });

    if ( this.isPartyGroup ) {
      const restSettings = this.actor.getFlag("dnd5e", "restSettings") ?? {};
      context.request = [
        {
          field: new BooleanField$s({
            label: game.i18n.localize("DND5E.REST.Request.AutoRest.Label"),
            hint: game.i18n.localize("DND5E.REST.Request.AutoRest.Hint")
          }),
          name: "autoRest",
          input: context.inputs.createCheckboxInput,
          value: restSettings.autoRest
        },
        ...this.actor.system.members
          .filter(m => ["character", "npc"].includes(m.actor?.type))
          .map(m => ({
            field: new BooleanField$s({
              label: m.actor.name
            }),
            name: `targets.${m.actor.id}`,
            input: context.inputs.createCheckboxInput,
            value: restSettings.targets ? restSettings.targets?.has(m.actor.id) : true
          }))
      ];
    }

    return context;
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle submission of the dialog using the form buttons.
   * @this {BaseRestDialog}
   * @param {Event|SubmitEvent} event    The form submission event.
   * @param {HTMLFormElement} form       The submitted form.
   * @param {FormDataExtended} formData  Data from the dialog.
   */
  static async #handleFormSubmission(event, form, formData) {
    const data = foundry.utils.expandObject(formData.object);
    if ( this.isPartyGroup ) {
      data.targets = filteredKeys(data.targets ?? {});
      this.actor.setFlag("dnd5e", "restSettings", data);
    }
    foundry.utils.mergeObject(this.config, data);
    this.#rested = true;
    await this.close();
  }

  /* -------------------------------------------- */
  /*  Factory Methods                             */
  /* -------------------------------------------- */

  /**
   * A helper to handle displaying and responding to the dialog.
   * @param {Actor5e} actor              The actor that is resting.
   * @param {RestConfiguration}  config  Configuration information for the rest.
   * @returns {Promise<RestConfiguration>}
   */
  static async configure(actor, config) {
    return new Promise((resolve, reject) => {
      const app = new this({
        config,
        buttons: [
          {
            default: true,
            icon: "fa-solid fa-bed",
            label: game.i18n.localize("DND5E.REST.Label"),
            name: "rest",
            type: "submit"
          }
        ],
        document: actor
      });
      app.addEventListener("close", () => app.rested ? resolve(app.config) : reject(), { once: true });
      app.render({ force: true });
    });
  }
}

const { BooleanField: BooleanField$r } = foundry.data.fields;

/**
 * Dialog for configuring a short rest.
 */
class ShortRestDialog extends BaseRestDialog {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["short-rest"],
    actions: {
      rollHitDie: ShortRestDialog.#rollHitDie
    },
    window: {
      title: "DND5E.REST.Short.Label"
    }
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  static PARTS = {
    ...super.PARTS,
    content: {
      template: "systems/dnd5e/templates/actors/rest/short-rest.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Currently selected hit dice denomination.
   * @type {string}
   */
  #denom;

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    context.autoRoll = new BooleanField$r({
      label: game.i18n.localize("DND5E.REST.HitDice.AutoSpend.Label"),
      hint: game.i18n.localize("DND5E.REST.HitDice.AutoSpend.Hint")
    });

    if ( this.actor.type === "npc" ) {
      const hd = this.actor.system.attributes.hd;
      context.hitDice = {
        canRoll: hd.value > 0,
        denomination: `d${hd.denomination}`,
        options: [{
          value: `d${hd.denomination}`,
          label: `d${hd.denomination} (${game.i18n.format("DND5E.HITDICE.Available", { number: hd.value })})`
        }]
      };
    }

    else if ( foundry.utils.hasProperty(this.actor, "system.attributes.hd") ) {
      context.hitDice = {
        canRoll: this.actor.system.attributes.hd.value > 0,
        options: Object.entries(this.actor.system.attributes.hd.bySize).map(([value, number]) => ({
          value, label: `${value} (${game.i18n.format("DND5E.HITDICE.Available", { number })})`, number
        }))
      };
      context.denomination = (this.actor.system.attributes.hd.bySize[this.#denom] > 0)
        ? this.#denom : context.hitDice.options.find(o => o.number > 0)?.value;
    }

    else context.fields.unshift({
      field: context.autoRoll,
      input: context.inputs.createCheckboxInput,
      name: "autoHD",
      value: context.config.autoHD
    });

    return context;
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle rolling a hit die.
   * @this {ShortRestDialog}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static async #rollHitDie(event, target) {
    this.#denom = this.form.denom.value;
    await this.actor.rollHitDie({ denomination: this.#denom });
    foundry.utils.mergeObject(this.config, new foundry.applications.ux.FormDataExtended(this.form).object);
    this.render();
  }
}

const { BooleanField: BooleanField$q } = foundry.data.fields;

/**
 * Dialog for configuring a long rest.
 */
class LongRestDialog extends BaseRestDialog {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["long-rest"],
    window: {
      title: "DND5E.REST.Long.Label"
    }
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  static PARTS = {
    ...super.PARTS,
    content: {
      template: "systems/dnd5e/templates/actors/rest/long-rest.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);

    const { enabled } = game.settings.get("dnd5e", "bastionConfiguration");
    if ( game.user.isGM && context.isGroup && enabled ) context.fields.unshift({
      field: new BooleanField$q({ label: game.i18n.localize("DND5E.Bastion.Action.BastionTurn") }),
      input: context.inputs.createCheckboxInput,
      name: "advanceBastionTurn",
      value: context.config.advanceBastionTurn
    });

    return context;
  }
}

/**
 * @import { SkillToolRollConfigurationDialogOptions } from "../../dice/_types.mjs";
 */

/**
 * Extended roll configuration dialog that allows selecting abilities.
 * @extends D20RollConfigurationDialog<SkillToolRollConfigurationDialogOptions>
 */
class SkillToolRollConfigurationDialog extends D20RollConfigurationDialog {
  /** @override */
  static DEFAULT_OPTIONS = {
    chooseAbility: true
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareConfigurationContext(context, options) {
    context = await super._prepareConfigurationContext(context, options);
    if ( this.options.chooseAbility ) context.fields.unshift({
      field: new foundry.data.fields.StringField({
        required: true, blank: false, label: game.i18n.localize("DND5E.Abilities")
      }),
      name: "ability",
      options: Object.entries(CONFIG.DND5E.abilities).map(([value, { label }]) => ({ value, label })),
      value: this.config.ability
    });
    return context;
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onChangeForm(formConfig, event) {
    super._onChangeForm(formConfig, event);
    if ( this.config.skill && (event.target?.name === "ability") ) {
      const skillLabel = CONFIG.DND5E.skills[this.config.skill]?.label ?? "";
      const ability = event.target.value ?? this.config.ability;
      const abilityLabel = CONFIG.DND5E.abilities[ability]?.label ?? "";
      const flavor = game.i18n.format("DND5E.SkillPromptTitle", { skill: skillLabel, ability: abilityLabel });
      foundry.utils.setProperty(this.message, "data.flavor", flavor);
      this._updateFrame({ window: { title: flavor } });
    }
  }
}

/**
 * @import { PropertyAttributionDescription } from "./_types.mjs";
 */

/**
 * Interface for viewing what factors went into determining a specific property.
 *
 * @param {Document} object                                The Document that owns the property being attributed.
 * @param {PropertyAttributionDescription[]} 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 Application5e {
  constructor(object, attributions, property, options={}) {
    super(options);
    this.object = object;
    this.attributions = attributions;
    this.property = property;
  }

  /* -------------------------------------------- */

  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["property-attribution"],
    window: {
      frame: false,
      positioned: false
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    attribution: {
      template: "systems/dnd5e/templates/apps/property-attribution.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /**
   * Prepare tooltip contents.
   * @returns {Promise<string>}
   */
  async renderTooltip() {
    await this.render({ force: true });
    return this.element.innerHTML;
  }

  /* -------------------------------------------- */

  /** @override */
  _insertElement(element) {}

  /* -------------------------------------------- */

  /** @override */
  async _prepareContext(options) {
    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: game.i18n.localize(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
    };
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * 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;
  }
}

const { NumberField: NumberField$t, StringField: StringField$K } = foundry.data.fields;

/**
 * @import { TravelPace5e } from "./_types.mjs";
 */

/**
 * Field for storing travel data.
 */
class TravelField extends foundry.data.fields.SchemaField {
  constructor(fields={}, { initialTime=8, initialUnits=null, ...options }={}) {
    fields = {
      pace: new StringField$K({
        required: true, blank: false, initial: "normal", choices: () => CONFIG.DND5E.travelPace,
        label: "DND5E.TRAVEL.Label"
      }),
      paces: new MappingField(new FormulaField({ deterministic: true }), {
        initialKeys: CONFIG.DND5E.travelTypes, initialKeysOnly: true
      }),
      speeds: new MappingField(new FormulaField({ deterministic: true }), {
        initialKeys: CONFIG.DND5E.travelTypes, initialKeysOnly: true
      }),
      time: new NumberField$t({
        positive: true, integer: true, initial: initialTime,
        label: "DND5E.TRAVEL.FIELDS.time.label", hint: "DND5E.TRAVEL.FIELDS.time.hint"
      }),
      units: new StringField$K({
        required: true, nullable: true, blank: false, initial: initialUnits, label: "DND5E.UNITS.TRAVEL.Label"
      }),
      ...fields
    };
    Object.entries(fields).forEach(([k, v]) => !v ? delete fields[k] : null);
    super(fields, options);
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /**
   * Prepare travel data.
   * @this {ActorDataModel}
   * @param {object} rollData  Actor's roll data.
   */
  static prepareData(rollData) {
    const { movement, travel } = this.attributes;
    const { pace: paceMode, units } = travel;
    Object.defineProperty(travel.paces, "max", { value: 0, writable: true });
    Object.defineProperty(travel.speeds, "max", { value: 0, writable: true });
    travel.prePace = {};

    const noMovement = this.parent.hasConditionEffect("noMovement");
    const halfMovement = this.parent.hasConditionEffect("halfMovement");
    const multiplier = noMovement ? 0 : halfMovement ? 0.5 : 1;
    for ( const type of Object.keys(travel.speeds) ) {
      let pace = travel.paces[type] = Math.max(0, simplifyBonus(travel.paces[type], rollData)) * multiplier;
      const speed = travel.speeds[type] = Math.max(0, simplifyBonus(travel.speeds[type], rollData)) * multiplier;
      if ( speed && !pace ) pace = travel.paces[type] = speed * travel.time;
      if ( pace && !speed ) travel.speeds[type] = Math.floor(pace / travel.time);
      if ( pace && paceMode ) {
        travel.prePace[type] = pace;
        travel.paces[type] = TravelField.applyPaceMultiplier(pace, paceMode, CONFIG.DND5E.travelUnits[units]?.type);
      }
      if ( pace > travel.paces.max ) travel.paces.max = pace;
      if ( speed > travel.speeds.max ) travel.speeds.max = speed;
    }

    if ( !movement ) return;
    for ( const [type, { travel: travelType="land" }] of Object.entries(CONFIG.DND5E.movementTypes) ) {
      if ( !movement[type] ) continue;
      const speed = TravelField.convertMovementToTravel(movement[type], movement.units, units);
      const pace = travel.paces[travelType] ||= TravelField.applyPaceMultiplier(
        speed * travel.time, paceMode, CONFIG.DND5E.movementUnits[movement.units]?.type
      );
      travel.speeds[travelType] ||= speed;
      if ( pace > travel.paces.max ) travel.paces.max = pace;
      if ( speed > travel.speeds.max ) travel.speeds.max = speed;
    }
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Apply the pace multiplier based on the current pace mode, taking the special casing for default imperial
   * movement into account.
   * @param {number} value
   * @param {TravelPace5e} pace
   * @param {"imperial"|"metric"} [unitType]
   * @returns {number}
   */
  static applyPaceMultiplier(value, pace, unitType) {
    const { normal } = CONFIG.DND5E.travelPace;
    const config = CONFIG.DND5E.travelPace[pace];
    if ( (unitType === "imperial") && (value === normal.standard) && config?.standard ) return config.standard;
    value *= CONFIG.DND5E.travelPace[pace]?.multiplier ?? 1;
    if ( config?.round === "down" ) value = Math.floor(value);
    else if ( config?.round === "up" ) value = Math.ceil(value);
    return value;
  }

  /* -------------------------------------------- */

  /**
   * Convert combat movement speed in feet or meters to travel speed in mph or km/h.
   * @param {number} value
   * @param {string} initialUnit
   * @param {string} finalUnit
   * @returns {number}
   */
  static convertMovementToTravel(value, initialUnit, finalUnit) {
    const fromConfig = CONFIG.DND5E.movementUnits[initialUnit];
    const toConfig = CONFIG.DND5E.movementUnits[finalUnit];
    if ( (fromConfig?.type === "metric") && (toConfig?.type === "metric") ) {
      value = convertLength(value, initialUnit, "m", { strict: false });
      return convertTravelSpeed(value / 2, "kph", { strict: false, to: finalUnit }).value;
    } else {
      value = convertLength(value, initialUnit, "ft", { strict: false });
      return convertTravelSpeed(value / 10, "mph", { strict: false, to: finalUnit }).value;
    }
  }

  /* -------------------------------------------- */

  /**
   * Apply rules for travel pace to the given skill.
   * @param {TravelPace5e} pace  The travel pace.
   * @param {string} skill       The skill.
   * @returns {{ advantage: boolean, disadvantage: boolean }}
   */
  static getTravelPaceMode(pace, skill) {
    const config = CONFIG.DND5E.skills[skill];
    return {
      advantage: config?.pace?.advantage?.has(pace) ?? false,
      disadvantage: config?.pace?.disadvantage?.has(pace) ?? false
    };
  }
}

const { SetField: SetField$p, StringField: StringField$J } = foundry.data.fields;

/**
 * @import { ActivationsData } from "./_types.mjs";
 */

/**
 * A field for storing relative UUIDs to activations on the actor.
 */
class ActivationsField extends SetField$p {
  constructor() {
    super(new StringField$J());
  }

  /* -------------------------------------------- */

  /**
   * Find any activity relative UUIDs on this actor that can be used during a set of periods.
   * @param {Actor5e} actor
   * @param {string[]} periods
   * @returns {string[]}
   */
  static getActivations(actor, periods) {
    return actor.items
      .map(i => i.system.activities
        ?.filter(a => periods.includes(a.activation?.type) && a.canUse)
        .map(a => a.relativeUUID) ?? [])
      .flat();
  }

  /* -------------------------------------------- */

  /**
   * Prepare activations for display on chat card.
   * @this {ActivationsData}
   * @param {Actor5e} actor  Actor to which this activations can be used.
   * @returns {Activity[]}
   */
  static processActivations(actor) {
    return Array.from(this)
      .map(uuid => fromUuidSync(uuid, { relative: actor, strict: false }))
      .filter(_ => _)
      .sort((lhs, rhs) => (lhs.item.sort - rhs.item.sort) || (lhs.sort - rhs.sort));
  }
}

/**
 * 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
 * @returns {Promise}
 */
async function create5eMacro(dropData, slot) {
  const macroData = { type: "script", scope: "actor" };
  switch ( dropData.type ) {
    case "Activity":
      const activity = await fromUuid(dropData.uuid);
      if ( !activity ) {
        ui.notifications.warn("MACRO.5eUnownedWarn", { localize: true });
        return null;
      }
      foundry.utils.mergeObject(macroData, {
        name: `${activity.item.name}: ${activity.name}`,
        img: activity.img,
        command: `dnd5e.documents.macro.rollItem("${activity.item._source.name}", { activityName: "${
          activity._source.name}", event });`,
        flags: { "dnd5e.itemMacro": true }
      });
      break;
    case "Item":
      const itemData = await Item.implementation.fromDropData(dropData);
      if ( !itemData ) {
        ui.notifications.warn("MACRO.5eUnownedWarn", { localize: true });
        return null;
      }
      foundry.utils.mergeObject(macroData, {
        name: itemData.name,
        img: itemData.img,
        command: `dnd5e.documents.macro.rollItem("${itemData._source.name}", { event })`,
        flags: { "dnd5e.itemMacro": true }
      });
      break;
    case "ActiveEffect":
      const effectData = await ActiveEffect.implementation.fromDropData(dropData);
      if ( !effectData ) {
        ui.notifications.warn("MACRO.5eUnownedWarn", { localize: true });
        return null;
      }
      foundry.utils.mergeObject(macroData, {
        name: effectData.name,
        img: effectData.img,
        command: `dnd5e.documents.macro.toggleEffect("${effectData.name}")`,
        flags: { "dnd5e.effectMacro": true }
      });
      break;
    default:
      return;
  }

  // Assign the macro to the hotbar
  const macro = game.macros.find(m => {
    return (m.name === macroData.name) && (m.command === macroData.command) && m.isAuthor;
  }) || await Macro.create(macroData);
  game.user.assignHotbarMacro(macro, slot);
}

/* -------------------------------------------- */

/**
 * Find a document of the specified name and type on an assigned or selected actor.
 * @param {string} name          Document name to locate.
 * @param {string} documentType  Type of embedded document (e.g. "Item" or "ActiveEffect").
 * @returns {Document}           Document if found, otherwise nothing.
 */
function getMacroTarget(name, documentType) {
  let actor;
  const speaker = ChatMessage.getSpeaker();
  if ( speaker.token ) actor = game.actors.tokens[speaker.token];
  actor ??= game.actors.get(speaker.actor);
  if ( !actor ) {
    ui.notifications.warn("MACRO.5eNoActorSelected", {localize: true});
    return null;
  }

  const collection = (documentType === "Item") ? actor.items : Array.from(actor.allApplicableEffects());

  // Find item in collection
  const documents = collection.filter(i => i._source.name === name);
  const type = game.i18n.localize(`DOCUMENT.${documentType}`);
  if ( documents.length === 0 ) {
    ui.notifications.warn(game.i18n.format("MACRO.5eMissingTargetWarn", { actor: actor.name, type, name }));
    return null;
  }
  if ( documents.length > 1 ) {
    ui.notifications.warn(game.i18n.format("MACRO.5eMultipleTargetsWarn", { actor: actor.name, type, name }));
  }
  return documents[0];
}

/* -------------------------------------------- */

/**
 * Trigger an item to be used when a macro is clicked.
 * @param {string} itemName                Name of the item on the selected actor to trigger.
 * @param {object} [options={}]
 * @param {string} [options.activityName]  Name of a specific activity on the item to trigger.
 * @param {Event} [options.event]          The triggering event.
 * @returns {Promise<ChatMessage|object>}  Usage result.
 */
function rollItem(itemName, { activityName, event }={}) {
  let target = getMacroTarget(itemName, "Item");
  if ( activityName ) target = target?.system.activities?.getName(activityName);
  return target?.use({ event, legacy: false });
}

/* -------------------------------------------- */

/**
 * 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
});

const slugify = value => value?.slugify().replaceAll("-", "").replaceAll("(", "").replaceAll(")", "");

/**
 * Set up custom text enrichers.
 */
function registerCustomEnrichers() {
  const stringNames = [
    "attack", "award", "check", "concentration", "damage", "heal", "healing", "item", "save", "skill", "tool"
  ];
  CONFIG.TextEditor.enrichers.push({
    pattern: new RegExp(`\\[\\[/(?<type>${stringNames.join("|")})(?<config> .*?)?]](?!])(?:{(?<label>[^}]+)})?`, "gi"),
    enricher: enrichString
  },
  {
    pattern: /\[\[(?<type>language|lookup) (?<config>[^\]]+)]](?:{(?<label>[^}]+)})?/gi,
    enricher: enrichString
  },
  {
    pattern: /&(?<type>Reference)\[(?<config>[^\]]+)](?:{(?<label>[^}]+)})?/gi,
    enricher: enrichString
  });

  document.body.addEventListener("click", applyAction);
  document.body.addEventListener("click", awardAction);
  document.body.addEventListener("click", rollAction);
}

/* -------------------------------------------- */

/**
 * Parse the enriched string and provide the appropriate content.
 * @param {RegExpMatchArray} match       The regular expression match result.
 * @param {EnrichmentOptions} options    Options provided to customize text enrichment.
 * @returns {Promise<HTMLElement|null>}  An HTML element to insert in place of the matched text or null to
 *                                       indicate that no replacement should be made.
 */
async function enrichString(match, options) {
  let { type, config, label } = match.groups;
  config = parseConfig(config, { multiple: ["damage", "heal", "healing"].includes(type) });
  config._input = match[0];
  config._rules = getRulesVersion(config, options);
  delete config.rules;
  switch ( type.toLowerCase() ) {
    case "attack": return enrichAttack(config, label, options);
    case "award": return enrichAward(config, label);
    case "heal":
    case "healing": config._isHealing = true;
    case "damage": return enrichDamage(config, label, options);
    case "check":
    case "skill":
    case "tool": return enrichCheck(config, label, options);
    case "language": return enrichLanguage(config, label);
    case "lookup": return enrichLookup(config, label, options);
    case "concentration": config._isConcentration = true;
    case "save": return enrichSave(config, label, options);
    case "item": return enrichItem(config, label, options);
    case "reference": return enrichReference(config, label);
  }
  return null;
}

/* -------------------------------------------- */

/**
 * Parse a roll string into a configuration object.
 * @param {string} match  Matched configuration string.
 * @param {object} [options={}]
 * @param {boolean} [options.multiple=false]  Support splitting configuration by "&" into multiple sub-configurations.
 *                                            If set to `true` then an array of configs will be returned.
 * @returns {object|object[]}
 */
function parseConfig(match="", { multiple=false }={}) {
  if ( multiple ) return match.split("&").map(s => parseConfig(s));
  const config = { _config: match, values: [] };
  for ( const part of match.match(/(?:[^\s"]+|"[^"]*")+/g) ?? [] ) {
    if ( !part ) continue;
    const [key, value] = part.split("=");
    const valueLower = value?.toLowerCase();
    if ( value === undefined ) config.values.push(key.replace(/(^"|"$)/g, ""));
    else if ( ["true", "false"].includes(valueLower) ) config[key] = valueLower === "true";
    else if ( Number.isNumeric(value) ) config[key] = Number(value);
    else config[key] = value.replace(/(^"|"$)/g, "");
  }
  return config;
}

/* -------------------------------------------- */

/**
 * Determine the appropriate rules version based on the config override, provided item's parent's rule version,
 * provided document's rule version, or the system setting (in that order).
 * @param {object} [config={}]              Config object for the enrichment.
 * @param {EnrichmentOptions} [options={}]  Options provided to customize text enrichment.
 * @returns {"2014"|"2024"}
 */
function getRulesVersion(config={}, options={}) {
  if ( Number.isNumeric(config.rules) ) return String(config.rules);
  return options.relativeTo?.parent?.system?.source?.rules
    || options.relativeTo?.system?.source?.rules
    || (game.settings.get("dnd5e", "rulesVersion") === "modern" ? "2024" : "2014");
}

/* -------------------------------------------- */
/*  Attack Enricher                             */
/* -------------------------------------------- */

/**
 * Enrich an attack link using a pre-set to hit value.
 * @param {object} config              Configuration data.
 * @param {string} [label]             Optional label to replace default text.
 * @param {EnrichmentOptions} options  Options provided to customize text enrichment.
 * @returns {HTMLElement|null}         An HTML link if the attack could be built, otherwise null.
 *
 * @example Create an attack link using a fixed to hit:
 * ```[[/attack +5]]``` or ```[[/attack formula=5]]```
 * becomes
 * ```html
 * <a class="roll-action" data-type="attack" data-formula="+5">
 *   <i class="fa-solid fa-dice-d20" inert></i> +5
 * </a>
 * ```
 *
 * @example Create an attack link using a specific attack mode:
 * ```[[/attack +5]]``` or ```[[/attack formula=5 attackMode=thrown]]```
 * becomes
 * ```html
 * <a class="roll-action" data-type="attack" data-formula="+5" data-attack-mode="thrown">
 *   <i class="fa-solid fa-dice-d20" inert></i> +5
 * </a>
 * ```
 *
 * @example Link an enricher to an attack activity, either explicitly or automatically:
 * ```[[/attack activity=RLQlsLo5InKHZadn]]``` or ```[[/attack]]```
 * becomes
 * ```html
 * <a class="roll-action" data-type="attack" data-formula="+8" data-activity-uuid="...uuid...">
 *   <i class="fa-solid fa-dice-d20" inert"></i> +8
 * </a>
 * ```
 *
 * @example Display the full attack section:
 * ```[[/attack format=extended]]``` or ```[[/attack extended]]```
 * becomes
 * ```html
 * <span class="attack-extended">
 *   <em>Melee Attack Roll</em>:
 *   <span class="roll-link-group" data-type="attack" data-formula="+16" data-activity-uuid="...uuid...">
 *     <a class="roll-link"><i class="fa-solid fa-dice-d20" inert"></i> +16</a>
 *   </span>, reach 15 ft
 * </span>
 * ```
 */
async function enrichAttack(config, label, options) {
  if ( config.activity && config.formula ) {
    console.warn(`Activity ID and formula found while enriching ${config._input}, only one is supported.`);
    return null;
  }

  const formulaParts = [];
  if ( config.formula ) formulaParts.push(config.formula);
  for ( const value of config.values ) {
    if ( value in CONFIG.DND5E.attackModes ) config.attackMode = value;
    else if ( value === "extended" ) config.format = "extended";
    else formulaParts.push(value);
  }
  config.formula = Roll.defaultImplementation.replaceFormulaData(
    formulaParts.join(" "),
    options.rollData ?? options.relativeTo?.getRollData?.() ?? {}
  );

  const activity = config.activity ? options.relativeTo?.system?.activities?.get(config.activity)
    : !config.formula ? options.relativeTo?.system?.activities?.getByType("attack")[0] : null;

  if ( activity ) {
    if ( activity.type !== "attack" ) {
      console.warn(`Attack enricher linked to non-attack activity when enriching ${config._input}`);
      return null;
    }

    config.activityUuid = activity.uuid;
    const attackConfig = activity.getAttackData({ attackMode: config.attackMode });
    config.formula = simplifyRollFormula(
      Roll.defaultImplementation.replaceFormulaData(attackConfig.parts.join(" + "), attackConfig.data)
    );
    if ( attackConfig.data.scaling ) config.scaling ??= String(attackConfig.data.scaling.increase);
    delete config.activity;
  }

  if ( !config.activityUuid && !config.formula ) {
    console.warn(`No formula or linked activity found while enriching ${config._input}.`);
    return null;
  }

  config.type = "attack";
  if ( label ) return createRollLink(label, config, { classes: "roll-link-group roll-link" });

  let displayFormula = simplifyRollFormula(config.formula)?.trim() || "+0";
  if ( !displayFormula.startsWith("+") && !displayFormula.startsWith("-") ) displayFormula = `+${displayFormula}`;

  const span = document.createElement("span");
  span.className = "roll-link-group";
  _addDataset(span, config);
  span.innerHTML = game.i18n.format(`EDITOR.DND5E.Inline.Attack${config._rules === "2014" ? "Long" : "Short"}`, {
    formula: createRollLink(displayFormula).outerHTML
  });

  if ( config.format === "extended" ) {
    const type = game.i18n.format(`DND5E.ATTACK.Formatted.${config._rules}`, {
      type: game.i18n.getListFormatter({ type: "disjunction" }).format(
        Array.from(activity?.validAttackTypes ?? []).map(t => CONFIG.DND5E.attackTypes[t]?.label)
      ),
      classification: CONFIG.DND5E.attackClassifications[activity?.attack.type.classification]?.label ?? ""
    }).trim();
    const parts = [span.outerHTML, activity?.getRangeLabel(config.attackMode)];
    if ( config._rules === "2014" ) parts.push(activity?.target?.affects.labels?.statblock);

    const full = document.createElement("span");
    full.className = "attack-extended";
    full.innerHTML = game.i18n.format("EDITOR.DND5E.Inline.AttackExtended", {
      type, parts: game.i18n.getListFormatter({ type: "unit" }).format(parts.filter(_ => _))
    });
    return full;
  }

  return span;
}

/* -------------------------------------------- */
/*  Award Enricher                              */
/* -------------------------------------------- */

/**
 * Enrich an award block displaying amounts for each part granted with a GM-control for awarding to the party.
 * @param {object} config              Configuration data.
 * @param {string} [label]             Optional label to replace default text.
 * @param {EnrichmentOptions} options  Options provided to customize text enrichment.
 * @returns {HTMLElement|null}         An HTML link if the check could be built, otherwise null.
 */
async function enrichAward(config, label, options) {
  const command = config._config;
  let parsed;
  try {
    parsed = Award.parseAwardCommand(command);
  } catch(err) {
    console.warn(err.message);
    return null;
  }

  const block = document.createElement("span");
  block.classList.add("award-block", "dnd5e2");
  block.dataset.awardCommand = command;

  const entries = [];
  for ( let [key, amount] of Object.entries(parsed.currency) ) {
    const label = CONFIG.DND5E.currencies[key].label;
    amount = Number.isNumeric(amount) ? formatNumber(amount) : amount;
    entries.push(`
      <span class="award-entry">
        ${amount} <i class="currency ${key}" data-tooltip aria-label="${label}"></i>
      </span>
    `);
  }
  if ( parsed.xp ) entries.push(`
    <span class="award-entry">
      ${formatNumber(parsed.xp)} ${game.i18n.localize("DND5E.ExperiencePoints.Abbreviation")}
    </span>
  `);

  let award = game.i18n.getListFormatter({ type: "unit" }).format(entries);
  if ( parsed.each ) award = game.i18n.format("EDITOR.DND5E.Inline.AwardEach", { award });

  block.innerHTML += `
    ${award}
    <a class="award-link" data-action="awardRequest">
      <i class="fa-solid fa-trophy"></i> ${label ?? game.i18n.localize("DND5E.Award.Action")}
    </a>
  `;

  return block;
}

/* -------------------------------------------- */
/*  Check & Save Enrichers                      */
/* -------------------------------------------- */

/**
 * Enrich an ability check link to perform a specific ability or skill check. If an ability is provided
 * along with a skill, then the skill check will always use the provided ability. Otherwise it will use
 * the character's default ability for that skill.
 * @param {object} config              Configuration data.
 * @param {string} [label]             Optional label to replace default text.
 * @param {EnrichmentOptions} options  Options provided to customize text enrichment.
 * @returns {HTMLElement|null}         An HTML link if the check could be built, otherwise null.
 *
 * @example Create a dexterity check:
 * ```[[/check ability=dex]]```
 * becomes
 * ```html
 * <a class="roll-action" data-type="check" data-ability="dex">
 *   <i class="fa-solid fa-dice-d20" inert></i> Dexterity
 * </a>
 * ```
 *
 * @example Create an acrobatics check with a DC and default ability:
 * ```[[/check skill=acr dc=20]]```
 * becomes
 * ```html
 * <a class="roll-action" data-type="check" data-skill="acr" data-dc="20">
 *   <i class="fa-solid fa-dice-d20" inert></i> DC 20 Dexterity (Acrobatics)
 * </a>
 * ```
 *
 * @example Create an acrobatics check using strength:
 * ```[[/check ability=str skill=acr]]```
 * becomes
 * ```html
 * <a class="roll-action" data-type="check" data-ability="str" data-skill="acr">
 *   <i class="fa-solid fa-dice-d20" inert></i> Strength (Acrobatics)
 * </a>
 * ```
 *
 * @example Create a tool check:
 * ```[[/check tool=thief ability=int]]```
 * becomes
 * ```html
 * <a class="roll-action" data-type="check" data-ability="int" data-tool="thief">
 *   <i class="fa-solid fa-dice-d20" inert></i> Intelligence (Thieves' Tools)
 * </a>
 * ```
 *
 * @example Create a skill check with a tool (when using the Modern rules):
 * ```[[/check slt thief]]```
 * ```[[/check skill=slt tool=thief]]```
 * becomes
 * ```html
 * <a class="roll-action" data-type="check" data-skill="slt" data-using-tool="thief">
 *   <i class="fa-solid fa-dice-d20" inert></i> Dexterity (Sleight of Hand)
 * </a> check using Thieves' Tools
 * ```
 *
 * @example Formulas used for DCs will be resolved using data provided to the description (not the roller):
 * ```[[/check ability=cha dc=@abilities.int.dc]]```
 * becomes
 * ```html
 * <a class="roll-action" data-type="check" data-ability="cha" data-dc="15">
 *   <i class="fa-solid fa-dice-d20" inert></i> DC 15 Charisma
 * </a>
 * ```
 *
 * @example Use multiple skills in a check using default abilities:
 * ```[[/check skill=acr/ath dc=15]]```
 * ```[[/check acrobatics athletics 15]]```
 * becomes
 * ```html
 * <span class="roll-link-group" data-type="check" data-skill="acr|ath" data-dc="15">
 *   DC 15
 *   <a class="roll-action" data-ability="dex" data-skill="acr">
 *     <i class="fa-solid fa-dice-d20" inert></i> Dexterity (Acrobatics)
 *   </a> or
 *   <a class="roll-action" data-ability="dex">
 *     <i class="fa-solid fa-dice-d20" inert></i> Strength (Athletics)
 *   </a>
 *   <a class="enricher-action" data-action="request" ...><!-- request link --></a>
 * </span>
 * ```
 *
 * @example Use multiple skills with a fixed ability:
 * ```[[/check ability=str skill=dec/per dc=15]]```
 * ```[[/check strength deception persuasion 15]]```
 * becomes
 * ```html
 * <span class="roll-link-group" data-type="check" data-ability="str" data-skill="dec|per" data-dc="15">
 *   DC 15 Strength
 *   (<a class="roll-action" data-skill="dec"><i class="fa-solid fa-dice-d20" inert></i> Deception</a> or
 *   <a class="roll-action" data-ability="per"><i class="fa-solid fa-dice-d20" inert></i> Persuasion</a>)
 *   <a class="enricher-action" data-action="request" ...><!-- request link --></a>
 * </span>
 * ```
 *
 * @example Link an enricher to an check activity, either explicitly or automatically
 * ```[[/check activity=RLQlsLo5InKHZadn]]``` or ```[[/check]]```
 * becomes
 * ```html
 * <span class="roll-link-group" data-type="check" data-ability="dex" data-dc="20" data-activity-uuid="...">
 *   <a class="roll-action"><i class="fa-solid fa-dice-d20" inert></i> DC 20 Dexterity</a>
 *   <a class="enricher-action" data-action="request" ...><!-- request link --></a>
 * </span>
 * ```
 */
async function enrichCheck(config, label, options) {
  config.skill = config.skill?.replaceAll("/", "|").split("|") ?? [];
  config.tool = config.tool?.replaceAll("/", "|").split("|") ?? [];
  for ( let value of config.values ) {
    const slug = foundry.utils.getType(value) === "string" ? slugify(value) : value;
    if ( slug in CONFIG.DND5E.enrichmentLookup.abilities ) config.ability = slug;
    else if ( slug in CONFIG.DND5E.enrichmentLookup.skills ) config.skill.push(slug);
    else if ( slug in CONFIG.DND5E.enrichmentLookup.tools ) config.tool.push(slug);
    else if ( Number.isNumeric(value) ) config.dc = Number(value);
    else config[value] = true;
  }
  delete config.values;

  const groups = new Map();
  let invalid = false;

  const anything = config.ability || config.skill.length || config.tool.length;
  const activity = config.activity ? options.relativeTo?.system?.activities?.get(config.activity)
    : !anything ? options.relativeTo?.system?.activities?.getByType("check")[0] : null;

  if ( activity ) {
    if ( activity.type !== "check" ) {
      console.warn(`Check enricher linked to non-check activity when enriching ${config._input}.`);
      return null;
    }

    if ( activity.check.ability ) config.ability = activity.check.ability;
    config.activityUuid = activity.uuid;
    config.dc = activity.check.dc.value;
    config.skill = [];
    config.tool = [];
    for ( const associated of activity.check.associated ) {
      if ( associated in CONFIG.DND5E.skills ) config.skill.push(associated);
      else if ( associated in CONFIG.DND5E.tools ) config.tool.push(associated);
    }
    delete config.activity;
  }

  // TODO: Support "spellcasting" ability
  let abilityConfig = CONFIG.DND5E.enrichmentLookup.abilities[slugify(config.ability)];
  if ( config.ability && !abilityConfig ) {
    console.warn(`Ability "${config.ability}" not found while enriching ${config._input}.`);
    invalid = true;
  } else if ( abilityConfig?.key ) config.ability = abilityConfig.key;

  for ( let [index, skill] of config.skill.entries() ) {
    const skillConfig = CONFIG.DND5E.enrichmentLookup.skills[slugify(skill)];
    if ( skillConfig ) {
      if ( skillConfig.key ) skill = config.skill[index] = skillConfig.key;
      const ability = config.ability || skillConfig.ability;
      if ( !groups.has(ability) ) groups.set(ability, []);
      groups.get(ability).push({ key: skill, type: "skill", label: skillConfig.label });
    } else {
      console.warn(`Skill "${skill}" not found while enriching ${config._input}.`);
      invalid = true;
    }
  }

  let usingTool;
  for ( const tool of config.tool ) {
    const toolConfig = CONFIG.DND5E.tools[slugify(tool)];
    const toolUUID = CONFIG.DND5E.enrichmentLookup.tools[slugify(tool)];
    const toolIndex = toolUUID ? getBaseItem(toolUUID.id, { indexOnly: true }) : null;
    if ( toolIndex ) {
      const ability = config.ability || toolConfig?.ability;
      if ( config.skill.length && (config.tool.length === 1) && (config._rules === "2024") ) {
        usingTool = { key: tool, label: toolIndex.name };
      } else if ( ability ) {
        if ( !groups.has(ability) ) groups.set(ability, []);
        groups.get(ability).push({ key: tool, type: "tool", label: toolIndex.name });
      } else {
        console.warn(`Tool "${tool}" found without specified or default ability while enriching ${config._input}.`);
        invalid = true;
      }
    } else {
      console.warn(`Tool "${tool}" not found while enriching ${config._input}.`);
      invalid = true;
    }
  }

  if ( !abilityConfig && !groups.size ) {
    console.warn(`No ability, skill, tool, or linked activity provided while enriching ${config._input}.`);
    invalid = true;
  }

  const complex = (config.skill.length + config.tool.length) > 1;
  if ( config.passive && complex ) {
    console.warn(`Multiple skills or tools and passive flag found while enriching ${config._input}, which aren't supported together.`);
    invalid = true;
  }
  if ( label && complex ) {
    console.warn(`Multiple skills or tools and a custom label found while enriching ${config._input}, which aren't supported together.`);
    invalid = true;
  }

  if ( config.dc && !Number.isNumeric(config.dc) ) {
    config.dc = simplifyBonus(config.dc, options.rollData ?? options.relativeTo?.getRollData?.() ?? {});
  }

  if ( invalid ) return null;

  if ( complex ) {
    const formatter = game.i18n.getListFormatter({ type: "disjunction" });
    const parts = [];
    for ( const [ability, associated] of groups.entries() ) {
      const makeConfig = ({ key, type }) => ({ type, [type]: key, ability: groups.size > 1 ? ability : undefined });

      // Multiple associated proficiencies, link each individually
      if ( associated.length > 1 ) parts.push(
        game.i18n.format("EDITOR.DND5E.Inline.SpecificCheck", {
          ability: CONFIG.DND5E.enrichmentLookup.abilities[ability].label,
          type: formatter.format(associated.map(a => createRollLink(a.label, makeConfig(a)).outerHTML ))
        })
      );

      // Only single associated proficiency, wrap whole thing in roll link
      else {
        const associatedConfig = makeConfig(associated[0]);
        parts.push(createRollLink(createRollLabel({ ...associatedConfig, ability }), associatedConfig).outerHTML);
      }
    }

    if ( usingTool ) {
      config.format = "long";
      config.usingTool = usingTool.key;
    }
    label = formatter.format(parts);
    if ( config.dc && !config.hideDC ) {
      label = game.i18n.format("EDITOR.DND5E.Inline.DC", { dc: config.dc, check: label });
    }
    label = game.i18n.format(`EDITOR.DND5E.Inline.Check${config.format === "long" ? "Long" : "Short"}`, { check: label });
    if ( usingTool ) label = game.i18n.format("EDITOR.DND5E.Inline.CheckUsing", {
      check: label, tool: usingTool.label
    });

    const template = document.createElement("template");
    template.innerHTML = label;
    return createRequestLink(template, {
      type: "check", ...config, skill: config.skill.join("|"), tool: config.tool.join("|")
    });
  }

  const type = config.skill.length ? "skill" : config.tool.length ? "tool" : "check";
  config = { type, ability: Array.from(groups.keys())[0], ...config, skill: config.skill[0], tool: config.tool[0] };
  if ( !label ) label = createRollLabel(config);
  return config.passive ? createPassiveTag(label, config) : createRequestLink(createRollLink(label), config);
}

/* -------------------------------------------- */

/**
 * Create the buttons for a check requested in chat.
 * @param {object} dataset
 * @returns {object[]}
 */
function createCheckRequestButtons(dataset) {
  const skills = dataset.skill?.split("|") ?? [];
  const tools = dataset.tool?.split("|") ?? [];
  if ( (skills.length + tools.length) <= 1 ) return [createRequestButton(dataset)];
  const baseDataset = { ...dataset };
  delete baseDataset.skill;
  delete baseDataset.tool;
  return [
    ...skills.map(skill => createRequestButton({
      ability: CONFIG.DND5E.skills[skill].ability, ...baseDataset, format: "short", skill, type: "skill"
    })),
    ...dataset.usingTool ? [] : tools.map(tool => createRequestButton({
      ability: CONFIG.DND5E.tools[tool]?.ability, ...baseDataset, format: "short", tool, type: "tool"
    }))
  ];
}

/* -------------------------------------------- */

/**
 * Enrich a saving throw link.
 * @param {object} config              Configuration data.
 * @param {string} [label]             Optional label to replace default text.
 * @param {EnrichmentOptions} options  Options provided to customize text enrichment.
 * @returns {HTMLElement|null}         An HTML link if the save could be built, otherwise null.
 *
 * @example Create a dexterity saving throw:
 * ```[[/save ability=dex]]```
 * becomes
 * ```html
 * <span class="roll-link-group" data-type="save" data-ability="dex">
 *   <a class="roll-action"><i class="fa-solid fa-dice-d20" inert></i> Dexterity</a>
 *   <a class="enricher-action" data-action="request" ...><!-- request link --></a>
 * </span>
 * ```
 *
 * @example Add a DC to the save:
 * ```[[/save ability=dex dc=20]]```
 * becomes
 * ```html
 * <span class="roll-link-group" data-type="save" data-ability="dex" data-dc="20">
 *   <a class="roll-action"><i class="fa-solid fa-dice-d20" inert></i> DC 20 Dexterity</a>
 *   <a class="enricher-action" data-action="request" ...><!-- request link --></a>
 * </span>
 * ```
 *
 * @example Specify multiple abilities:
 * ```[[/save ability=str/dex dc=20]]```
 * ```[[/save strength dexterity 20]]```
 * becomes
 * ```html
 * <span class="roll-link-group" data-type="save" data-ability="str|dex" data-dc="20">
 *   DC 20
 *   <a class="roll-action" data-ability="str"><i class="fa-solid fa-dice-d20" inert></i> Strength</a> or
 *   <a class="roll-action" data-ability="dex"><i class="fa-solid fa-dice-d20" inert></i> Dexterity</a>
 *   <a class="enricher-action" data-action="request" ...><!-- request link --></a>
 * </span>
 * ```
 *
 * @example Create a concentration saving throw:
 * ```[[/concentration 10]]```
 * becomes
 * ```html
 * <span class="roll-link-group" data-type="concentration" data-dc=10>
 *   <a class="roll-action"><i class="fa-solid fa-dice-d20" inert></i> DC 10 concentration</a>
 *   <a class="enricher-action" data-action="request" ...><!-- request link --></a>
 * </span>
 * ```
 *
 * @example Link an enricher to an save activity, either explicitly or automatically
 * ```[[/save activity=RLQlsLo5InKHZadn]]``` or ```[[/save]]```
 * becomes
 * ```html
 * <span class="roll-link-group" data-type="save" data-ability="dex" data-dc="20" data-activity-uuid="...">
 *   <a class="roll-action"><i class="fa-solid fa-dice-d20" inert></i> DC 20 Dexterity</a>
 *   <a class="enricher-action" data-action="request" ...><!-- request link --></a>
 * </span>
 * ```
 */
async function enrichSave(config, label, options) {
  config.ability = config.ability?.replace("/", "|").split("|") ?? [];
  for ( let value of config.values ) {
    const slug = foundry.utils.getType(value) === "string" ? slugify(value) : value;
    if ( slug in CONFIG.DND5E.enrichmentLookup.abilities ) config.ability.push(slug);
    else if ( Number.isNumeric(value) ) config.dc = Number(value);
    else config[value] = true;
  }
  config.ability = config.ability
    .filter(a => a in CONFIG.DND5E.enrichmentLookup.abilities)
    .map(a => CONFIG.DND5E.enrichmentLookup.abilities[a].key ?? a);

  const activity = config.activity ? options.relativeTo?.system?.activities?.get(config.activity)
    : !config.ability.length ? options.relativeTo?.system?.activities?.getByType("save")[0] : null;

  if ( activity ) {
    if ( activity.type !== "save" ) {
      console.warn(`Save enricher linked to non-save activity when enriching ${config._input}`);
      return null;
    }

    config.ability = Array.from(activity.save.ability);
    config.activityUuid = activity.uuid;
    config.dc = activity.save.dc.value;
    delete config.activity;
  }

  if ( !config.ability.length && !config._isConcentration ) {
    console.warn(`No ability or linked activity found while enriching ${config._input}.`);
    return null;
  }

  if ( config.dc && !Number.isNumeric(config.dc) ) {
    config.dc = simplifyBonus(config.dc, options.rollData ?? options.relativeTo?.getRollData?.() ?? {});
  }

  if ( config.ability.length > 1 && label ) {
    console.warn(`Multiple abilities and custom label found while enriching ${config._input}, which aren't supported together.`);
    return null;
  }

  config = { type: config._isConcentration ? "concentration" : "save", ...config };
  if ( label ) label = createRollLink(label);
  else if ( config.ability.length <= 1 ) label = createRollLink(createRollLabel(config));
  else {
    label = game.i18n.getListFormatter({ type: "disjunction" }).format(config.ability.map(ability =>
      createRollLink(createRollLabel({ type: "save", ability }), { ability }).outerHTML
    ));
    if ( config.dc && !config.hideDC ) {
      label = game.i18n.format("EDITOR.DND5E.Inline.DC", { dc: config.dc, check: label });
    }
    label = game.i18n.format(`EDITOR.DND5E.Inline.Save${config.format === "long" ? "Long" : "Short"}`, { save: label });
    const template = document.createElement("template");
    template.innerHTML = label;
    label = template;
  }
  return createRequestLink(label, { ...config, ability: config.ability.join("|") });
}

/* -------------------------------------------- */

/**
 * Create the buttons for a save requested in chat.
 * @param {object} dataset
 * @returns {object[]}
 */
function createSaveRequestButtons(dataset) {
  return (dataset.ability?.split("|") ?? [])
    .map(ability => createRequestButton({ ...dataset, format: "long", ability }));
}

/* -------------------------------------------- */
/*  Damage Enricher                             */
/* -------------------------------------------- */

/**
 * Enrich a damage link.
 * @param {object[]} configs           Configuration data.
 * @param {string} [label]             Optional label to replace default text.
 * @param {EnrichmentOptions} options  Options provided to customize text enrichment.
 * @returns {HTMLElement|null}         An HTML link if the save could be built, otherwise null.
 *
 * @example Create a damage link:
 * ```[[/damage 2d6 type=bludgeoning]]``
 * becomes
 * ```html
 * <a class="roll-link-group" data-type="damage" data-formulas="2d6" data-damage-types="bludgeoning">
 *   <span class="roll-link"><i class="fa-solid fa-dice-d20"></i> 2d6</span> bludgeoning
 * </a>
 * ````
 *
 * @example Display the average:
 * ```[[/damage 2d6 type=bludgeoning average=true]]``
 * becomes
 * ```html
 * 7 (<a class="roll-link-group" data-type="damage" data-formulas="2d6" data-damage-types="bludgeoning">
 *   <span class="roll-link"><i class="fa-solid fa-dice-d20"></i> 2d6</span>
 * </a>) bludgeoning
 * ````
 *
 * @example Manually set the average & don't prefix the type:
 * ```[[/damage 8d4dl force average=666]]``
 * becomes
 * ```html
 * 666 (<a class="roll-link-group" data-type="damage" data-formulas="8d4dl" data-damage-types="force">
 *   <span class="roll-link"><i class="fa-solid fa-dice-d20"></i> 8d4dl</span>
 * </a> force
 * ````
 *
 * @example Create a healing link:
 * ```[[/heal 2d6]]``` or ```[[/damage 2d6 healing]]```
 * becomes
 * ```html
 * <a class="roll-link-group" data-type="damage" data-formulas="2d6" data-damage-types="healing">
 *   <span class="roll-link"><i class="fa-solid fa-dice-d20"></i> 2d6</span>
 * </a> healing
 * ```
 *
 * @example Specify variable damage types:
 * ```[[/damage 2d6 type=fire|cold]]``` or ```[[/damage 2d6 type=fire/cold]]```
 * becomes
 * ```html
 * <a class="roll-link-group" data-type="damage" data-formulas="2d6" data-damage-types="fire|cold">
 *   <span class="roll-link"><i class="fa-solid fa-dice-d20"></i> 2d6</span>
 * </a> fire or cold
 * ```
 *
 * @example Add multiple damage parts
 * ```[[/damage 1d6 fire & 1d6 cold]]```
 * becomes
 * ```html
 * <a class="roll-link-group" data-type="damage" data-formulas="1d6&1d6" data-damage-types="fire&cold">
 *   <span class="roll-link"><i class="fa-solid fa-dice-d20"></i> 1d6</span> fire and
 *   <span class="roll-link"><i class="fa-solid fa-dice-d20"></i> 1d6</span> cold
 * </a>
 * ```
 *
 * @example Link an enricher to an damage activity, either explicitly or automatically
 * ```[[/damage activity=RLQlsLo5InKHZadn]]``` or ```[[/damage]]```
 * becomes
 * ```html
 * <a class="roll-link-group" data-type="damage" data-formulas="1d6&1d6" data-damage-types="fire&cold"
 *    data-activity-uuid="...">
 *   <span class="roll-link"><i class="fa-solid fa-dice-d20"></i> 1d6</span> fire and
 *   <span class="roll-link"><i class="fa-solid fa-dice-d20"></i> 1d6</span> cold
 * </a>
 * ```
 *
 * @example Displaying the full hit section:
 * ```[[/damage extended]]``
 * becomes
 * ```html
 * <span class="damage-extended">
 *   <em>Hit:</em>
 *   <a class="roll-link-group" data-type="damage" data-formulas="2d6" data-damage-types="bludgeoning"
 *      data-activity-uuid="...">
 *     7 (<span class="roll-link"><i class="fa-solid fa-dice-d20"></i> 2d6</span></a>) Bludgeoning damage
 *   </a>
 * </span>
 * ````
 */
async function enrichDamage(configs, label, options) {
  const config = { type: "damage", formulas: [], damageTypes: [], rollType: configs._isHealing ? "healing" : "damage" };
  for ( const c of configs ) {
    const formulaParts = [];
    if ( c.activity ) config.activity = c.activity;
    if ( c.attackMode ) config.attackMode = c.attackMode;
    if ( c.average ) config.average = c.average;
    if ( c.format ) config.format = c.format;
    if ( c.formula ) formulaParts.push(c.formula);
    c.type = c.type?.replaceAll("/", "|").split("|") ?? [];
    for ( const value of c.values ) {
      if ( value in CONFIG.DND5E.damageTypes ) c.type.push(value);
      else if ( value in CONFIG.DND5E.healingTypes ) c.type.push(value);
      else if ( value in CONFIG.DND5E.attackModes ) config.attackMode = value;
      else if ( value === "average" ) config.average = true;
      else if ( value === "extended" ) config.format = "extended";
      else if ( value === "temp" ) c.type.push("temphp");
      else formulaParts.push(value);
    }
    c.formula = Roll.defaultImplementation.replaceFormulaData(
      formulaParts.join(" "),
      options.rollData ?? options.relativeTo?.getRollData?.() ?? {}
    );
    if ( configs._isHealing && !c.type.length ) c.type.push("healing");
    if ( c.formula ) {
      config.formulas.push(c.formula);
      config.damageTypes.push(c.type.join("|"));
    }
  }
  config.damageTypes = config.damageTypes.map(t => t?.replace("/", "|"));
  if ( config.format === "extended" ) config.average ??= true;

  if ( config.activity && config.formulas.length ) {
    console.warn(`Activity ID and formulas found while enriching ${config._input}, only one is supported.`);
    return null;
  }

  let activity = options.relativeTo?.system?.activities?.get(config.activity);
  if ( !activity && !config.formulas.length ) {
    const types = configs._isHealing ? ["heal"] : ["attack", "damage", "save"];
    for ( const a of options.relativeTo?.system?.activities?.getByTypes(...types) ?? [] ) {
      if ( a.damage?.parts.length || a.healing?.formula ) {
        activity = a;
        break;
      }
    }
  }

  if ( activity ) {
    config.activityUuid = activity.uuid;
    const damageConfig = activity.getDamageConfig({ attackMode: config.attackMode });
    for ( const roll of damageConfig.rolls ) {
      config.formulas.push(simplifyRollFormula(
        Roll.defaultImplementation.replaceFormulaData(roll.parts.join(" + "), roll.data)
      ));
      if ( roll.data.scaling ) config.scaling ??= String(roll.data.scaling.increase);
      config.damageTypes.push(roll.options.types?.join("|") ?? roll.options.type);
    }
    delete config.activity;
  }

  if ( !config.activityUuid && !config.formulas.length ) {
    console.warn(`No formula or linked activity found while enriching ${config._input}.`);
    return null;
  }

  const formulas = config.formulas.join("&");
  const damageTypes = config.damageTypes.join("&");

  if ( !config.formulas.length ) return null;
  if ( label ) {
    return createRollLink(label, { ...config, formulas, damageTypes }, { classes: "roll-link-group roll-link" });
  }

  const parts = [];
  for ( const [idx, formula] of config.formulas.entries() ) {
    const type = config.damageTypes[idx];
    const types = type?.split("|")
      .map(t => CONFIG.DND5E.damageTypes[t]?.label ?? CONFIG.DND5E.healingTypes[t]?.label)
      .filter(_ => _);
    const localizationData = {
      formula: createRollLink(formula, {}, { tag: "span" }).outerHTML,
      type: game.i18n.getListFormatter({ type: "disjunction" }).format(types)
    };
    if ( configs._rules === "2014" ) localizationData.type = localizationData.type.toLowerCase();

    let localizationType = "Short";
    if ( config.average ) {
      localizationType = "Long";
      if ( config.average === true ) {
        const minRoll = Roll.create(formula).evaluate({ minimize: true });
        const maxRoll = Roll.create(formula).evaluate({ maximize: true });
        localizationData.average = Math.floor(((await minRoll).total + (await maxRoll).total) / 2);
      } else if ( Number.isNumeric(config.average) ) {
        localizationData.average = config.average;
      } else {
        localizationType = "Short";
      }
      if ( String(localizationData.average) === formula ) localizationType = "Short";
    }

    parts.push(game.i18n.format(`EDITOR.DND5E.Inline.Damage${localizationType}`, localizationData));
  }

  const link = document.createElement("a");
  link.className = "roll-link-group";
  _addDataset(link, { ...config, formulas, damageTypes });
  if ( config.average && (parts.length === 2) ) {
    link.innerHTML = game.i18n.format("EDITOR.DND5E.Inline.DamageDouble", { first: parts[0], second: parts[1] });
  } else {
    link.innerHTML = game.i18n.getListFormatter().format(parts);
  }

  if ( config.format === "extended" ) {
    const span = document.createElement("span");
    span.className = "damage-extended";
    span.innerHTML = game.i18n.format("EDITOR.DND5E.Inline.DamageExtended", { damage: link.outerHTML });
    return span;
  }

  return link;
}

/* -------------------------------------------- */
/*  Language Enricher                           */
/* -------------------------------------------- */

/**
 * Enrich a language reference.
 */
function enrichLanguage(config, label, options) {
  for ( const value of config.values ) {
    const slug = foundry.utils.getType(value) === "string" ? slugify(value) : value;
    if ( slug in CONFIG.DND5E.enrichmentLookup.languages ) config.language = slug;
  }
  delete config.values;

  if ( !(config.language in CONFIG.DND5E.enrichmentLookup.languages) ) {
    console.warn(`No language found while enriching ${config._input}.`);
    return null;
  }

  config.type = "language";
  return createPassiveTag(label ?? CONFIG.DND5E.enrichmentLookup.languages[config.language], config);
}

/* -------------------------------------------- */
/*  Lookup Enricher                             */
/* -------------------------------------------- */

/**
 * Enrich a property lookup.
 * @param {object} config              Configuration data.
 * @param {string} [fallback]          Optional fallback if the value couldn't be found.
 * @param {EnrichmentOptions} options  Options provided to customize text enrichment.
 * @returns {HTMLElement|null}         An HTML element if the lookup could be built, otherwise null.
 *
 * @example Include a creature's name in its description:
 * ```[[lookup @name]]```
 * becomes
 * ```html
 * <span class="lookup-value">Adult Black Dragon</span>
 * ```
 *
 * @example Lookup a property within an activity:
 * ```[[lookup @target.template.size activity=dnd5eactivity000]]```
 * becomes
 * ```html
 * <span class="lookup-value">120</span>
 * ```
 */
function enrichLookup(config, fallback, options) {
  let keyPath = config.path;
  let style = config.style;
  for ( const value of config.values ) {
    if ( value === "capitalize" ) style ??= "capitalize";
    else if ( value === "lowercase" ) style ??= "lowercase";
    else if ( value === "uppercase" ) style ??= "uppercase";
    else if ( value.startsWith("@") ) keyPath ??= value;
  }

  let activity = options.relativeTo?.system?.activities?.get(config.activity);
  if ( config.activity && !activity ) {
    console.warn(`Activity not found when enriching ${config._input}.`);
    return null;
  }

  if ( !keyPath ) {
    console.warn(`Lookup path must be defined to enrich ${config._input}.`);
    return null;
  }

  const data = activity ? activity.getRollData().activity : options.rollData
    ?? options.relativeTo?.getRollData?.() ?? {};
  let value = foundry.utils.getProperty(data, keyPath.substring(1)) ?? fallback;
  if ( value !== undefined ) value = String(value);
  if ( value && style ) {
    if ( style === "capitalize" ) value = value.capitalize();
    else if ( style === "lowercase" ) value = value.toLowerCase();
    else if ( style === "uppercase" ) value = value.toUpperCase();
  }

  const span = document.createElement("span");
  span.classList.add("lookup-value");
  if ( !value && (options.documents === false) ) return null;
  if ( !value ) span.classList.add("not-found");
  span.innerText = value ?? keyPath;
  return span;
}

/* -------------------------------------------- */
/*  Reference Enricher                          */
/* -------------------------------------------- */

/**
 * Enrich a reference link.
 * @param {object} config              Configuration data.
 * @param {string} [label]             Optional label to replace default text.
 * @param {EnrichmentOptions} options  Options provided to customize text enrichment.
 * @returns {HTMLElement|null}         An HTML link to the Journal Entry Page for the given reference.
 *
 * @example Create a content link to the relevant reference:
 * ```&Reference[condition=unconscious]{Label}```
 * becomes
 * ```html
 * <span class="reference-link">
 *   <a class="content-link" draggable="true"
 *      data-uuid="Compendium.dnd5e.rules.JournalEntry.w7eitkpD7QQTB6j0.JournalEntryPage.UWw13ISmMxDzmwbd"
 *      data-type="JournalEntryPage" data-tooltip="Text Page">
 *     <i class="fas fa-book-open"></i> Label
 *   </a>
 *   <a class="enricher-action" data-action="apply" data-status="unconscious"
 *      data-tooltip aria-label="Apply Status to Selected Tokens">
 *     <i class="fas fa-fw fa-reply-all fa-flip-horizontal"></i>
 *   </a>
 * </span>
 * ```
 */
async function enrichReference(config, label, options) {
  let key;
  let source;
  let type = Object.keys(config).find(k => k in CONFIG.DND5E.ruleTypes);
  if ( type ) {
    key = slugify(config[type]);
    const { references } = CONFIG.DND5E.ruleTypes[type] ?? {};
    source = foundry.utils.getProperty(CONFIG.DND5E, references)?.[key];
  } else if ( config.values.length ) {
    key = slugify(config.values.join(""));
    for ( const [t, { references }] of Object.entries(CONFIG.DND5E.ruleTypes) ) {
      source = foundry.utils.getProperty(CONFIG.DND5E, references)?.[key];
      if ( source ) {
        type = t;
        break;
      }
    }
  }
  if ( !source ) {
    console.warn(`No valid rule found while enriching ${config._input}.`);
    return null;
  }
  const uuid = foundry.utils.getType(source) === "Object" ? source.reference : source;
  if ( !uuid ) return null;
  const doc = await fromUuid(uuid);
  const span = document.createElement("span");
  span.classList.add("reference-link");
  span.append(doc.toAnchor({ name: label || doc.name }));
  if ( (type === "condition") && (config.apply !== false) ) {
    const apply = document.createElement("a");
    apply.classList.add("enricher-action");
    apply.dataset.action = "apply";
    apply.dataset.status = key;
    apply.dataset.tooltip = "";
    apply.setAttribute("aria-label", game.i18n.localize("EDITOR.DND5E.Inline.ApplyStatus"));
    apply.innerHTML = '<i class="fas fa-fw fa-reply-all fa-flip-horizontal"></i>';
    span.append(apply);
  }
  return span;
}

/* -------------------------------------------- */
/*  Helpers                                     */
/* -------------------------------------------- */

/**
 * Enrich an item use link to roll an item on the selected token.
 * @param {string[]} config              Configuration data.
 * @param {string} [label]               Optional label to replace default text.
 * @param {EnrichmentOptions} options  Options provided to customize text enrichment.
 * @returns {Promise<HTMLElement|null>}  An HTML link if the item link could be built, otherwise null.
 *
 * @example Use an Item from a name:
 * ```[[/item Heavy Crossbow]]```
 * becomes
 * ```html
 * <a class="roll-action" data-type="item" data-roll-item-name="Heavy Crossbow">
 *   <i class="fa-solid fa-dice-d20"></i> Heavy Crossbow
 * </a>
 * ```
 *
 * @example Use an Item from a UUID:
 * ```[[/item Actor.M4eX4Mu5IHCr3TMf.Item.amUUCouL69OK1GZU]]```
 * becomes
 * ```html
 * <a class="roll-action" data-type="item" data-roll-item-uuid="Actor.M4eX4Mu5IHCr3TMf.Item.amUUCouL69OK1GZU">
 *   <i class="fa-solid fa-dice-d20"></i> Bite
 * </a>
 * ```
 *
 * @example Use an Item from an ID:
 * ```[[/item amUUCouL69OK1GZU]]```
 * becomes
 * ```html
 * <a class="roll-action" data-type="item" data-roll-item-uuid="Actor.M4eX4Mu5IHCr3TMf.Item.amUUCouL69OK1GZU">
 *   <i class="fa-solid fa-dice-d20"></i> Bite
 * </a>
 * ```
 *
 * @example Use an Activity on an Item from a name:
 * ```[[/item Heavy Crossbow activity=Poison]]```
 * becomes
 * ```html
 * <a class="roll-action" data-type="item" data-roll-item-name="Heavy Crossbow" data-roll-activity-name="Poison">
 *   <i class="fa-solid fa-dice-d20"></i> Heavy Crossbow: Poison
 * </a>
 * ```
 *
 * @example Use an Activity on an Item:
 * ```[[/item amUUCouL69OK1GZU activity=G8ng63Tjqy5W52OP]]```
 * becomes
 * ```html
 * <a class="roll-action" data-type="item"
 *    data-roll-activity-uuid="Actor.M4eX4Mu5IHCr3TMf.Item.amUUCouL69OK1GZU.Activity.G8ng63Tjqy5W52OP">
 *   <i class="fa-solid fa-dice-d20"></i> Bite: Save
 * </a>
 * ```
 */
async function enrichItem(config, label, options) {
  const givenItem = config.values.join(" ");
  let parsed = foundry.utils.parseUuid(givenItem);

  const makeLink = (label, dataset) => {
    const span = document.createElement("span");
    span.classList.add("roll-link-group");
    _addDataset(span, dataset);
    span.append(createRollLink(label));
    return span;
  };

  if ( ["Activity", "Item"].includes(parsed?.type) ) {
    const ownerActor = parsed.primaryType === "Actor" ? parsed.primaryId
      : parsed.embedded.includes("Actor") ? parsed.embedded[parsed.embedded.findIndex(e => e === "Actor") + 1] : null;
    let doc = await fromUuid(parsed.uuid);
    if ( !doc ) {
      console.warn(`Item not found while enriching ${config._input}.`);
      return null;
    }
    if ( (doc instanceof Item) && config.activity ) {
      doc = doc.system.activities?.get(config.activity) ?? doc.system.activities?.getName(config.activity);
      if ( !doc ) {
        console.warn(`Activity not found while enriching ${config._input}.`);
        return null;
      }
    }
    if ( !label ) {
      if ( doc instanceof Item ) label = doc.name;
      else label = game.i18n.format("EDITOR.DND5E.Inline.ItemActivity", { item: doc.item.name, activity: doc.name });
    }
    return makeLink(label, { type: "item", rollItemActor: ownerActor, [`roll${doc.documentName}Uuid`]: doc.uuid });
  }

  const foundActor = options.relativeTo instanceof Item
    ? options.relativeTo.parent
    : options.relativeTo instanceof Actor ? options.relativeTo : null;
  let foundItem = foundActor?.items.get(givenItem);
  let foundActivity;

  // If config is a relative UUID
  if ( givenItem.startsWith(".") ) {
    try {
      foundItem = await fromUuid(givenItem, { relative: options.relativeTo });
    } catch(err) { return null; }
  }

  if ( !foundItem && !givenItem && (options.relativeTo instanceof Item) ) foundItem = options.relativeTo;

  if ( foundItem ) {
    if ( config.activity ) {
      foundActivity = foundItem.system.activities?.get(config.activity)
        ?? foundItem.system.activities?.getName(config.activity);
      if ( !foundActivity ) {
        console.warn(`Activity ${config.activity} not found on ${foundItem.name} while enriching ${config._input}.`);
        return null;
      }
      if ( !label ) label = game.i18n.format("EDITOR.DND5E.Inline.ItemActivity", {
        item: foundItem.name, activity: foundActivity.name
      });
      return makeLink(label, { type: "item", rollActivityUuid: foundActivity.uuid });
    }

    if ( !label ) label = foundItem.name;
    return makeLink(label, { type: "item", rollItemUuid: foundItem.uuid });
  }

  // Finally, if config is an item name
  if ( !label ) label = config.activity ? game.i18n.format("EDITOR.DND5E.Inline.ItemActivity", {
    item: foundItem?.name ?? givenItem, activity: foundActivity?.name ?? config.activity
  }) : givenItem;
  return makeLink(label, {
    type: "item", rollItemActor: foundActor?.uuid, rollItemName: givenItem, rollActivityName: config.activity
  });
}

/* -------------------------------------------- */

/**
 * Add a dataset object to the provided element.
 * @param {HTMLElement} element  Element to modify.
 * @param {object} dataset       Data properties to add.
 * @private
 */
function _addDataset(element, dataset) {
  for ( const [key, value] of Object.entries(dataset) ) {
    if ( !key.startsWith("_") && (key !== "values") && value ) element.dataset[key] = value;
  }
}

/* -------------------------------------------- */

/**
 * Create a passive skill tag.
 * @param {string} label    Label to display.
 * @param {object} dataset  Data that will be added to the tag.
 * @returns {HTMLElement}
 */
function createPassiveTag(label, dataset) {
  const span = document.createElement("span");
  span.classList.add("passive-check");
  _addDataset(span, {
    ...dataset,
    tooltip: `
      <section class="loading" data-passive><i class="fas fa-spinner fa-spin-pulse"></i></section>
    `
  });
  span.innerText = label;
  return span;
}

/* -------------------------------------------- */

/**
 * Create a label for a roll message.
 * @param {object} config  Configuration data.
 * @returns {string}
 */
function createRollLabel(config) {
  const { label: ability, abbreviation } = CONFIG.DND5E.abilities[config.ability] ?? {};
  const skill = CONFIG.DND5E.skills[config.skill]?.label;
  const toolUUID = CONFIG.DND5E.enrichmentLookup.tools[config.tool];
  const tool = toolUUID ? getBaseItem(toolUUID.id, { indexOnly: true })?.name : null;
  const longSuffix = config.format === "long" ? "Long" : "Short";
  const showDC = config.dc && !config.hideDC;

  let label;
  switch ( config.type ) {
    case "check":
    case "skill":
    case "tool":
      if ( ability && (skill || tool) ) {
        label = game.i18n.format("EDITOR.DND5E.Inline.SpecificCheck", { ability, type: skill ?? tool });
      } else {
        label = ability;
      }
      if ( config.passive ) {
        label = game.i18n.format(
          `EDITOR.DND5E.Inline.${showDC ? "DC" : ""}Passive${longSuffix}`, { dc: config.dc, check: label }
        );
      } else {
        if ( showDC ) label = game.i18n.format("EDITOR.DND5E.Inline.DC", { dc: config.dc, check: label });
        label = game.i18n.format(`EDITOR.DND5E.Inline.Check${longSuffix}`, { check: label });
      }
      break;
    case "concentration":
    case "save":
      if ( config.type === "save" ) label = ability;
      else label = `${game.i18n.localize("DND5E.Concentration")} ${ability ? `(${abbreviation})` : ""}`;
      if ( showDC ) label = game.i18n.format("EDITOR.DND5E.Inline.DC", { dc: config.dc, check: label });
      label = game.i18n.format(`EDITOR.DND5E.Inline.Save${longSuffix}`, { save: label });
      break;
    default:
      return "";
  }

  if ( config.icon ) {
    switch ( config.type ) {
      case "check":
      case "skill":
        label = `<i class="dnd5e-icon" data-src="systems/dnd5e/icons/svg/ability-score-improvement.svg"></i>${label}`;
        break;
      case "tool":
        label = `<i class="fas fa-hammer"></i>${label}`;
        break;
      case "concentration":
      case "save":
        label = `<i class="fas fa-shield-heart"></i>${label}`;
        break;
    }
  }

  return label;
}

/* -------------------------------------------- */

/**
 * Create a rollable link with a request section for GMs.
 * @param {HTMLElement|string} label  Label to display
 * @param {object} dataset            Data that will be added to the link for the rolling method.
 * @returns {HTMLElement}
 */
function createRequestLink(label, dataset) {
  const span = document.createElement("span");
  span.classList.add("roll-link-group");
  _addDataset(span, dataset);
  if ( label instanceof HTMLTemplateElement ) span.append(label.content);
  else span.append(label);

  // Add chat request link for GMs
  if ( game.user.isGM ) {
    const gmLink = document.createElement("a");
    gmLink.classList.add("enricher-action");
    gmLink.dataset.action = "request";
    gmLink.dataset.tooltip = "EDITOR.DND5E.Inline.RequestRoll";
    gmLink.setAttribute("aria-label", game.i18n.localize(gmLink.dataset.tooltip));
    gmLink.insertAdjacentHTML("afterbegin", '<i class="fa-solid fa-comment-dots"></i>');
    span.insertAdjacentElement("beforeend", gmLink);
  }

  return span;
}

/* -------------------------------------------- */

/**
 * Create a rollable link.
 * @param {string} label                           Label to display.
 * @param {object} [dataset={}]                    Data that will be added to the link for the rolling method.
 * @param {object} [options={}]
 * @param {boolean} [options.classes="roll-link"]  Class to add to the link.
 * @param {string} [options.tag="a"]               Tag to use for the main link.
 * @returns {HTMLElement}
 */
function createRollLink(label, dataset={}, { classes="roll-link", tag="a" }={}) {
  const link = document.createElement(tag);
  link.className = classes;
  link.insertAdjacentHTML("afterbegin", '<i class="fa-solid fa-dice-d20" inert></i>');
  link.append(label);
  _addDataset(link, dataset);
  return link;
}

/* -------------------------------------------- */
/*  Actions                                     */
/* -------------------------------------------- */

/**
 * Toggle status effects on selected tokens.
 * @param {PointerEvent} event  The triggering event.
 * @returns {Promise<void>}
 */
async function applyAction(event) {
  const target = event.target.closest('[data-action="apply"][data-status]');
  const status = target?.dataset.status;
  if ( !status ) return;
  event.stopPropagation();
  window.getSelection().empty();
  const actors = new Set();
  for ( const { actor } of canvas.tokens.controlled ) {
    if ( !actor || actors.has(actor) ) continue;
    await actor.toggleStatusEffect(status);
    actors.add(actor);
  }
}

/* -------------------------------------------- */

/**
 * Forward clicks on award requests to the Award application.
 * @param {Event} event  The click event triggering the action.
 * @returns {Promise<void>}
 */
async function awardAction(event) {
  const target = event.target.closest('[data-action="awardRequest"]');
  const command = target?.closest("[data-award-command]")?.dataset.awardCommand;
  if ( !command ) return;
  event.stopPropagation();
  window.getSelection().empty();
  Award.handleAward(command);
}

/* -------------------------------------------- */

/**
 * Perform the provided roll action.
 * @param {Event} event  The click event triggering the action.
 * @returns {Promise}
 */
async function rollAction(event) {
  const target = event.target.closest('.roll-link-group, [data-action="rollRequest"], [data-action="concentration"]');
  if ( !target ) return;
  event.stopPropagation();
  window.getSelection().empty();

  const dataset = {
    ...((event.target.closest(".roll-link-group") ?? target)?.dataset ?? {}),
    ...(event.target.closest(".roll-link")?.dataset ?? {})
  };
  const { type, ability, skill, tool, dc } = dataset;
  const options = { event };
  if ( ability in CONFIG.DND5E.abilities ) options.ability = ability;
  if ( dc ) options.target = Number(dc);

  const action = event.target.closest("a")?.dataset.action ?? "roll";
  const link = event.target.closest("a") ?? event.target;

  // Direct roll
  if ( (action === "roll") || !game.user.isGM ) {
    link.disabled = true;
    try {
      switch ( type ) {
        case "attack": return await rollAttack(event);
        case "damage": return await rollDamage(event);
        case "item": return await useItem(dataset);
      }

      const actors = getSceneTargets().map(t => t.actor);
      if ( !actors.length && game.user.character ) actors.push(game.user.character);
      if ( !actors.length ) {
        ui.notifications.warn("EDITOR.DND5E.Inline.Warning.NoActor", { localize: true });
        return;
      }

      for ( const actor of actors ) {
        switch ( type ) {
          case "check":
            await actor.rollAbilityCheck(options);
            break;
          case "concentration":
            await actor.rollConcentration({ ...options, legacy: false });
            break;
          case "save":
            await actor.rollSavingThrow(options);
            break;
          case "skill":
            await actor.rollSkill({ skill, tool: dataset.usingTool, ...options });
            break;
          case "tool":
            await actor.rollToolCheck({ tool, ...options });
            break;
        }
      }
    } finally {
      link.disabled = false;
    }
  }

  // Roll request
  else {
    const MessageClass = getDocumentClass("ChatMessage");

    let buttons;
    if ( dataset.type === "check" ) buttons = createCheckRequestButtons(dataset);
    else if ( dataset.type === "save" ) buttons = createSaveRequestButtons(dataset);
    else buttons = [createRequestButton({ ...dataset, format: "short" })];

    const chatData = {
      user: game.user.id,
      content: await foundry.applications.handlebars.renderTemplate(
        "systems/dnd5e/templates/chat/roll-request-card.hbs", { buttons }
      ),
      flavor: game.i18n.localize("EDITOR.DND5E.Inline.RollRequest"),
      speaker: MessageClass.getSpeaker({user: game.user})
    };
    return MessageClass.create(chatData);
  }
}

/* -------------------------------------------- */

/**
 * Create a button for a chat request.
 * @param {object} dataset
 * @returns {object}
 */
function createRequestButton(dataset) {
  return {
    buttonLabel: createRollLabel({ ...dataset, icon: true }),
    hiddenLabel: createRollLabel({ ...dataset, icon: true, hideDC: true }),
    dataset: { ...dataset, action: "rollRequest", visibility: "all" }
  };
}

/* -------------------------------------------- */

/**
 * Perform an attack roll.
 * @param {Event} event     The click event triggering the action.
 * @returns {Promise|void}
 */
async function rollAttack(event) {
  const target = event.target.closest(".roll-link-group");
  const { activityUuid, attackMode, formula, scaling } = target.dataset;

  if ( activityUuid ) {
    const activity = await _fetchActivity(activityUuid, Number(scaling ?? 0));
    if ( activity ) return activity.rollAttack({ attackMode, event });
  }

  const targets = getTargetDescriptors();
  const rollConfig = {
    attackMode, event,
    hookNames: ["attack", "d20Test"],
    rolls: [{
      parts: [formula.replace(/^\s*\+\s*/, "")],
      options: {
        target: targets.length === 1 ? targets[0].ac : undefined
      }
    }]
  };

  const dialogConfig = {
    applicationClass: AttackRollConfigurationDialog
  };

  const messageConfig = {
    data: {
      flags: {
        dnd5e: {
          messageType: "roll",
          roll: { type: "attack" }
        }
      },
      flavor: game.i18n.localize("DND5E.AttackRoll"),
      speaker: ChatMessage.implementation.getSpeaker()
    }
  };

  const rolls = await CONFIG.Dice.D20Roll.build(rollConfig, dialogConfig, messageConfig);
  if ( rolls?.length ) {
    Hooks.callAll("dnd5e.rollAttack", rolls, { subject: null, ammoUpdate: null });
    Hooks.callAll("dnd5e.rollAttackV2", rolls, { subject: null, ammoUpdate: null });
    Hooks.callAll("dnd5e.postRollAttack", rolls, { subject: null });
  }
}

/* -------------------------------------------- */

/**
 * Perform a damage roll.
 * @param {Event} event  The click event triggering the action.
 * @returns {Promise<void>}
 */
async function rollDamage(event) {
  const target = event.target.closest(".roll-link-group");
  let { activityUuid, attackMode, formulas, damageTypes, rollType, scaling } = target.dataset;

  if ( activityUuid ) {
    const activity = await _fetchActivity(activityUuid, Number(scaling ?? 0));
    if ( activity ) return activity.rollDamage({ attackMode, event });
  }

  formulas = formulas?.split("&") ?? [];
  damageTypes = damageTypes?.split("&") ?? [];

  const rollConfig = {
    attackMode, event,
    hookNames: ["damage"],
    rolls: formulas.map((formula, idx) => {
      const types = damageTypes[idx]?.split("|") ?? [];
      return {
        parts: [formula],
        options: { type: types[0], types }
      };
    })
  };

  const messageConfig = {
    create: true,
    data: {
      flags: {
        dnd5e: {
          messageType: "roll",
          roll: { type: rollType },
          targets: getTargetDescriptors()
        }
      },
      flavor: game.i18n.localize(`DND5E.${rollType === "healing" ? "Healing" : "Damage"}Roll`),
      speaker: ChatMessage.implementation.getSpeaker()
    }
  };

  const rolls = await CONFIG.Dice.DamageRoll.build(rollConfig, {}, messageConfig);
  if ( !rolls?.length ) return;
  Hooks.callAll("dnd5e.rollDamage", rolls);
  Hooks.callAll("dnd5e.rollDamageV2", rolls);
}

/* -------------------------------------------- */

/**
 * Fetch an activity with scaling applied.
 * @param {string} uuid     Activity UUID.
 * @param {number} scaling  Scaling increase to apply.
 * @returns {Activity|void}
 */
async function _fetchActivity(uuid, scaling) {
  const activity = await fromUuid(uuid);
  if ( !activity || !scaling ) return activity;
  const item = activity.item.clone({ "flags.dnd5e.scaling": scaling }, { keepId: true });
  return item.system.activities.get(activity.id);
}

/* -------------------------------------------- */

/**
 * Use an Item from an Item enricher.
 * @param {object} [options]
 * @param {string} [options.rollActivityUuid]  Lookup the Activity by UUID.
 * @param {string} [options.rollActivityName]  Lookup the Activity by name.
 * @param {string} [options.rollItemUuid]      Lookup the Item by UUID.
 * @param {string} [options.rollItemName]      Lookup the Item by name.
 * @param {string} [options.rollItemActor]     The UUID of a specific Actor that should use the Item.
 * @returns {Promise}
 */
async function useItem({ rollActivityUuid, rollActivityName, rollItemUuid, rollItemName, rollItemActor }={}) {
  // If UUID is provided, always roll that item directly
  if ( rollActivityUuid ) return (await fromUuid(rollActivityUuid))?.use();
  if ( rollItemUuid ) return (await fromUuid(rollItemUuid))?.use({ legacy: false });

  if ( !rollItemName ) return;
  const actor = rollItemActor ? await fromUuid(rollItemActor) : null;

  // If no actor is specified or player isn't owner, fall back to the macro rolling logic
  if ( !actor?.isOwner ) return rollItem(rollItemName, { activityName: rollActivityName });
  const token = canvas.tokens.controlled[0];

  // If a token is controlled, and it has an item with the correct name, activate it
  let item = token?.actor.items.getName(rollItemName);

  // Otherwise check the specified actor for the item
  if ( !item ) {
    item = actor.items.getName(rollItemName);

    // Display a warning to indicate the item wasn't rolled from the controlled actor
    if ( item && canvas.tokens.controlled.length ) ui.notifications.warn(
      game.i18n.format("MACRO.5eMissingTargetWarn", {
        actor: token.name, name: rollItemName, type: game.i18n.localize("DOCUMENT.Item")
      })
    );
  }

  if ( item ) {
    if ( rollActivityName ) {
      const activity = item.system.activities?.getName(rollActivityName);
      if ( activity ) return activity.use();

      // If no activity could be found at all, display a warning
      else ui.notifications.warn(game.i18n.format("EDITOR.DND5E.Inline.Warning.NoActivityOnItem", {
        activity: rollActivityName, actor: actor.name, item: rollItemName
      }));
    }

    else return item.use({ legacy: false });
  }

  // If no item could be found at all, display a warning
  else ui.notifications.warn(game.i18n.format("EDITOR.DND5E.Inline.Warning.NoItemOnActor", {
    actor: actor.name, item: rollItemName
  }));
}

var enrichers = /*#__PURE__*/Object.freeze({
  __proto__: null,
  createRollLabel: createRollLabel,
  getRulesVersion: getRulesVersion,
  registerCustomEnrichers: registerCustomEnrichers
});

const TextEditor$a = foundry.applications.ux.TextEditor.implementation;
const { ObjectField: ObjectField$1, SchemaField: SchemaField$y, SetField: SetField$o, StringField: StringField$I } = foundry.data.fields;

/**
 * @import { FavoriteData5e } from "../data/abstract/_types.mjs";
 */

/**
 * Extend the base ActiveEffect class to implement system-specific logic.
 */
class ActiveEffect5e extends DependentDocumentMixin(ActiveEffect) {
  /**
   * Static ActiveEffect ID for various conditions.
   * @type {Record<string, string>}
   */
  static ID = {
    BLOODIED: staticID("dnd5ebloodied"),
    ENCUMBERED: staticID("dnd5eencumbered"),
    EXHAUSTION: staticID("dnd5eexhaustion")
  };

  /* -------------------------------------------- */

  /**
   * Additional key paths to properties added during base data preparation that should be treated as formula fields.
   * @type {Set<string>}
   */
  static FORMULA_FIELDS = new Set([
    "system.attributes.ac.bonus",
    "system.attributes.ac.min",
    "system.attributes.encumbrance.bonuses.encumbered",
    "system.attributes.encumbrance.bonuses.heavilyEncumbered",
    "system.attributes.encumbrance.bonuses.maximum",
    "system.attributes.encumbrance.bonuses.overall",
    "system.attributes.encumbrance.multipliers.encumbered",
    "system.attributes.encumbrance.multipliers.heavilyEncumbered",
    "system.attributes.encumbrance.multipliers.maximum",
    "system.attributes.encumbrance.multipliers.overall",
    "save.dc.bonus"
  ]);

  /* -------------------------------------------- */

  /**
   * Active effect fields that should be redirected to another field, optionally with a compatibility warning.
   * Optional warning object contains options passed to `foundry.utils.logCompatibilityWarning`.
   * @type {Record<string, { key: string, [warning]: object }>}
   */
  static SHIM_FIELDS = {
    "system.attributes.movement.speed": { key: "system.attributes.movement.walk" }
  };

  /* -------------------------------------------- */

  /** @inheritdoc */
  static LOCALIZATION_PREFIXES = [...super.LOCALIZATION_PREFIXES, "DND5E.ACTIVEEFFECT"];

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Another effect that granted this effect as a rider.
   * @type {ActiveEffect5e|null}
   */
  get dependentOrigin() {
    if ( !(this.parent instanceof Item) ) return null;
    return this.parent.effects.get(this.flags.dnd5e?.dependentOn) ?? null;
  }

  /* -------------------------------------------- */

  /**
   * Is this effect an enchantment on an item that accepts enchantment?
   * @type {boolean}
   */
  get isAppliedEnchantment() {
    return (this.type === "enchantment") && !!this.origin && (this.origin !== this.parent.uuid);
  }

  /* -------------------------------------------- */

  /**
   * Should this status effect be hidden from the current user?
   * @type {boolean}
   */
  get isConcealed() {
    if ( this.target?.testUserPermission(game.user, "OBSERVER") ) return false;

    // Hide bloodied status effect from players unless the token is friendly
    if ( (this.id === this.constructor.ID.BLOODIED) && (game.settings.get("dnd5e", "bloodied") === "player") ) {
      return this.target?.token?.disposition !== foundry.CONST.TOKEN_DISPOSITIONS.FRIENDLY;
    }

    return false;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  get isSuppressed() {
    if ( super.isSuppressed ) return true;
    if ( this.type === "enchantment" ) return false;
    if ( this.parent instanceof dnd5e.documents.Item5e ) {
      if ( this.parent.areEffectsSuppressed ) return true;
      if ( this.dependentOrigin?.active === false ) return true;
    }
    return false;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  get isTemporary() {
    return super.isTemporary && !this.isConcealed;
  }

  /* -------------------------------------------- */

  /**
   * Retrieve the source Actor or Item, or null if it could not be determined.
   * @returns {Promise<Actor5e|Item5e|null>}
   */
  async getSource() {
    if ( (this.target instanceof dnd5e.documents.Actor5e) && (this.parent instanceof dnd5e.documents.Item5e) ) {
      return this.parent;
    }
    return fromUuid(this.origin);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static async _fromStatusEffect(statusId, { reference, ...effectData }, options) {
    if ( !("description" in effectData) && reference ) effectData.description = `@Embed[${reference} inline]`;
    return super._fromStatusEffect?.(statusId, effectData, options) ?? new this(effectData, options);
  }

  /* -------------------------------------------- */
  /*  Data Migration                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _initializeSource(data, options={}) {
    if ( data instanceof foundry.abstract.DataModel ) data = data.toObject();

    if ( data.flags?.dnd5e?.type === "enchantment" ) {
      data.type = "enchantment";
      delete data.flags.dnd5e.type;
      foundry.utils.setProperty(data, "flags.dnd5e.persistSourceMigration", true);
    }

    return super._initializeSource(data, options);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static migrateData(data) {
    data = super.migrateData(data);
    for ( const change of data.changes ?? [] ) {
      if ( change.key === "flags.dnd5e.initiativeAdv" ) {
        change.key = "system.attributes.init.roll.mode";
        change.mode = CONST.ACTIVE_EFFECT_MODES.ADD;
        change.value = 1;
      }
    }
    return data;
  }

  /* -------------------------------------------- */
  /*  Effect Application                          */
  /* -------------------------------------------- */

  /** @inheritDoc */
  apply(doc, change) {
    // Apply shims to moved fields
    change = this._applyChangeShim(change);

    // Ensure changes targeting flags use the proper types
    if ( change.key.startsWith("flags.dnd5e.") ) change = this._prepareFlagChange(doc, change);

    // Properly handle formulas that don't exist as part of the data model
    if ( ActiveEffect5e.FORMULA_FIELDS.has(change.key) ) {
      const field = new FormulaField({ deterministic: true });
      return { [change.key]: this.constructor.applyField(doc, change, field) };
    }

    // Handle activity-targeted changes
    if ( (change.key.startsWith("activities[") || change.key.startsWith("system.activities."))
      && (doc instanceof Item) ) return this.applyActivity(doc, change);

    return super.apply(doc, change);
  }

  /* -------------------------------------------- */

  /**
   * Apply a change to activities on this item.
   * @param {Item5e} item              The Item to whom this change should be applied.
   * @param {EffectChangeData} change  The change data being applied.
   * @returns {Record<string, *>}      An object of property paths and their updated values.
   */
  applyActivity(item, change) {
    const changes = {};
    const apply = (activity, key) => {
      const c = this.apply(activity, { ...change, key });
      Object.entries(c).forEach(([k, v]) => changes[`system.activities.${activity.id}.${k}`] = v);
    };
    if ( change.key.startsWith("system.activities.") ) {
      const [, , id, ...keyPath] = change.key.split(".");
      const activity = item.system.activities?.get(id);
      if ( activity ) apply(activity, keyPath.join("."));
    } else {
      const { type, key } = change.key.match(/activities\[(?<type>[^\]]+)]\.(?<key>.+)/)?.groups ?? {};
      item.system.activities?.getByType(type)?.forEach(activity => apply(activity, key));
    }
    return changes;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static applyField(model, change, field) {
    field ??= model.schema.getField(change.key);
    change = foundry.utils.deepClone(change);
    const current = foundry.utils.getProperty(model, change.key);
    const modes = CONST.ACTIVE_EFFECT_MODES;

    // Replace value when using string interpolation syntax
    if ( (field instanceof StringField$I) && (change.mode === modes.OVERRIDE) && change.value.includes("{}") ) {
      change.value = change.value.replace("{}", current ?? "");
    }

    // If current value is `null`, UPGRADE & DOWNGRADE should always just set the value
    if ( (current === null) && [modes.UPGRADE, modes.DOWNGRADE].includes(change.mode) ) change.mode = modes.OVERRIDE;

    // Handle removing entries from sets
    if ( (field instanceof SetField$o) && (change.mode === modes.ADD) && (foundry.utils.getType(current) === "Set") ) {
      for ( const value of field._castChangeDelta(change.value) ) {
        const neg = value.replace(/^\s*-\s*/, "");
        if ( neg !== value ) current.delete(neg);
        else current.add(value);
      }
      return current;
    }

    // If attempting to apply active effect to empty MappingField entry, create it
    if ( (current === undefined) && change.key.startsWith("system.") ) {
      let keyPath = change.key;
      let mappingField = field;
      while ( !(mappingField instanceof MappingField) && mappingField ) {
        if ( mappingField.name ) keyPath = keyPath.substring(0, keyPath.length - mappingField.name.length - 1);
        mappingField = mappingField.parent;
      }
      if ( mappingField && (foundry.utils.getProperty(model, keyPath) === undefined) ) {
        const created = mappingField.model.initialize(mappingField.model.getInitialValue(), mappingField);
        foundry.utils.setProperty(model, keyPath, created);
      }
    }

    // Parse any JSON provided when targeting an object
    if ( (field instanceof ObjectField$1) || (field instanceof SchemaField$y) ) {
      change = { ...change, value: parseOrString(change.value) };
    }

    return super.applyField(model, change, field);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _applyAdd(actor, change, current, delta, changes) {
    if ( current instanceof Set ) {
      const handle = v => {
        const neg = v.replace(/^\s*-\s*/, "");
        if ( neg !== v ) current.delete(neg);
        else current.add(v);
      };
      if ( Array.isArray(delta) ) delta.forEach(item => handle(item));
      else if ( delta instanceof Set ) {
        for ( const item of delta ) handle(item);
      }
      else handle(delta);
      return;
    }
    super._applyAdd(actor, change, current, delta, changes);
  }

  /* -------------------------------------------- */

  /**
   * Modify the provided change according to a shim an emit a warning if required.
   * @param {EffectChangeData} change  The change being applied.
   * @returns {EffectChangeData}
   * @protected
   */
  _applyChangeShim(change) {
    const shim = ActiveEffect5e.SHIM_FIELDS[change.key];
    if ( !shim ) return change;
    if ( shim.warning ) foundry.utils.logCompatibilityWarning(
      `The active effect key "${change.key}" has been deprecated and should be changed to "${shim.key}".`,
      shim.warning
    );
    return { ...change, key: shim.key };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _applyLegacy(actor, change, changes) {
    if ( this.system._applyLegacy?.(actor, change, changes) === false ) return;
    super._applyLegacy(actor, change, changes);
  }

  /* --------------------------------------------- */

  /** @inheritDoc */
  _applyUpgrade(actor, change, current, delta, changes) {
    if ( current === null ) return this._applyOverride(actor, change, current, delta, changes);
    return super._applyUpgrade(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;
  }

  /* --------------------------------------------- */

  /**
   * @deprecated
   * @ignore
   */
  determineSuppression() {
    foundry.utils.logCompatibilityWarning(
      "The `ActiveEffect5e#determineSuppression` method has been deprecated and is no longer necessary to call.",
      { since: "DnD5e 5.1", until: "DnD5e 5.3" }
    );
  }
  /* -------------------------------------------- */
  /*  Lifecycle                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareDerivedData() {
    super.prepareDerivedData();
    if ( this.id === this.constructor.ID.EXHAUSTION ) this._prepareExhaustionLevel();
    if ( this.isAppliedEnchantment && this.uuid ) dnd5e.registry.enchantments.track(this.origin, this.uuid);
  }

  /* -------------------------------------------- */

  /**
   * Modify the ActiveEffect's attributes based on the exhaustion level.
   * @protected
   */
  _prepareExhaustionLevel() {
    const config = CONFIG.DND5E.conditionTypes.exhaustion;
    let level = this.getFlag("dnd5e", "exhaustionLevel");
    if ( !Number.isFinite(level) ) level = 1;
    this.img = this.constructor._getExhaustionImage(level);
    this.name = `${game.i18n.localize("DND5E.Exhaustion")} ${level}`;
    if ( level >= config.levels ) {
      this.statuses.add("dead");
      CONFIG.DND5E.statusEffects.dead.statuses?.forEach(s => this.statuses.add(s));
    }
  }

  /* -------------------------------------------- */

  /**
   * Prepare effect favorite data.
   * @returns {Promise<FavoriteData5e>}
   */
  async getFavoriteData() {
    return {
      img: this.img,
      title: this.name,
      subtitle: this.duration.remaining ? this.duration.label : "",
      toggle: !this.disabled,
      suppressed: this.isSuppressed
    };
  }

  /* -------------------------------------------- */

  /**
   * Create conditions that are applied separately from an effect.
   * @returns {Promise<ActiveEffect5e[]>}      Created rider effects.
   */
  async createRiderConditions() {
    const riders = new Set();

    for ( const status of this.getFlag("dnd5e", "riders.statuses") ?? [] ) {
      riders.add(status);
    }

    for ( const status of this.statuses ) {
      const r = CONFIG.statusEffects.find(e => e.id === status)?.riders ?? [];
      for ( const p of r ) riders.add(p);
    }

    if ( !riders.size ) return [];

    const createRider = async id => {
      const existing = this.parent.effects.get(staticID(`dnd5e${id}`));
      if ( existing ) return;
      const effect = await ActiveEffect5e.fromStatusEffect(id);
      return effect.toObject();
    };

    const effectData = await Promise.all(Array.from(riders).map(createRider));
    return ActiveEffect5e.createDocuments(effectData.filter(_ => _), { keepId: true, parent: this.parent });
  }

  /* -------------------------------------------- */

  /**
   * Create additional activities, effects, and items that are applied separately from an enchantment.
   * @param {object} options  Options passed to the effect creation.
   */
  async createRiderEnchantments(options={}) {
    let item;
    let profile;
    const { chatMessageOrigin } = options;
    const { enchantmentProfile, activityId } = options.dnd5e ?? {};

    if ( chatMessageOrigin ) {
      const message = game.messages.get(options?.chatMessageOrigin);
      item = message?.getAssociatedItem();
      const activity = message?.getAssociatedActivity();
      profile = activity?.effects.find(e => e._id === message?.getFlag("dnd5e", "use.enchantmentProfile"));
    } else if ( enchantmentProfile && activityId ) {
      let activity;
      const origin = await fromUuid(this.origin);
      if ( origin instanceof dnd5e.documents.activity.EnchantActivity ) {
        activity = origin;
        item = activity.item;
      } else if ( origin instanceof Item ) {
        item = origin;
        activity = item.system.activities?.get(activityId);
      }
      profile = activity?.effects.find(e => e._id === enchantmentProfile);
    }

    if ( !profile || !item ) return;

    // Create Activities
    const riderActivities = {};
    let riderEffects = [];
    for ( const id of profile.riders.activity ) {
      const activityData = item.system.activities.get(id)?.toObject();
      if ( !activityData ) continue;
      activityData._id = foundry.utils.randomID();
      foundry.utils.setProperty(activityData, "flags.dnd5e.dependentOn", this.id);
      riderActivities[activityData._id] = activityData;
    }
    let createdActivities = [];
    if ( !foundry.utils.isEmpty(riderActivities) ) {
      await this.parent.update({ "system.activities": riderActivities });
      createdActivities = Object.keys(riderActivities).map(id => this.parent.system.activities?.get(id));
      createdActivities.forEach(a => a.effects?.forEach(e => {
        if ( !this.parent.effects.has(e._id) ) riderEffects.push(item.effects.get(e._id)?.toObject());
      }));
    }

    // Create Effects
    riderEffects.push(...profile.riders.effect.map(id => {
      const effectData = item.effects.get(id)?.toObject();
      if ( effectData ) {
        delete effectData._id;
        delete effectData.flags?.dnd5e?.rider;
        effectData.origin = this.origin;
      }
      return effectData;
    }));
    riderEffects = riderEffects.filter(_ => _);
    riderEffects.forEach(e => foundry.utils.setProperty(e, "flags.dnd5e.dependentOn", this.id));
    await this.parent.createEmbeddedDocuments("ActiveEffect", riderEffects, { keepId: true });

    // Create Items
    if ( this.parent.isEmbedded ) {
      const riderItems = await Item5e.createWithContents(
        (await Promise.all(profile.riders.item.map(uuid => fromUuid(uuid)))).filter(_ => _), {
          transformAll: item => {
            const itemData = item.clone({}, { keepId: true }).toObject();
            foundry.utils.setProperty(itemData, "flags.dnd5e.dependentOn", this.uuid);
            foundry.utils.setProperty(itemData, "flags.dnd5e.enchantment.origin", this.uuid);
            return itemData;
          }
        }
      );
      await this.parent.actor.createEmbeddedDocuments("Item", riderItems, { keepId: true });
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  toDragData() {
    const data = super.toDragData();
    const activity = this.parent?.system.activities?.getByType("enchant").find(a => {
      return a.effects.some(e => e._id === this.id);
    });
    if ( activity ) data.activityId = activity.id;
    return data;
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preCreate(data, options, user) {
    if ( await super._preCreate(data, options, user) === false ) return false;
    if ( options.keepOrigin === false ) this.updateSource({ origin: this.parent.uuid });

    // Enchantments cannot be added directly to actors
    if ( (this.type === "enchantment") && (this.parent instanceof Actor) ) {
      ui.notifications.error("DND5E.ENCHANTMENT.Warning.NotOnActor", { localize: true });
      return false;
    }

    if ( this.isAppliedEnchantment ) {
      const origin = await fromUuid(this.origin);
      const errors = origin?.canEnchant?.(this.parent);
      if ( errors?.length ) {
        errors.forEach(err => console.error(err));
        return false;
      }
      this.updateSource({ disabled: false });
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onCreate(data, options, userId) {
    super._onCreate(data, options, userId);
    if ( userId === game.userId ) {
      if ( this.active && (this.parent instanceof Actor) ) await this.createRiderConditions();
      if ( this.isAppliedEnchantment ) await this.createRiderEnchantments(options);
    }
    if ( options.chatMessageOrigin ) {
      document.body.querySelectorAll(`[data-message-id="${options.chatMessageOrigin}"] enchantment-application`)
        .forEach(element => element.buildItemList());
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onUpdate(data, options, userId) {
    super._onUpdate(data, options, userId);
    const originalLevel = foundry.utils.getProperty(options, "dnd5e.originalExhaustion");
    const newLevel = foundry.utils.getProperty(data, "flags.dnd5e.exhaustionLevel");
    const originalEncumbrance = foundry.utils.getProperty(options, "dnd5e.originalEncumbrance");
    const newEncumbrance = data.statuses?.[0];
    const name = this.name;

    // Display proper scrolling status effects for exhaustion
    if ( (this.id === this.constructor.ID.EXHAUSTION) && Number.isFinite(newLevel) && Number.isFinite(originalLevel) ) {
      if ( newLevel === originalLevel ) return;
      // Temporarily set the name for the benefit of _displayScrollingTextStatus. We should improve this method to
      // accept a name parameter instead.
      if ( newLevel < originalLevel ) this.name = `Exhaustion ${originalLevel}`;
      this._displayScrollingStatus(newLevel > originalLevel);
      this.name = name;
    }

    // Display proper scrolling status effects for encumbrance
    else if ( (this.id === this.constructor.ID.ENCUMBERED) && originalEncumbrance && newEncumbrance ) {
      if ( newEncumbrance === originalEncumbrance ) return;
      const increase = !originalEncumbrance || ((originalEncumbrance === "encumbered") && newEncumbrance)
        || (newEncumbrance === "exceedingCarryingCapacity");
      if ( !increase ) this.name = CONFIG.DND5E.encumbrance.effects[originalEncumbrance].name;
      this._displayScrollingStatus(increase);
      this.name = name;
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preDelete(options, user) {
    const dependents = this.getDependents();
    if ( dependents.length && !game.users.activeGM ) {
      ui.notifications.warn("DND5E.ConcentrationBreakWarning", { localize: true });
      return false;
    }
    return super._preDelete(options, user);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDelete(options, userId) {
    super._onDelete(options, userId);
    if ( game.user === game.users.activeGM ) this.getDependents().forEach(e => e.delete());
    if ( this.isAppliedEnchantment ) dnd5e.registry.enchantments.untrack(this.origin, this.uuid);
    document.body.querySelectorAll(`enchantment-application:has([data-enchantment-uuid="${this.uuid}"]`)
      .forEach(element => element.buildItemList());
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _displayScrollingStatus(enabled) {
    if ( this.isConcealed ) return;
    super._displayScrollingStatus(enabled);
  }

  /* -------------------------------------------- */
  /*  Exhaustion and Concentration Handling       */
  /* -------------------------------------------- */

  /**
   * Create effect data for concentration on an actor.
   * @param {Activity} activity  The Activity on which to begin concentrating.
   * @param {object} [data]      Additional data provided for the effect instance.
   * @returns {object}           Created data for the ActiveEffect.
   */
  static createConcentrationEffectData(activity, data={}) {
    const item = activity?.item;
    if ( !item?.isEmbedded || !activity.duration.concentration ) {
      throw new Error("You may not begin concentrating on this item!");
    }

    const statusEffect = CONFIG.statusEffects.find(e => e.id === CONFIG.specialStatusEffects.CONCENTRATING);
    const effectData = foundry.utils.mergeObject({
      ...statusEffect,
      name: `${game.i18n.localize("EFFECT.DND5E.StatusConcentrating")}: ${item.name}`,
      description: `<p>${game.i18n.format("DND5E.ConcentratingOn", {
        name: item.name,
        type: game.i18n.localize(`TYPES.Item.${item.type}`)
      })}</p><hr><p>@Embed[${item.uuid} inline]</p>`,
      duration: activity.duration.getEffectData(),
      "flags.dnd5e": {
        activity: {
          type: activity.type, id: activity.id, uuid: activity.uuid
        },
        item: {
          type: item.type, id: item.id, uuid: item.uuid,
          data: !item.actor.items.has(item.id) ? item.toObject() : undefined
        }
      },
      origin: item.uuid,
      statuses: [statusEffect.id].concat(statusEffect.statuses ?? [])
    }, data, {inplace: false});
    delete effectData.id;
    if ( item.type === "spell" ) effectData["flags.dnd5e.spellLevel"] = item.system.level;

    return effectData;
  }

  /* -------------------------------------------- */

  /**
   * Register listeners for custom handling in the TokenHUD.
   */
  static registerHUDListeners() {
    Hooks.on("renderTokenHUD", this.onTokenHUDRender);
    document.addEventListener("click", this.onClickTokenHUD.bind(this), { capture: true });
    document.addEventListener("contextmenu", this.onClickTokenHUD.bind(this), { capture: true });
  }

  /* -------------------------------------------- */

  /**
   * Add modifications to the core ActiveEffect config.
   * @param {ActiveEffectConfig} app           The ActiveEffect config.
   * @param {HTMLElement} html                 The ActiveEffect config element.
   * @param {ApplicationRenderContext} context The app's rendering context.
   */
  static onRenderActiveEffectConfig(app, html, context) {
    const element = new foundry.data.fields.SetField(new foundry.data.fields.StringField(), {}).toFormGroup({
      label: game.i18n.localize("DND5E.CONDITIONS.RiderConditions.label"),
      hint: game.i18n.localize("DND5E.CONDITIONS.RiderConditions.hint")
    }, {
      name: "flags.dnd5e.riders.statuses",
      value: app.document.getFlag("dnd5e", "riders.statuses") ?? [],
      options: CONFIG.statusEffects.map(se => ({ value: se.id, label: se.name })),
      disabled: !context.editable
    });
    html.querySelector("[data-tab=details] > .form-group:has([name=statuses])")?.after(element);

    // Add tooltip with link to wiki for effects/enchantments
    const helpIconElement = document.createElement("i");
    helpIconElement.classList.add("fa-solid", "fa-circle-question");
    const tooltipText = game.i18n.format("DND5E.ACTIVEEFFECT.AttributeKeyTooltip", {
      url: app.document.type === "enchantment"
        ? "https://github.com/foundryvtt/dnd5e/wiki/Enchantment"
        : "https://github.com/foundryvtt/dnd5e/wiki/Active-Effect-Guide"
    });
    Object.assign(helpIconElement.dataset, { tooltip: tooltipText, tooltipDirection: "RIGHT", locked: "" });
    const targetElement = html.querySelector("section:is([data-tab='effects'], [data-tab='changes']) .key");
    if ( targetElement ) targetElement.insertAdjacentElement("beforeend", helpIconElement);
  }

  /* -------------------------------------------- */

  /**
   * Adjust exhaustion icon display to match current level.
   * @param {Application} app   The TokenHUD application.
   * @param {HTMLElement} html  The TokenHUD HTML.
   */
  static onTokenHUDRender(app, html) {
    const actor = app.object.actor;
    const level = foundry.utils.getProperty(actor, "system.attributes.exhaustion");
    if ( Number.isFinite(level) && (level > 0) ) {
      const img = ActiveEffect5e._getExhaustionImage(level);
      const elem = html.querySelector('[data-status-id="exhaustion"]');
      if ( elem ) {
        elem.style.objectPosition = "-100px";
        elem.style.background = `url('${img}') no-repeat center / contain`;
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Get the image used to represent exhaustion at this level.
   * @param {number} level
   * @returns {string}
   */
  static _getExhaustionImage(level) {
    const { img } = CONFIG.DND5E.conditionTypes.exhaustion;
    const split = img.split(".");
    const ext = split.pop();
    const path = split.join(".");
    return `${path}-${level}.${ext}`;
  }

  /* -------------------------------------------- */

  /**
   * Implement custom behavior for select conditions on the token HUD.
   * @param {PointerEvent} event        The triggering event.
   */
  static onClickTokenHUD(event) {
    const { target } = event;
    if ( !target.classList?.contains("effect-control") ) return;

    const actor = canvas.hud.token.object?.actor;
    if ( !actor ) return;

    const id = target.dataset?.statusId;
    if ( id === "exhaustion" ) ActiveEffect5e._manageExhaustion(event, actor);
    else if ( id === "concentrating" ) ActiveEffect5e._manageConcentration(event, actor);
  }

  /* -------------------------------------------- */

  /**
   * Manage custom exhaustion cycling when interacting with the token HUD.
   * @param {PointerEvent} event        The triggering event.
   * @param {Actor5e} actor             The actor belonging to the token.
   */
  static _manageExhaustion(event, actor) {
    let level = foundry.utils.getProperty(actor, "system.attributes.exhaustion");
    if ( !Number.isFinite(level) ) return;
    event.preventDefault();
    event.stopPropagation();
    if ( event.button === 0 ) level++;
    else level--;
    const max = CONFIG.DND5E.conditionTypes.exhaustion.levels;
    actor.update({ "system.attributes.exhaustion": Math.clamp(level, 0, max) });
  }

  /* -------------------------------------------- */

  /**
   * Manage custom concentration handling when interacting with the token HUD.
   * @param {PointerEvent} event        The triggering event.
   * @param {Actor5e} actor             The actor belonging to the token.
   */
  static _manageConcentration(event, actor) {
    const { effects } = actor.concentration;
    if ( effects.size < 1 ) return;
    event.preventDefault();
    event.stopPropagation();
    if ( effects.size === 1 ) {
      actor.endConcentration(effects.first());
      return;
    }
    const choices = effects.reduce((acc, effect) => {
      const data = effect.getFlag("dnd5e", "item");
      acc[effect.id] = data?.name ?? actor.items.get(data?.id)?.name ?? game.i18n.localize("DND5E.ConcentratingItemless");
      return acc;
    }, {});
    const options = HandlebarsHelpers.selectOptions(choices, { hash: { sort: true } });
    const content = `
    <p>${game.i18n.localize("DND5E.ConcentratingEndChoice")}</p>
    <div class="form-group">
      <label>${game.i18n.localize("DND5E.SOURCE.FIELDS.source.label")}</label>
      <div class="form-fields">
        <select name="source">${options}</select>
      </div>
    </div>`;
    foundry.applications.api.Dialog.prompt({
      content,
      window: { title: game.i18n.localize("DND5E.Concentration") },
      ok: {
        label: game.i18n.localize("DND5E.Confirm"),
        callback: (event, button, dialog) => {
          const source = new foundry.applications.ux.FormDataExtended(button.form).object.source;
          if ( source ) actor.endConcentration(source);
        }
      }
    });
  }

  /* -------------------------------------------- */

  /**
   * Record another effect as a dependent of this one.
   * @param {...ActiveEffect5e} dependent  One or more dependent effects.
   * @returns {Promise<ActiveEffect5e>}
   */
  addDependent(...dependent) {
    foundry.utils.logCompatibilityWarning(
      "Dependent documents are now tracked using the `dependentOn` flag on the document itself.",
      { since: "DnD5e 5.2", until: "DnD5e 5.4", once: true }
    );
    return Promise.all(dependent.map(d => d.setFlag("dnd5e", "dependentOn", this.uuid))).then(() => this);
  }

  /* -------------------------------------------- */

  /**
   * Retrieve a list of dependent effects.
   * @returns {Array<ActiveEffect5e|Item5e>}
   */
  getDependents() {
    const actor = this.parent instanceof Actor ? this.parent : this.parent?.parent;
    const item = this.parent instanceof Item ? this.parent : null;
    return (this.getFlag("dnd5e", "dependents") || []).reduce((arr, { uuid }) => {
      let doc;
      // TODO: Remove this special casing once https://github.com/foundryvtt/foundryvtt/issues/11214 is resolved
      if ( this.parent.pack && uuid.includes(this.parent.uuid) ) {
        const [, embeddedName, id] = uuid.replace(this.parent.uuid, "").split(".");
        doc = this.parent.getEmbeddedDocument(embeddedName, id);
      }
      else doc = fromUuidSync(uuid, { strict: false });
      if ( doc ) {
        const otherActor = doc.parent instanceof Actor ? doc.parent : doc.parent?.parent;
        const otherItem = doc.parent instanceof Item ? doc.parent : null;
        if ( ((doc instanceof ActiveEffect) && (doc.origin === this.uuid))
          || ((actor && (actor === otherActor)) || (item && (item === otherItem)))) arr.push(doc);
      }
      return arr;
    }, []).concat(dnd5e.registry.dependents.get(this));
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Helper method to add choices that have been overridden by an active effect. Used to determine what fields might
   * need to be disabled because they are overridden by an active effect in a way not easily determined by looking at
   * the `Document#overrides` data structure.
   * @param {Actor5e|Item5e} doc  Document from which to determine the overrides.
   * @param {string} prefix       The initial form prefix under which the choices are grouped.
   * @param {string} path         Path in document data.
   * @param {string[]} overrides  The list of fields that are currently modified by Active Effects. *Will be mutated.*
   */
  static addOverriddenChoices(doc, prefix, path, overrides) {
    const source = new Set(foundry.utils.getProperty(doc._source, path) ?? []);
    const current = foundry.utils.getProperty(doc, path) ?? new Set();
    const delta = current.symmetricDifference(source);
    for ( const choice of delta ) overrides.push(`${prefix}.${choice}`);
  }

  /* -------------------------------------------- */

  /**
   * Render a rich tooltip for this effect.
   * @param {EnrichmentOptions} [enrichmentOptions={}]  Options for text enrichment.
   * @returns {Promise<{content: string, classes: string[]}>}
   */
  async richTooltip(enrichmentOptions={}) {
    const properties = [];
    if ( this.isSuppressed ) properties.push("DND5E.EffectType.Unavailable");
    else if ( this.disabled ) properties.push("DND5E.EffectType.Inactive");
    else if ( this.isTemporary ) properties.push("DND5E.EffectType.Temporary");
    else properties.push("DND5E.EffectType.Passive");
    if ( this.type === "enchantment" ) properties.push("DND5E.ENCHANTMENT.Label");

    return {
      content: await foundry.applications.handlebars.renderTemplate(
        "systems/dnd5e/templates/effects/parts/effect-tooltip.hbs", {
          effect: this,
          description: await TextEditor$a.enrichHTML(this.description ?? "", { relativeTo: this, ...enrichmentOptions }),
          durationParts: this.duration.remaining ? this.duration.label.split(", ") : [],
          properties: properties.map(p => game.i18n.localize(p))
        }
      ),
      classes: ["dnd5e2", "dnd5e-tooltip", "effect-tooltip", "themed", "theme-light"]
    };
  }

  /* -------------------------------------------- */

  /** @override */
  async deleteDialog(dialogOptions={}, operation={}) {
    const type = game.i18n.localize(this.constructor.metadata.label);
    return foundry.applications.api.DialogV2.confirm(foundry.utils.mergeObject({
      window: { title: `${game.i18n.format("DOCUMENT.Delete", { type })}: ${this.name}` },
      position: { width: 400 },
      content: `
        <p>
            <strong>${game.i18n.localize("AreYouSure")}</strong> ${game.i18n.format("SIDEBAR.DeleteWarning", { type })}
        </p>
      `,
      yes: { callback: () => this.delete(operation) }
    }, dialogOptions));
  }
}

/**
 * @import { RequestOptions5e } from "../../_types.mjs";
 * @import { AttributionDescription } from "../../applications/_types.mjs";
 * @import { TravelPace5e } from "../../data/actor/fields/_types.mjs";
 * @import { SkillData } from "../../data/actor/templates/_types.mjs";
 * @import {
 *   AbilityRollProcessConfiguration,
 *   BasicRollDialogConfiguration, BasicRollMessageConfiguration,
 *   HitDieRollProcessConfiguration, InitiativeRollOptions,
 *   SkillToolRollDialogConfiguration, SkillToolRollProcessConfiguration
 * } from "../../dice/_types.mjs";
 * @import {
 *   DamageApplicationOptions, DamageDescription, DamageSummary, RestConfiguration, RestResult, SpellcastingDescription
 * } from "../_types.mjs";
 */

/**
 * Extend the base Actor class to implement additional system-specific logic.
 */
class Actor5e extends SystemDocumentMixin(Actor) {

  /** @override */
  static DEFAULT_ICON = "systems/dnd5e/icons/svg/documents/actor.svg";

  /* -------------------------------------------- */

  /**
   * Lazily computed store of classes, subclasses, background, and species.
   * @type {Record<string, Record<string, Item5e|Item5e[]>>}
   */
  _lazy = {};

  /* -------------------------------------------- */

  /**
   * Cached copy of the preferred artwork.
   * @type {{ src: string, isToken: boolean, isRandom: boolean, isVideo: boolean }|null}
   */
  _preferredArtwork = this._preferredArtwork;

  /* -------------------------------------------- */

  /**
   * Mapping of item identifiers to the items.
   * @type {IdentifiedItemsMap<string, Set<Item5e>>}
   */
  identifiedItems = this.identifiedItems;

  /* -------------------------------------------- */

  /**
   * Mapping of item compendium source UUIDs to the items.
   * @type {SourcedItemsMap<string, Set<Item5e>>}
   */
  sourcedItems = this.sourcedItems;

  /* -------------------------------------------- */

  /**
   * Types that can be selected within the compendium browser.
   * @param {object} [options={}]
   * @param {Set<string>} [options.chosen]  Types that have been selected.
   * @returns {SelectChoices}
   */
  static compendiumBrowserTypes({ chosen=new Set() }={}) {
    return new SelectChoices(Actor.TYPES.filter(t => t !== CONST.BASE_DOCUMENT_TYPE).reduce((obj, type) => {
      obj[type] = {
        label: CONFIG.Actor.typeLabels[type],
        chosen: chosen.has(type)
      };
      return obj;
    }, {}));
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * A mapping of classes belonging to this Actor.
   * @type {Record<string, Item5e>}
   */
  get classes() {
    if ( this._lazy?.classes !== undefined ) return this._lazy.classes;
    return this._lazy.classes = Object.fromEntries(this.itemTypes.class.map(cls => [cls.identifier, cls]));
  }

  /* -------------------------------------------- */

  /**
   * Calculate the bonus from any cover the actor is affected by.
   * @type {number}     The cover bonus to AC and dexterity saving throws.
   */
  get coverBonus() {
    const { coverHalf, coverThreeQuarters } = CONFIG.DND5E.statusEffects;
    if ( this.statuses.has("coverThreeQuarters") ) return coverThreeQuarters?.coverBonus;
    else if ( this.statuses.has("coverHalf") ) return coverHalf?.coverBonus;
    return 0;
  }

  /* -------------------------------------------- */

  /**
   * Get all classes which have spellcasting ability.
   * @type {Record<string, Item5e>}
   */
  get spellcastingClasses() {
    if ( this._lazy.spellcastingClasses !== undefined ) return this._lazy.spellcastingClasses;
    return this._lazy.spellcastingClasses = Object.entries(this.classes).reduce((obj, [identifier, cls]) => {
      if ( cls.spellcasting && (cls.spellcasting.progression !== "none") ) obj[identifier] = cls;
      return obj;
    }, {});
  }

  /* -------------------------------------------- */

  /**
   * A mapping of subclasses belonging to this Actor.
   * @type {Record<string, Item5e>}
   */
  get subclasses() {
    if ( this._lazy?.subclasses !== undefined ) return this._lazy.subclasses;
    return this._lazy.subclasses = Object.fromEntries(this.itemTypes.subclass.map(i => [i.identifier, i]));
  }

  /* -------------------------------------------- */

  /**
   * 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;
  }

  /* -------------------------------------------- */

  /**
   * The items this actor is concentrating on, and the relevant effects.
   * @type {{items: Set<Item5e>, effects: Set<ActiveEffect5e>}}
   */
  get concentration() {
    const concentration = {
      items: new Set(),
      effects: new Set()
    };

    const limit = this.system.attributes?.concentration?.limit ?? 0;
    if ( !limit ) return concentration;

    for ( const effect of this.effects ) {
      if ( !effect.statuses.has(CONFIG.specialStatusEffects.CONCENTRATING) ) continue;
      const data = effect.getFlag("dnd5e", "item");
      concentration.effects.add(effect);
      if ( data ) {
        let item = this.items.get(data.id);
        if ( !item && (foundry.utils.getType(data.data) === "Object") ) {
          item = new Item.implementation(data.data, { keepId: true, parent: this });
        }
        if ( item ) concentration.items.add(item);
      }
    }
    return concentration;
  }

  /* -------------------------------------------- */

  /**
   * Creatures summoned by this actor.
   * @type {Actor5e[]}
   */
  get summonedCreatures() {
    return dnd5e.registry.summons.creatures(this);
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _initializeSource(source, options={}) {
    if ( source instanceof foundry.abstract.DataModel ) source = source.toObject();

    // Migrate encounter groups to their own Actor type.
    if ( (source.type === "group") && (source.system?.type?.value === "encounter") ) {
      source.type = "encounter";
      foundry.utils.setProperty(source, "flags.dnd5e.persistSourceMigration", true);
    }

    source = super._initializeSource(source, options);
    const pack = game.packs.get(options.pack);
    if ( !source._id || !pack || !game.compendiumArt.enabled ) return source;
    const uuid = pack.getUuid(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);
      Actor5e.applyCompendiumArt(source, pack, art);
    }
    return source;
  }

  /* -------------------------------------------- */

  /**
   * Apply package-provided art to a compendium Document.
   * @param {object} source                  The Document's source data.
   * @param {CompendiumCollection} pack      The Document's compendium.
   * @param {CompendiumArtInfo} art          The art being applied.
   */
  static applyCompendiumArt(source, pack, art) {
    const biography = source.system.details?.biography;
    if ( art.credit && biography ) {
      if ( typeof biography.value !== "string" ) biography.value = "";
      biography.value += `<p>${art.credit}</p>`;
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareData() {
    if ( this.system.modelProvider !== dnd5e ) return super.prepareData();
    this._clearCachedValues();
    this._preparationWarnings = [];
    this.labels = {};
    super.prepareData();
    this.items.forEach(item => item.prepareFinalAttributes());
    this._prepareSpellcasting();
  }

  /* --------------------------------------------- */

  /**
   * Clear cached class collections.
   * @internal
   */
  _clearCachedValues() {
    this._lazy = {};
    this._preferredArtwork = null;
  }

  /* --------------------------------------------- */

  /** @inheritDoc */
  prepareEmbeddedDocuments() {
    this.identifiedItems = new IdentifiedItemsMap();
    this.sourcedItems = new SourcedItemsMap();
    this._embeddedPreparation = true;
    super.prepareEmbeddedDocuments();
    delete this._embeddedPreparation;
  }

  /* --------------------------------------------- */

  /** @inheritDoc */
  applyActiveEffects() {
    if ( this.system?.prepareEmbeddedData instanceof Function ) this.system.prepareEmbeddedData();
    return super.applyActiveEffects();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  *allApplicableEffects() {
    for ( const effect of super.allApplicableEffects() ) {
      if ( effect.type === "enchantment" ) continue;
      if ( effect.parent?.getFlag("dnd5e", "riders.effect")?.includes(effect.id) ) continue;
      yield effect;
    }
  }

  /* -------------------------------------------- */

  /**
   * Fetch an Actor by UUID and obtain a version of it in the World. If the Actor is inside a compendium, check if a
   * version has already been imported before importing it again.
   * @param {string} uuid                  The Actor's UUID.
   * @param {object} [options]
   * @param {object} [options.origin]      Optionally check if the Actor has a specific origin. If not supplied, any
   *                                       Actor that matches the criteria will be returned.
   * @param {string} [options.origin.key]  The origin property.
   * @param {any} [options.origin.value]   The origin value.
   * @returns {Promise<Actor5e>}
   * @throws {Error}                       If the Actor cannot be found, or cannot be imported.
   */
  static async fetchExisting(uuid, options={}) {
    const { origin } = options;
    const actor = await fromUuid(uuid);
    if ( !actor ) throw new Error(game.i18n.format("DND5E.ACTOR.Warning.NoActor", { uuid }));

    const { actorLink } = actor.prototypeToken;
    const matchesOrigin = !origin || (foundry.utils.getProperty(actor, origin.key) === origin.value);
    if ( !actor.pack && (!actorLink || matchesOrigin) ) return actor;

    // Search world actors to see if any had been previously imported for this purpose.
    // Linked actors must match the origin to be considered.
    const localActor = game.actors.find(a => {
      const matchesOrigin = !origin || (foundry.utils.getProperty(a, origin.key) === origin.value);
      // Has been auto-imported by this process.
      return (a.getFlag("dnd5e", "isAutoImported") || a.getFlag("dnd5e", "summonedCopy")) // Back-compat
      // User has ownership of existing actor
      && a.isOwner
      // Sourced from the desired actor UUID.
      && ((a._stats?.compendiumSource === uuid) || (a._stats?.duplicateSource === uuid))
      // Unlinked or created from a specific source.
      && (!a.prototypeToken.actorLink || matchesOrigin);
    });
    if ( localActor ) return localActor;

    // Check permissions to create actors.
    if ( !game.user.can("ACTOR_CREATE") ) throw new Error("DND5E.ACTOR.Warning.CreateActor");

    // No suitable world actor was found, create one.
    if ( actor.pack ) {
      // Template actor resides only in a compendium, import the actor into the world.
      return game.actors.importFromCompendium(game.packs.get(actor.pack), actor.id, {
        "flags.dnd5e.isAutoImported": true
      });
    } else {
      // A linked world actor was found. Create a copy to avoid affecting the original.
      return actor.clone({
        "flags.dnd5e.isAutoImported": true,
        "_stats.compendiumSource": actor._stats.compendiumSource,
        "_stats.duplicateSource": actor.uuid
      }, { save: true });
    }
  }

  /* -------------------------------------------- */

  /**
   * Select appropriate artwork to display on sheet & chat cards based on `showTokenPortrait` flag.
   * @returns {Promise<{ src: string, token: boolean, isRandom: boolean, isVideo: boolean }>}
   */
  async getPreferredArtwork() {
    if ( !this._preferredArtwork ) {
      const showTokenPortrait = this.getFlag("dnd5e", "showTokenPortrait") === true;
      const token = this.isToken ? this.token : this.prototypeToken;
      const defaultArtwork = Actor.implementation.getDefaultArtwork(this._source)?.img;
      let texture = token?.texture.src;
      if ( showTokenPortrait && token?.randomImg ) {
        const images = await this.getTokenImages();
        texture = images[Math.floor(Math.random() * images.length)];
      }
      const src = (showTokenPortrait ? texture : this.img) ?? defaultArtwork;
      this._preferredArtwork = {
        src,
        isRandom: showTokenPortrait && token?.randomImg,
        isToken: showTokenPortrait,
        isVideo: foundry.helpers.media.VideoHelper.hasVideoExtension(src)
      };
    }
    return this._preferredArtwork;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareDerivedData() {
    const origin = this.getFlag("dnd5e", "summon.origin");
    if ( origin && this.token?.id ) {
      const { collection, primaryId } = foundry.utils.parseUuid(origin);
      dnd5e.registry.summons.track(collection?.get?.(primaryId)?.uuid, this.uuid);
    }
  }

  /* -------------------------------------------- */

  /**
   * Calculate the DC of a concentration save required for a given amount of damage.
   * @param {number} damage  Amount of damage taken.
   * @returns {number}       DC of the required concentration save.
   */
  getConcentrationDC(damage) {
    return Math.clamp(
      Math.floor(damage / 2), 10, game.settings.get("dnd5e", "rulesVersion") === "modern" ? 30 : Infinity
    );
  }

  /* -------------------------------------------- */

  /**
   * 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|null} cr  The creature's challenge rating.
   * @returns {number|null}   The amount of experience granted per kill.
   */
  getCRExp(cr) {
    if ( cr === null ) return null;
    if ( cr < 1.0 ) return Math.max(200 * cr, 10);
    return CONFIG.DND5E.CR_EXP_LEVELS[cr] ?? Object.values(CONFIG.DND5E.CR_EXP_LEVELS).pop();
  }

  /* -------------------------------------------- */

  /**
   * @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 }={}) {
    let data;
    if ( this.system.getRollData ) data = this.system.getRollData({ deterministic });
    else data = {...super.getRollData()};
    data.flags = {...this.flags};
    data.name = this.name;
    data.statuses = {};
    for ( const status of this.statuses ) {
      data.statuses[status] = status === "exhaustion"
        ? this.system.attributes?.exhaustion ?? 1
        : status === "concentrating" ? this.concentration.effects.size : 1;
    }
    return data;
  }

  /* -------------------------------------------- */

  /**
   * Is this actor under the effect of this property from some status or due to its level of exhaustion?
   * @param {string} key      A key in `DND5E.conditionEffects`.
   * @returns {boolean}       Whether the actor is affected.
   */
  hasConditionEffect(key) {
    const props = CONFIG.DND5E.conditionEffects[key] ?? new Set();
    const level = this.system.attributes?.exhaustion ?? null;
    const imms = this.system.traits?.ci?.value ?? new Set();
    const applyExhaustion = (level !== null) && !imms.has("exhaustion")
      && (game.settings.get("dnd5e", "rulesVersion") === "legacy");
    const statuses = this.statuses;
    return props.some(k => {
      const l = Number(k.split("-").pop());
      return (statuses.has(k) && !imms.has(k)) || (applyExhaustion && Number.isInteger(l) && (level >= l));
    });
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /**
   * Prepares data for a specific skill.
   * @param {string} skillId    The id of the skill to prepare data for.
   * @param {object} [options]  Additional options passed to {@link CreatureTemplate#prepareSkill}.
   * @returns {SkillData}
   * @internal
   */
  _prepareSkill(skillId, options) {
    return this.system.prepareSkill?.(skillId, options) ?? {};
  }

  /* -------------------------------------------- */
  /*  Spellcasting Preparation                    */
  /* -------------------------------------------- */

  /**
   * Prepare data related to the spell-casting capabilities of the Actor.
   * Mutates the value of the system.spells object. Must be called after final item preparation.
   * @protected
   */
  _prepareSpellcasting() {
    if ( !this.system.spells ) return;

    // Translate the list of classes into spellcasting progression
    const progression = Object.values(CONFIG.DND5E.spellcasting).reduce((acc, model) => {
      if ( model.slots ) acc[model.key] = 0;
      return acc;
    }, {});
    const types = {};

    // Grab all classes with spellcasting
    const classes = this.itemTypes.class.filter(cls => {
      const type = cls.spellcasting.type;
      if ( !type ) return false;
      types[type] = (types[type] ?? 0) + 1;
      return true;
    });

    for ( const cls of classes ) {
      this.constructor.computeClassProgression(progression, cls, { actor: this, count: types[cls.spellcasting.type] });
    }

    if ( this.type === "npc" ) {
      const level = Object.values(progression).find(_ => _);
      if ( level ) this.system.attributes.spell.level = level;
      else progression.spell = this.system.attributes.spell.level ?? 0;
    }

    for ( const [type, model] of Object.entries(CONFIG.DND5E.spellcasting) ) {
      if ( !model.slots ) continue;
      // Assume spellcasting methods without progression are based on character level rather than class level.
      if ( foundry.utils.isEmpty(model.progression) ) model.computeProgression(progression, this);
      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} [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 ??= 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|void} 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
    );
    const model = CONFIG.DND5E.spellcasting[type];
    if ( (allowed === false) || !model.slots ) return;

    // Check for deprecated overrides.
    if ( model.isSingleLevel ) {
      if ( foundry.utils.getDefiningClass(this, "computePactProgression") !== Actor5e ) {
        foundry.utils.logCompatibilityWarning("Actor5e.computePactProgression is deprecated. Please use "
          + "SpellcastingModel#computeProgression instead.", { since: "DnD5e 5.1", until: "DnD5e 5.4" });
        this.computePactProgression(progression, actor, cls, spellcasting, count);
        return;
      }
    } else if ( foundry.utils.getDefiningClass(this, "computeLeveledProgression") !== Actor5e ) {
      foundry.utils.logCompatibilityWarning("Actor5e.computeLeveledProgression is deprecated. Please use "
        + "SpellcastingModel#computeProgression instead.", { since: "DnD5e 5.1", until: "DnD5e 5.4" });
      this.computeLeveledProgression(progression, actor, cls, spellcasting, count);
      return;
    }

    // Otherwise proceed with calculation.
    model.computeProgression(progression, actor, cls, spellcasting, count);
  }

  /* -------------------------------------------- */

  /**
   * 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|void} actor   Actor for whom the data is being prepared, if any.
     * @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 === false ) return;
    const model = CONFIG.DND5E.spellcasting[type];

    // Check for deprecated overrides.
    if ( model.isSingleLevel ) {
      if ( foundry.utils.getDefiningClass(this, "preparePactSlots") !== Actor5e ) {
        foundry.utils.logCompatibilityWarning("Actor5e.preparePactSlots is deprecated. Please use "
          + "SpellcastingModel#prepareSlots instead.", { since: "DnD5e 5.1", until: "DnD5e 5.4" });
        this.preparePactSlots(spells, actor, progression);
        return;
      }
    } else if ( foundry.utils.getDefiningClass(this, "prepareLeveledSlots") !== Actor5e ) {
      foundry.utils.logCompatibilityWarning("Actor5e.prepareLeveledSlots is deprecated. Please use "
        + "SpellcastingModel#prepareSlots instead.", { since: "DnD5e 5.1", until: "DnD5e 5.4" });
      this.prepareLeveledSlots(spells, actor, progression);
      return;
    }

    // Otherwise proceed with calculation.
    model.prepareSlots(spells, actor, progression);
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preCreate(data, options, user) {
    if ( (await super._preCreate(data, options, user)) === false ) return false;

    const sourceId = this._stats?.compendiumSource;
    if ( sourceId?.startsWith("Compendium.") ) return;

    // Configure prototype token settings
    const prototypeToken = {};
    if ( "size" in (this.system.traits || {}) ) {
      const size = CONFIG.DND5E.actorSizes[this.system.traits.size || "med"].token ?? 1;
      if ( !foundry.utils.hasProperty(data, "prototypeToken.width") ) prototypeToken.width = size;
      if ( !foundry.utils.hasProperty(data, "prototypeToken.height") ) prototypeToken.height = size;
    }
    if ( this.type === "character" ) Object.assign(prototypeToken, {
      sight: { enabled: true }, actorLink: true, disposition: CONST.TOKEN_DISPOSITIONS.FRIENDLY
    });
    if ( this.type === "group" ) prototypeToken.actorLink = true;
    this.updateSource({ prototypeToken });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preUpdate(changed, options, user) {
    if ( (await super._preUpdate(changed, options, user)) === false ) return false;

    // 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.actorSizes[newSize].token ?? 1;
        if ( !foundry.utils.hasProperty(changed, "prototypeToken.width") ) {
          changed.prototypeToken ||= {};
          changed.prototypeToken.height = size;
          changed.prototypeToken.width = size;
        }
      }
    }

    // Reset death save counters and store hp
    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);
      }
      foundry.utils.setProperty(options, "dnd5e.hp", { ...this.system.attributes.hp });
    }

    // Record previous exhaustion level.
    if ( Number.isFinite(foundry.utils.getProperty(changed, "system.attributes.exhaustion")) ) {
      foundry.utils.setProperty(options, "dnd5e.originalExhaustion", this.system.attributes.exhaustion);
    }
  }

  /* -------------------------------------------- */

  /**
   * 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, { isDelta });
    } else if ( attribute.startsWith(".") ) {
      const item = fromUuidSync(attribute, { relative: this });
      let newValue = item?.system.uses?.value ?? 0;
      if ( isDelta ) newValue += value;
      else newValue = value;
      return item?.update({ "system.uses.spent": item.system.uses.max - newValue });
    }
    return super.modifyTokenAttribute(attribute, value, isDelta, isBar);
  }

  /* -------------------------------------------- */

  /**
   * Apply a certain amount of damage or healing to the health pool for Actor
   * @param {DamageDescription[]|number} damages     Damages to apply.
   * @param {DamageApplicationOptions} [options={}]  Damage application options.
   * @returns {Promise<Actor5e>}                     A Promise which resolves once the damage has been applied.
   */
  async applyDamage(damages, options={}) {
    const hp = this.system.attributes.hp;
    if ( !hp ) return this; // Group actors don't have HP at the moment

    if ( Number.isNumeric(damages) ) {
      damages = [{ value: damages }];
      options.ignore ??= true;
    }

    damages = this.calculateDamage(damages, options);
    if ( !damages ) return this;

    const { amount, temp } = damages;
    const deltaTemp = amount > 0 ? Math.min(hp.temp, amount) : 0;
    const deltaHP = Math.clamp(amount - deltaTemp, -hp.damage, hp.value);
    const updates = {
      "system.attributes.hp.temp": hp.temp - deltaTemp,
      "system.attributes.hp.value": hp.value - deltaHP
    };

    if ( temp > updates["system.attributes.hp.temp"] ) updates["system.attributes.hp.temp"] = temp;

    /**
     * A hook event that fires before damage is applied to an actor.
     * @param {Actor5e} actor                     Actor the damage will be applied to.
     * @param {number} amount                     Amount of damage that will be applied.
     * @param {object} updates                    Distinct updates to be performed on the actor.
     * @param {DamageApplicationOptions} options  Additional damage application options.
     * @returns {boolean}                         Explicitly return `false` to prevent damage application.
     * @function dnd5e.preApplyDamage
     * @memberof hookEvents
     */
    if ( Hooks.call("dnd5e.preApplyDamage", this, amount, updates, options) === false ) return this;

    // Delegate damage application to a hook
    // TODO: Replace this in the future with a better modifyTokenAttribute function in the core
    if ( Hooks.call("modifyTokenAttribute", {
      attribute: "attributes.hp",
      value: amount,
      isDelta: false,
      isBar: true
    }, updates) === false ) return this;

    await this.update(updates);

    /**
     * A hook event that fires after damage has been applied to an actor.
     * @param {Actor5e} actor                     Actor that has been damaged.
     * @param {number} amount                     Amount of damage that has been applied.
     * @param {DamageApplicationOptions} options  Additional damage application options.
     * @function dnd5e.applyDamage
     * @memberof hookEvents
     */
    Hooks.callAll("dnd5e.applyDamage", this, amount, options);

    return this;
  }

  /* -------------------------------------------- */

  /**
   * Calculate the damage that will be applied to this actor.
   * @param {DamageDescription[]} damages            Damages to calculate.
   * @param {DamageApplicationOptions} [options={}]  Damage calculation options.
   * @returns {DamageSummary|false}                  New damage descriptions with changes applied, or `false` if the
   *                                                 calculation was canceled.
   */
  calculateDamage(damages, options={}) {
    damages = foundry.utils.deepClone(damages);
    damages.amount = 0;
    damages.temp = 0;

    /**
     * A hook event that fires before damage amount is calculated for an actor.
     * @param {Actor5e} actor                     The actor being damaged.
     * @param {DamageDescription[]} damages       Damage descriptions.
     * @param {DamageApplicationOptions} options  Additional damage application options.
     * @returns {boolean}                         Explicitly return `false` to prevent damage application.
     * @function dnd5e.preCalculateDamage
     * @memberof hookEvents
     */
    if ( Hooks.call("dnd5e.preCalculateDamage", this, damages, options) === false ) return false;

    const multiplier = options.multiplier ?? 1;

    const downgrade = type => options.downgrade === true || options.downgrade?.has?.(type);
    const ignore = (category, type, skipDowngrade) => {
      return options.ignore === true
        || options.ignore?.[category] === true
        || options.ignore?.[category]?.has?.(type)
        || ((category === "immunity") && downgrade(type) && !skipDowngrade)
        || ((category === "resistance") && downgrade(type) && !hasEffect("di", type));
    };

    const traits = this.system.traits ?? {};
    const hasEffect = (category, type, properties) => {
      if ( (category === "dr") && downgrade(type) && hasEffect("di", type, properties)
        && !ignore("immunity", type, true) ) return true;
      const config = traits[category];
      if ( !config?.value.has(type) ) return false;
      if ( !CONFIG.DND5E.damageTypes[type]?.isPhysical || !properties?.size ) return true;
      return !config.bypasses?.intersection(properties)?.size;
    };

    const skipped = type => {
      if ( options.only === "damage" ) return type in CONFIG.DND5E.healingTypes;
      if ( options.only === "healing" ) return type in CONFIG.DND5E.damageTypes;
      return false;
    };

    const rollData = this.getRollData({deterministic: true});

    damages.forEach(d => {
      d.active ??= {};

      // Skip damage types with immunity
      if ( skipped(d.type) || (!ignore("immunity", d.type) && hasEffect("di", d.type, d.properties)) ) {
        d.value = 0;
        d.active.multiplier = 0;
        d.active.immunity = true;
        return;
      }

      // Apply type-specific damage reduction
      if ( !ignore("modification", d.type) && traits.dm?.amount[d.type]
        && !traits.dm.bypasses.intersection(d.properties).size ) {
        const modification = simplifyBonus(traits.dm.amount[d.type], rollData);
        if ( Math.sign(d.value) !== Math.sign(d.value + modification) ) d.value = 0;
        else d.value += modification;
        d.active.modification = true;
      }

      let damageMultiplier = multiplier;

      // Apply type-specific damage resistance
      if ( !ignore("resistance", d.type) && hasEffect("dr", d.type, d.properties) ) {
        damageMultiplier /= 2;
        d.active.resistance = true;
      }

      // Apply type-specific damage vulnerability
      if ( !ignore("vulnerability", d.type) && hasEffect("dv", d.type, d.properties) ) {
        damageMultiplier *= 2;
        d.active.vulnerability = true;
      }

      // Negate healing types
      if ( (options.invertHealing !== false) && (d.type === "healing") ) damageMultiplier *= -1;

      d.value = d.value * damageMultiplier;
      d.active.multiplier = (d.active.multiplier ?? 1) * damageMultiplier;
      if ( d.type === "temphp" ) damages.temp += d.value;
      else damages.amount += d.value;
    });

    damages.amount = damages.amount > 0 ? Math.floor(damages.amount) : Math.ceil(damages.amount);

    // Apply damage threshold
    if ( ((damages.amount > 0) && (damages.amount < (this.system.attributes?.hp?.dt ?? -Infinity)))
      && !((options.ignore === true) || options.ignore?.threshold) ) {
      damages.amount = 0;
      damages.forEach(d => {
        d.value = 0;
        d.active.multiplier = 0;
        d.active.threshold = true;
      });
    }

    /**
     * A hook event that fires after damage values are calculated for an actor.
     * @param {Actor5e} actor                     The actor being damaged.
     * @param {DamageSummary} damages             Damage descriptions.
     * @param {DamageApplicationOptions} options  Additional damage application options.
     * @returns {boolean}                         Explicitly return `false` to prevent damage application.
     * @function dnd5e.calculateDamage
     * @memberof hookEvents
     */
    if ( Hooks.call("dnd5e.calculateDamage", this, damages, options) === false ) return false;

    return damages;
  }

  /* -------------------------------------------- */

  /**
   * 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.clamp(current, 0, max) / max;
    return Color.fromRGB([(1-(pct/2)), pct, 0]);
  }

  /* -------------------------------------------- */

  /**
   * Initiate concentration on an item.
   * @param {Activity} activity                  The activity on which to being concentration.
   * @param {object} [effectData]                Effect data to merge into the created effect.
   * @returns {Promise<ActiveEffect5e|void>}     A promise that resolves to the created effect.
   */
  async beginConcentrating(activity, effectData={}) {
    effectData = ActiveEffect5e.createConcentrationEffectData(activity, effectData);

    /**
     * A hook that is called before a concentration effect is created.
     * @function dnd5e.preBeginConcentrating
     * @memberof hookEvents
     * @param {Actor5e} actor         The actor initiating concentration.
     * @param {Item5e} item           The item that will be concentrated on.
     * @param {object} effectData     Data used to create the ActiveEffect.
     * @param {Activity} activity     The activity that triggered the concentration.
     * @returns {boolean}             Explicitly return false to prevent the effect from being created.
     */
    if ( Hooks.call("dnd5e.preBeginConcentrating", this, activity.item, effectData, activity) === false ) return;

    const effect = await ActiveEffect5e.create(effectData, { parent: this });

    /**
     * A hook that is called after a concentration effect is created.
     * @function dnd5e.createConcentrating
     * @memberof hookEvents
     * @param {Actor5e} actor             The actor initiating concentration.
     * @param {Item5e} item               The item that is being concentrated on.
     * @param {ActiveEffect5e} effect     The created ActiveEffect instance.
     * @param {Activity} activity         The activity that triggered the concentration.
     */
    Hooks.callAll("dnd5e.beginConcentrating", this, activity.item, effect, activity);

    return effect;
  }

  /* -------------------------------------------- */

  /**
   * End concentration on an item.
   * @param {Item5e|ActiveEffect5e|string} [target]    An item or effect to end concentration on, or id of an effect.
   *                                                   If not provided, all maintained effects are removed.
   * @returns {Promise<ActiveEffect5e[]>}              A promise that resolves to the deleted effects.
   */
  async endConcentration(target) {
    let effect;
    const { effects } = this.concentration;

    if ( !target ) {
      return effects.reduce(async (acc, effect) => {
        acc = await acc;
        return acc.concat(await this.endConcentration(effect));
      }, []);
    }

    if ( foundry.utils.getType(target) === "string" ) effect = effects.find(e => e.id === target);
    else if ( target instanceof ActiveEffect5e ) effect = effects.has(target) ? target : null;
    else if ( target instanceof Item5e ) {
      effect = effects.find(e => {
        const data = e.getFlag("dnd5e", "item") ?? {};
        return (data.id === target._id) || (data.data?._id === target._id);
      });
    }
    if ( !effect ) return [];

    /**
     * A hook that is called before a concentration effect is deleted.
     * @function dnd5e.preEndConcentration
     * @memberof hookEvents
     * @param {Actor5e} actor             The actor ending concentration.
     * @param {ActiveEffect5e} effect     The ActiveEffect that will be deleted.
     * @returns {boolean}                 Explicitly return false to prevent the effect from being deleted.
     */
    if ( Hooks.call("dnd5e.preEndConcentration", this, effect) === false) return [];

    await effect.delete();

    /**
     * A hook that is called after a concentration effect is deleted.
     * @function dnd5e.endConcentration
     * @memberof hookEvents
     * @param {Actor5e} actor             The actor ending concentration.
     * @param {ActiveEffect5e} effect     The ActiveEffect that was deleted.
     */
    Hooks.callAll("dnd5e.endConcentration", this, effect);

    return [effect];
  }

  /* -------------------------------------------- */

  /**
   * Create a chat message for this actor with a prompt to challenge concentration.
   * @param {object} [options]
   * @param {number} [options.dc]         The target value of the saving throw.
   * @param {string} [options.ability]    An ability to use instead of the default.
   * @returns {Promise<ChatMessage5e>}    A promise that resolves to the created chat message.
   */
  async challengeConcentration({ dc=10, ability=null }={}) {
    const isConcentrating = this.concentration.effects.size > 0;
    if ( !isConcentrating ) return null;

    const dataset = {
      action: "concentration",
      dc: dc
    };
    if ( ability in CONFIG.DND5E.abilities ) dataset.ability = ability;

    const config = {
      type: "concentration",
      format: "short",
      icon: true
    };

    return ChatMessage.implementation.create({
      content: await foundry.applications.handlebars.renderTemplate(
        "systems/dnd5e/templates/chat/roll-request-card.hbs",
        {
          buttons: [{
            dataset: { ...dataset, type: "concentration", visbility: "all" },
            buttonLabel: createRollLabel({ ...dataset, ...config }),
            hiddenLabel: createRollLabel({ ...dataset, ...config, hideDC: true })
          }]
        }
      ),
      whisper: game.users.filter(user => this.testUserPermission(user, "OWNER")),
      speaker: ChatMessage.implementation.getSpeaker({ actor: this })
    });
  }

  /* -------------------------------------------- */

  /**
   * 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 (game.settings.get("dnd5e", "rulesVersion") === "legacy") && this.getFlag("dnd5e", "remarkableAthlete")
      && CONFIG.DND5E.characterFlags.remarkableAthlete.abilities.includes(ability);
  }

  /* -------------------------------------------- */
  /*  Rolling                                     */
  /* -------------------------------------------- */

  /**
   * Add the reduction to this roll from exhaustion if using the modern rules.
   * @param {string[]} parts  Roll parts.
   * @param {object} data     Roll data.
   */
  addRollExhaustion(parts, data) {
    if ( (game.settings.get("dnd5e", "rulesVersion") !== "modern") || !this.system.attributes?.exhaustion ) return;
    const amount = this.system.attributes.exhaustion * (CONFIG.DND5E.conditionTypes.exhaustion?.reduction?.rolls ?? 0);
    if ( amount ) {
      parts.push("@exhaustion");
      data.exhaustion = -amount;
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle rolling a skill as part of a requested group check.
   * @param {Actor5e} actor                                      The actor.
   * @param {ChatMessage5e} request                              The request message.
   * @param {Partial<SkillToolRollProcessConfiguration>} config  Roll configuration.
   * @param {RequestOptions5e} [requestOptions]
   * @returns {Promise<ChatMessage5e|null>}
   */
  static async handleSkillCheckRequest(actor, request, config, { event }={}) {
    const data = {};
    foundry.utils.setProperty(data, "flags.dnd5e.requestResult", { actorUuid: actor.uuid, requestId: request.id });
    const [roll] = (await actor.rollSkill({ ...config, event }, {}, { data })) ?? [];
    return roll?.parent ?? null;
  }

  /* -------------------------------------------- */

  /**
   * Roll an ability check with a skill.
   * @param {Partial<SkillToolRollProcessConfiguration>} config  Configuration information for the roll.
   * @param {Partial<SkillToolRollDialogConfiguration>} dialog   Configuration for the roll dialog.
   * @param {Partial<BasicRollMessageConfiguration>} message     Configuration for the roll message.
   * @returns {Promise<D20Roll[]|null>}                          A Promise which resolves to the created Roll instance.
   */
  async rollSkill(config={}, dialog={}, message={}) {
    if ( (typeof this.system.rollSkill === "function")
      && (await this.system.rollSkill(config, dialog, message) === false) ) return null;
    if ( !this.system.skills ) return null;
    const skillLabel = CONFIG.DND5E.skills[config.skill]?.label ?? "";
    const ability = config.ability ?? this.system.skills[config.skill]?.ability ?? CONFIG.DND5E.skills[config.skill]?.ability ?? "";
    const abilityLabel = CONFIG.DND5E.abilities[ability]?.label ?? "";
    const dialogConfig = foundry.utils.mergeObject({
      options: {
        window: {
          title: game.i18n.format("DND5E.SkillPromptTitle", { skill: skillLabel, ability: abilityLabel }),
          subtitle: this.name
        }
      }
    }, dialog);
    return this.#rollSkillTool("skill", config, dialogConfig, message);
  }

  /* -------------------------------------------- */

  /**
   * Roll an ability check with a tool.
   * @param {Partial<SkillToolRollProcessConfiguration>} config  Configuration information for the roll.
   * @param {Partial<SkillToolRollDialogConfiguration>} dialog   Configuration for the roll dialog.
   * @param {Partial<BasicRollMessageConfiguration>} message     Configuration for the roll message.
   * @returns {Promise<D20Roll[]|null>}                          A Promise which resolves to the created Roll instance.
   */
  async rollToolCheck(config={}, dialog={}, message={}) {
    const toolLabel = keyLabel(config.tool, { trait: "tool" }) ?? "";
    const dialogConfig = foundry.utils.mergeObject({
      options: {
        window: {
          title: game.i18n.format("DND5E.ToolPromptTitle", { tool: toolLabel }),
          subtitle: this.name
        }
      }
    }, dialog);
    return this.#rollSkillTool("tool", config, dialogConfig, message);
  }

  /* -------------------------------------------- */

  /**
   * Shared rolling functionality between skill & tool checks.
   * @param {"skill"|"tool"} type                                Type of roll.
   * @param {Partial<SkillToolRollProcessConfiguration>} config  Configuration information for the roll.
   * @param {Partial<SkillToolRollDialogConfiguration>} dialog   Configuration for the roll dialog.
   * @param {Partial<BasicRollMessageConfiguration>} message     Configuration for the roll message.
   * @returns {Promise<D20Roll[]|null>}                          A Promise which resolves to the created Roll instance.
   */
  async #rollSkillTool(type, config={}, dialog={}, message={}) {
    let oldFormat = false;
    const name = type === "skill" ? "Skill" : "ToolCheck";

    const skillConfig = CONFIG.DND5E.skills[config.skill];
    const toolConfig = CONFIG.DND5E.tools[config.tool] ?? CONFIG.DND5E.vehicleTypes[config.tool];
    if ( ((type === "skill") && !skillConfig) || ((type === "tool") && !toolConfig) ) {
      return this.rollAbilityCheck(config, dialog, message);
    }

    const relevant = type === "skill" ? this.system.skills?.[config.skill] : this.system.tools?.[config.tool];
    const alternate = type === "skill" ? this.system.tools?.[config.tool] : this.system.skills?.[config.skill];
    const abilityId = config.ability ?? relevant?.ability ?? (type === "skill" ? skillConfig.ability : toolConfig.ability);
    const ability = this.system.abilities?.[abilityId];
    const hostActor = this.isPolymorphed && this.flags?.dnd5e?.transformOptions?.mergeSkills && (type === "skill")
      ? game.actors.get(this.flags.dnd5e?.originalActor) : null;
    const buildConfig = this._buildSkillToolConfig.bind(this, type, hostActor);
    const doubleProf = !!relevant?.prof.hasProficiency && !!alternate?.prof.hasProficiency;
    const pace = TravelField.getTravelPaceMode(config.pace, config.skill);

    const { advantage, disadvantage } = AdvantageModeField.combineFields(this.system, [
      `abilities.${abilityId}.check.roll.mode`,
      `${type}s.${type === "skill" ? config.skill : config.tool}.roll.mode`
    ], {
      advantages: { count: Number(doubleProf) + Number(pace.advantage) },
      disadvantages: { count: Number(pace.disadvantage) }
    });

    const rollConfig = foundry.utils.mergeObject({
      advantage, disadvantage,
      ability: relevant?.ability ?? (type === "skill" ? skillConfig.ability : toolConfig?.ability),
      halflingLucky: this.getFlag("dnd5e", "halflingLucky"),
      reliableTalent: (relevant?.value >= 1) && this.getFlag("dnd5e", "reliableTalent")
    }, config);
    rollConfig.hookNames = [...(config.hookNames ?? []), type, "abilityCheck", "d20Test"];
    rollConfig.rolls = [CONFIG.Dice.D20Roll.mergeConfigs({
      options: {
        maximum: Math.min(relevant?.roll.max ?? Infinity, ability?.check.roll.max ?? Infinity),
        minimum: Math.max(relevant?.roll.min ?? -Infinity, ability?.check.roll.min ?? -Infinity)
      }
    }, config.rolls?.shift())].concat(config.rolls ?? []);
    rollConfig.subject = this;

    const dialogConfig = foundry.utils.mergeObject({
      applicationClass: SkillToolRollConfigurationDialog,
      options: {
        buildConfig,
        chooseAbility: true
      }
    }, dialog);

    const abilityLabel = CONFIG.DND5E.abilities[abilityId]?.label ?? "";

    const messageConfig = foundry.utils.mergeObject({
      create: true,
      data: {
        flags: {
          dnd5e: {
            messageType: "roll",
            roll: {
              [`${type}Id`]: config[type],
              type
            }
          }
        },
        flavor: type === "skill"
          ? game.i18n.format("DND5E.SkillPromptTitle", { skill: skillConfig.label, ability: abilityLabel })
          : game.i18n.format("DND5E.ToolPromptTitle", { tool: keyLabel(config.tool, { trait: "tool" }) ?? "" }),
        speaker: ChatMessage.getSpeaker({ actor: this })
      }
    }, message);

    const rolls = await CONFIG.Dice.D20Roll.build(rollConfig, dialogConfig, messageConfig);
    if ( !rolls.length ) return null;

    /**
     * A hook event that fires after a skill or tool check has been rolled.
     * @function dnd5e.rollSkill
     * @function dnd5e.rollToolCheck
     * @memberof hookEvents
     * @param {D20Roll[]} rolls       The resulting rolls.
     * @param {object} data
     * @param {string} data.ability   Ability used as defined in `CONFIG.DND5E.abilities`.
     * @param {string} [data.skill]   ID of the skill that was rolled as defined in `CONFIG.DND5E.skills`.
     * @param {string} [data.tool]    ID of the tool that was rolled as defined in `CONFIG.DND5E.tools`.
     * @param {Actor5e} data.subject  Actor for which the roll has been performed.
     */
    const data = { ability: rollConfig.ability, [type]: rollConfig[type], subject: this };
    Hooks.callAll(`dnd5e.roll${name}`, rolls, data);
    Hooks.callAll(`dnd5e.roll${name}V2`, rolls, data);

    return oldFormat ? rolls[0] : rolls;
  }

  /* -------------------------------------------- */

  /**
   * Configure a roll config for each roll performed as part of the skill or tool check process. Will be called once
   * per roll in the process each time an option is changed in the roll configuration interface.
   * @param {"skill"|"tool"} type                          Type of roll.
   * @param {Actor5e|null} hostActor                       The original actor from which this one was transformed.
   * @param {D20RollProcessConfiguration} process          Configuration for the entire rolling process.
   * @param {D20RollConfiguration} config                  Configuration for a specific roll.
   * @param {FormDataExtended} [formData]                  Any data entered into the rolling prompt.
   * @param {number} index                                 Index of the roll within all rolls being prepared.
   */
  _buildSkillToolConfig(type, hostActor, process, config, formData, index) {
    const relevant = type === "skill" ? this.system.skills?.[process.skill] : this.system.tools?.[process.tool];
    const rollData = this.getRollData();
    const abilityId = formData?.get("ability") ?? process.ability;
    const ability = this.system.abilities?.[abilityId];
    const { calculateSkillToolProficiency } = dnd5e.dataModels.actor.CommonTemplate;
    let prof = calculateSkillToolProficiency(this, abilityId, process);
    const originalProf = calculateSkillToolProficiency(hostActor, abilityId, process);
    if ( originalProf?.multiplier > prof.multiplier ) prof = originalProf;

    let { parts, data } = CONFIG.Dice.D20Roll.constructParts({
      mod: ability?.mod,
      prof: prof?.hasProficiency ? prof.term : null,
      [`${config[type]}Bonus`]: relevant?.bonuses?.check,
      extraBonus: process.bonus,
      [`${abilityId}CheckBonus`]: ability?.bonuses?.check,
      [`${type}Bonus`]: this.system.bonuses?.abilities?.[type],
      abilityCheckBonus: this.system.bonuses?.abilities?.check
    }, { ...rollData });

    // Add exhaustion reduction
    this.addRollExhaustion(parts, data);

    config.parts = [...(config.parts ?? []), ...parts];
    config.data = { ...data, ...(config.data ?? {}) };
    config.data.abilityId = abilityId;
  }

  /* -------------------------------------------- */

  /**
   * Roll a generic ability test or saving throw.
   * Prompt the user for input on which variety of roll they want to do.
   * @param {Partial<AbilityRollProcessConfiguration>} config  Configuration information for the roll.
   * @param {Partial<BasicRollDialogConfiguration>} dialog     Configuration for the roll dialog.
   * @param {Partial<BasicRollMessageConfiguration>} message   Configuration for the roll message.
   */
  rollAbility(config={}, dialog={}, message={}) {
    const abilityId = config.ability;
    const label = CONFIG.DND5E.abilities[abilityId]?.label ?? "";
    new foundry.applications.api.Dialog({
      window: { title: `${game.i18n.format("DND5E.AbilityPromptTitle", { ability: label })}: ${this.name}` },
      position: { width: 400 },
      content: `<p>${game.i18n.format("DND5E.AbilityPromptText", { ability: label })}</p>`,
      buttons: [
        {
          action: "test",
          label: game.i18n.localize("DND5E.ActionAbil"),
          callback: () => this.rollAbilityCheck(config, dialog, message)
        },
        {
          action: "save",
          label: game.i18n.localize("DND5E.ActionSave"),
          callback: () => this.rollSavingThrow(config, dialog, message)
        }
      ]
    }).render({ force: true });
  }

  /* -------------------------------------------- */

  /**
   * Roll an Ability Check.
   * @param {Partial<AbilityRollProcessConfiguration>} config  Configuration information for the roll.
   * @param {Partial<BasicRollDialogConfiguration>} dialog     Configuration for the roll dialog.
   * @param {Partial<BasicRollMessageConfiguration>} message   Configuration for the roll message.
   * @returns {Promise<D20Roll[]|null>}                        A Promise which resolves to the created Roll instance.
   */
  async rollAbilityCheck(config={}, dialog={}, message={}) {
    const abilityLabel = CONFIG.DND5E.abilities[config.ability]?.label ?? "";
    const dialogConfig = foundry.utils.mergeObject({
      options: {
        window: {
          title: game.i18n.format("DND5E.AbilityPromptTitle", { ability: abilityLabel }),
          subtitle: this.name
        }
      }
    }, dialog);
    return this.#rollD20Test("check", config, dialogConfig, message);
  }

  /* -------------------------------------------- */

  /**
   * Roll a Saving Throw.
   * @param {Partial<AbilityRollProcessConfiguration>} config  Configuration information for the roll.
   * @param {Partial<BasicRollDialogConfiguration>} dialog     Configuration for the roll dialog.
   * @param {Partial<BasicRollMessageConfiguration>} message   Configuration for the roll message.
   * @returns {Promise<D20Roll[]|null>}                        A Promise which resolves to the created Roll instances.
   */
  async rollSavingThrow(config={}, dialog={}, message={}) {
    const abilityLabel = CONFIG.DND5E.abilities[config.ability]?.label ?? "";
    const dialogConfig = foundry.utils.mergeObject({
      options: {
        window: {
          title: game.i18n.format("DND5E.SavePromptTitle", { ability: abilityLabel }),
          subtitle: this.name
        }
      }
    }, dialog);
    return this.#rollD20Test("save", config, dialogConfig, message);
  }

  /* -------------------------------------------- */

  /**
   * Shared rolling functionality between ability checks & saving throws.
   * @param {"check"|"save"} type                     D20 test type.
   * @param {Partial<AbilityRollProcessConfiguration>} config  Configuration information for the roll.
   * @param {Partial<BasicRollDialogConfiguration>} dialog     Configuration for the roll dialog.
   * @param {Partial<BasicRollMessageConfiguration>} message   Configuration for the roll message.
   * @returns {Promise<D20Roll[]|null>}               A Promise which resolves to the created Roll instance.
   */
  async #rollD20Test(type, config={}, dialog={}, message={}) {
    let oldFormat = false;
    const name = type === "check" ? "AbilityCheck" : "SavingThrow";

    const ability = this.system.abilities?.[config.ability];
    const abilityConfig = CONFIG.DND5E.abilities[config.ability];

    const rollData = this.getRollData();
    let { parts, data } = CONFIG.Dice.D20Roll.constructParts({
      mod: ability?.mod,
      prof: ability?.[`${type}Prof`].hasProficiency ? ability[`${type}Prof`].term : null,
      [`${config.ability}${type.capitalize()}Bonus`]: ability?.bonuses[type],
      [`${type}Bonus`]: this.system.bonuses?.abilities?.[type],
      cover: (config.ability === "dex") && (type === "save") ? this.system.attributes?.ac?.cover : null
    }, rollData);
    const options = {
      advantage: ability?.[type]?.roll.mode === CONFIG.Dice.D20Roll.ADV_MODE.ADVANTAGE,
      disadvantage: ability?.[type]?.roll.mode === CONFIG.Dice.D20Roll.ADV_MODE.DISADVANTAGE,
      maximum: ability?.[type]?.roll.max,
      minimum: ability?.[type]?.roll.min
    };

    const rollConfig = foundry.utils.mergeObject({
      halflingLucky: this.getFlag("dnd5e", "halflingLucky")
    }, config);
    rollConfig.hookNames = [...(config.hookNames ?? []), name, "d20Test"];
    rollConfig.rolls = [
      CONFIG.Dice.D20Roll.mergeConfigs({ parts, data, options }, config.rolls?.shift())
    ].concat(config.rolls ?? []);
    rollConfig.rolls.forEach(({ parts, data }) => this.addRollExhaustion(parts, data));
    rollConfig.subject = this;

    const dialogConfig = foundry.utils.deepClone(dialog);

    const messageConfig = foundry.utils.mergeObject({
      create: true,
      data: {
        flags: {
          dnd5e: {
            messageType: "roll",
            roll: {
              ability: config.ability,
              type: type === "check" ? "ability" : "save"
            }
          }
        },
        flavor: game.i18n.format(
          `DND5E.${type === "check" ? "Ability" : "Save"}PromptTitle`, { ability: abilityConfig?.label ?? "" }
        ),
        speaker: ChatMessage.getSpeaker({ actor: this })
      }
    }, message);

    const rolls = await CONFIG.Dice.D20Roll.build(rollConfig, dialogConfig, messageConfig);

    // TODO: Temporary fix to re-apply roll mode back to original config object to allow calling methods to
    // access the roll mode set in the dialog. There should be a better fix for this that works for all rolls.
    message.rollMode = messageConfig.rollMode;

    if ( !rolls.length ) return null;

    /**
     * A hook event that fires after an ability check or save has been rolled.
     * @function dnd5e.rollAbilityCheck
     * @function dnd5e.rollSavingThrow
     * @memberof hookEvents
     * @param {D20Roll[]} rolls       The resulting rolls.
     * @param {object} data
     * @param {string} data.ability   ID of the ability that was rolled as defined in `CONFIG.DND5E.abilities`.
     * @param {Actor5e} data.subject  Actor for which the roll has been performed.
     */
    Hooks.callAll(`dnd5e.roll${name}`, rolls, { ability: config.ability, subject: this });

    return oldFormat ? rolls[0] : rolls;
  }

  /* -------------------------------------------- */

  /**
   * Perform a death saving throw, rolling a d20 plus any global save bonuses.
   * @param {Partial<D20RollProcessConfiguration>} config     Configuration information for the roll.
   * @param {Partial<BasicRollDialogConfiguration>} dialog    Configuration for the roll dialog.
   * @param {Partial<BasicRollMessageConfiguration>} message  Configuration for the roll message.
   * @returns {Promise<D20Roll[]|null>}                       A Promise which resolves to the Roll instance.
   */
  async rollDeathSave(config={}, dialog={}, message={}) {
    let oldFormat = false;
    const death = this.system.attributes?.death;
    if ( !death ) throw new Error(`Actors of the type '${this.type}' don't support death saves.`);

    // 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("DND5E.DeathSaveUnnecessary", { localize: true });
      return null;
    }

    const parts = [];
    let data = {};
    const options = {
      advantage: death.roll.mode === CONFIG.Dice.D20Roll.ADV_MODE.ADVANTAGE,
      disadvantage: death.roll.mode === CONFIG.Dice.D20Roll.ADV_MODE.DISADVANTAGE,
      maximum: death.roll.max,
      minimum: death.roll.min
    };

    // Diamond Soul adds proficiency
    if ( this.getFlag("dnd5e", "diamondSoul") ) {
      parts.push("@prof");
      data.prof = new Proficiency(this.system.attributes.prof, 1).term;
    }

    // Death save bonus
    if ( death.bonuses.save ) parts.push(death.bonuses.save);

    const rollConfig = foundry.utils.mergeObject({ target: 10 }, config);
    rollConfig.hookNames = [...(config.hookNames ?? []), "deathSave"];
    rollConfig.rolls = [
      CONFIG.Dice.D20Roll.mergeConfigs({ parts, data, options }, config.rolls?.shift())
    ].concat(config.rolls ?? []);

    const dialogConfig = foundry.utils.deepClone(dialog);

    const messageConfig = foundry.utils.mergeObject({
      data: {
        flags: {
          dnd5e: {
            roll: {
              type: "death"
            }
          }
        },
        flavor: game.i18n.localize("DND5E.DeathSavingThrow")
      }
    }, message);

    const rolls = await this.rollSavingThrow(rollConfig, dialogConfig, messageConfig);
    if ( !rolls?.length ) return null;

    // Take action depending on the result
    const details = { subject: this };
    const roll = rolls[0];
    const returnValue = oldFormat ? roll : rolls;

    // Save success
    if ( roll.total >= (roll.options.target ?? 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.clamp(successes, 0, 3)};
    }

    // Save failure
    else {
      let failures = (death.failure || 0) + (roll.isFumble ? 2 : 1);
      details.updates = {"system.attributes.death.failure": Math.clamp(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 {D20Roll[]} rolls         The resulting rolls.
     * @param {object} data
     * @param {string} data.chatString  Localizable string displayed in the create chat message. If not set, then
     *                                  no chat message will be displayed.
     * @param {object} data.updates     Updates that will be applied to the actor as a result of this save.
     * @param {Actor5e} data.subject    Actor for which the death saving throw has been rolled.
     * @returns {boolean}               Explicitly return `false` to prevent updates from being performed.
     */
    if ( Hooks.call("dnd5e.rollDeathSave", rolls, details) === false ) return returnValue;
    if ( Hooks.call("dnd5e.rollDeathSaveV2", rolls, details) === false ) return returnValue;

    if ( !foundry.utils.isEmpty(details.updates) ) await this.update(details.updates);

    // Display success/failure chat message
    let resultsMessage;
    if ( details.chatString ) {
      const chatData = {
        content: game.i18n.format(details.chatString, { name: this.name }),
        speaker: messageConfig.speaker ?? ChatMessage.getSpeaker({ actor: this })
      };
      ChatMessage.applyRollMode(chatData, messageConfig.rollMode ?? game.settings.get("core", "rollMode"));
      resultsMessage = await ChatMessage.create(chatData);
    }

    /**
     * A hook event that fires after a death saving throw has been rolled and after changes have been applied.
     * @function dnd5e.postRollDeathSave
     * @memberof hookEvents
     * @param {D20Roll[]} rolls                  The resulting rolls.
     * @param {object} data
     * @param {ChatMessage5e|void} data.message  The created results chat message.
     * @param {Actor5e} data.subject             Actor for which the death saving throw has been rolled.
     */
    Hooks.callAll("dnd5e.postRollDeathSave", rolls, { message: resultsMessage, subject: this });

    return returnValue;
  }

  /* -------------------------------------------- */

  /**
   * Perform a saving throw to maintain concentration.
   * @param {Partial<AbilityRollProcessConfiguration>} config  Configuration information for the roll.
   * @param {Partial<BasicRollDialogConfiguration>} dialog     Configuration for the roll dialog.
   * @param {Partial<BasicRollMessageConfiguration>} message   Configuration for the roll message.
   * @returns {Promise<D20Roll[]|null>}                        A Promise which resolves to the created Roll instance.
   */
  async rollConcentration(config={}, dialog={}, message={}) {
    let oldFormat = false;
    if ( !this.isOwner ) return null;
    const conc = this.system.attributes?.concentration;
    if ( !conc ) throw new Error("You may not make a Concentration Saving Throw with this Actor.");

    let data = {};
    const parts = [];
    const options = {
      advantage: conc.roll.mode === CONFIG.Dice.D20Roll.ADV_MODE.ADVANTAGE,
      disadvantage: conc.roll.mode === CONFIG.Dice.D20Roll.ADV_MODE.DISADVANTAGE,
      maximum: conc.roll.max,
      minimum: conc.roll.min
    };

    // Concentration bonus
    if ( conc.bonuses.save ) parts.push(conc.bonuses.save);

    const rollConfig = foundry.utils.mergeObject({
      ability: (conc.ability in CONFIG.DND5E.abilities) ? conc.ability : CONFIG.DND5E.defaultAbilities.concentration,
      isConcentration: true,
      target: 10
    }, config);
    rollConfig.hookNames = [...(config.hookNames ?? []), "concentration"];
    rollConfig.rolls = [
      CONFIG.Dice.D20Roll.mergeConfigs({ parts, data, options }, config.rolls?.shift())
    ].concat(config.rolls ?? []);

    const dialogConfig = foundry.utils.mergeObject({
      options: {
        window: {
          title: game.i18n.format("DND5E.SavePromptTitle", { ability: game.i18n.localize("DND5E.Concentration") })
        }
      }
    }, dialog);

    const messageConfig = foundry.utils.deepClone(message);

    const rolls = await this.rollSavingThrow(rollConfig, dialogConfig, messageConfig);
    if ( !rolls?.length ) return null;

    /**
     * A hook event that fires after a saving throw to maintain concentration is rolled for an Actor.
     * @function dnd5e.rollConcentration
     * @memberof hookEvents
     * @param {D20Roll[]} rolls     The resulting rolls.
     * @param {object} data
     * @param {Actor5e} data.actor  Actor for which the saving throw has been rolled.
     */
    Hooks.callAll("dnd5e.rollConcentration", rolls, { subject: this });
    Hooks.callAll("dnd5e.rollConcentrationV2", rolls, { subject: this });

    return oldFormat ? rolls[0] : rolls;
  }

  /* -------------------------------------------- */

  /**
   * Get an un-evaluated D20Roll instance used to roll initiative for this Actor.
   * @param {Partial<InitiativeRollOptions>} options  Configuration information for the roll.
   * @returns {D20Roll|null}                          The constructed but unevaluated D20Roll.
   */
  getInitiativeRoll(options={}) {
    // Use a temporarily cached initiative roll
    if ( this._cachedInitiativeRoll ) return this._cachedInitiativeRoll.clone();
    const config = this.getInitiativeRollConfig(options);
    if ( !config ) return null;

    // Create a normal D20 roll
    if ( config.options?.fixed === undefined ) {
      const formula = ["1d20"].concat(config.parts).join(" + ");
      return new CONFIG.Dice.D20Roll(formula, config.data, config.options);
    }

    // Create a basic roll with the fixed score
    return new CONFIG.Dice.BasicRoll(String(config.options.fixed), config.data, config.options);
  }

  /* -------------------------------------------- */

  /**
   * Get an un-evaluated D20Roll instance used to roll initiative for this Actor.
   * @param {Partial<InitiativeRollOptions>} options  Configuration information for the roll.
   * @returns {D20RollConfiguration|null}             Roll configuration.
   */
  getInitiativeRollConfig(options={}) {
    const init = this.system.attributes?.init;
    const flags = this.flags.dnd5e ?? {};
    const abilityId = init?.ability || CONFIG.DND5E.defaultAbilities.initiative;
    const ability = this.system.abilities?.[abilityId];

    const rollData = this.getRollData();
    let { parts, data } = CONFIG.Dice.D20Roll.constructParts({
      mod: init?.mod,
      prof: init.prof.hasProficiency ? init.prof.term : null,
      initiativeBonus: init.bonus,
      [`${abilityId}AbilityCheckBonus`]: ability?.bonuses?.check,
      abilityCheckBonus: this.system.bonuses?.abilities?.check,
      alert: flags.initiativeAlert && (game.settings.get("dnd5e", "rulesVersion") === "legacy") ? 5 : null
    }, rollData);

    const { advantage, disadvantage } = AdvantageModeField.combineFields(this.system, [
      `abilities.${abilityId}.check.roll.mode`,
      "attributes.init.roll.mode"
    ]);

    // Add exhaustion reduction
    this.addRollExhaustion(parts, data);

    // Ability score tiebreaker
    const tiebreaker = game.settings.get("dnd5e", "initiativeDexTiebreaker");
    if ( tiebreaker && Number.isNumeric(ability?.value) ) parts.push(String(ability.value / 100));

    // Fixed initiative score
    const scoreMode = game.settings.get("dnd5e", "initiativeScore");
    const useScore = (scoreMode === "all") || ((scoreMode === "npcs") && game.user.isGM && (this.type === "npc"));

    options = foundry.utils.mergeObject({
      advantage, disadvantage,
      fixed: useScore ? init.score : undefined,
      flavor: options.flavor ?? game.i18n.localize("DND5E.Initiative"),
      halflingLucky: flags.halflingLucky ?? false,
      maximum: Math.min(init.roll.max ?? Infinity, ability?.check.roll.max ?? Infinity),
      minimum: Math.max(init.roll.min ?? -Infinity, ability?.check.roll.min ?? -Infinity)
    }, options);

    const rollConfig = { parts, data, options, subject: this };

    /**
     * A hook event that fires before initiative roll is prepared for an Actor.
     * @function dnd5e.preConfigureInitiative
     * @memberof hookEvents
     * @param {Actor5e} subject              The Actor that is rolling initiative.
     * @param {D20RollConfiguration} config  Configuration data for the pending roll.
     */
    Hooks.callAll("dnd5e.preConfigureInitiative", this, rollConfig);

    return rollConfig;
  }

  /* -------------------------------------------- */

  /**
   * Roll initiative for this Actor with a dialog that provides an opportunity to elect advantage or other bonuses.
   * @param {Partial<InitiativeRollOptions>} [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={}) {
    const config = {
      evaluate: false,
      event: rollOptions.event,
      hookNames: ["initiativeDialog", "abilityCheck", "d20Test"],
      rolls: [this.getInitiativeRollConfig(rollOptions)],
      subject: this
    };
    if ( !config.rolls[0] ) return;

    // Display the roll configuration dialog
    const messageOptions = { rollMode: game.settings.get("core", "rollMode") };
    if ( config.rolls[0].options?.fixed === undefined ) {
      const dialog = { options: { title: game.i18n.localize("DND5E.InitiativeRoll") } };
      const rolls = await CONFIG.Dice.D20Roll.build(config, dialog, messageOptions);
      if ( !rolls.length ) return;
      this._cachedInitiativeRoll = rolls[0];
    }

    // Just create a basic roll with the fixed score
    else {
      const { data, options } = config.rolls[0];
      this._cachedInitiativeRoll = new CONFIG.Dice.BasicRoll(String(options.fixed), data, options);
    }

    await this.rollInitiative({ createCombatants: true, initiativeOptions: { messageOptions } });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async rollInitiative(options={}, rollOptions={}) {
    this._cachedInitiativeRoll ??= this.getInitiativeRoll(rollOptions);

    /**
     * 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 ) {
      delete this._cachedInitiativeRoll;
      return null;
    }

    const combat = await super.rollInitiative(options);
    const combatants = this.isToken ? this.getActiveTokens(false, true).reduce((arr, t) => {
      return arr.concat(game.combat.getCombatantsByToken(t.id));
    }, []) : game.combat.getCombatantsByActor(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);
    delete this._cachedInitiativeRoll;
    return combat;
  }

  /* -------------------------------------------- */

  /**
   * Roll a hit die of the appropriate type, gaining hit points equal to the die roll plus your CON modifier.
   * @param {HitDieRollProcessConfiguration} config  Configuration information for the roll.
   * @param {BasicRollDialogConfiguration} dialog    Configuration for the roll dialog.
   * @param {BasicRollMessageConfiguration} message  Configuration for the roll message.
   * @returns {Promise<BasicRoll[]|null>}            The created Roll instances, or `null` if no hit die was rolled.
   */
  async rollHitDie(config={}, dialog={}, message={}) {
    let formula;
    let oldFormat = false;

    let cls = null;

    // NPCs only have one denomination
    if ( this.type === "npc" ) {
      config.denomination = `d${this.system.attributes.hd.denomination}`;

      // If no hit dice are available, display an error notification
      if ( !this.system.attributes.hd.value ) {
        ui.notifications.error(game.i18n.format("DND5E.HitDiceNPCWarn", {name: this.name}));
        return null;
      }
    }

    // Otherwise check classes
    else {
      // If no denomination was provided, choose the first available
      if ( !config.denomination ) {
        cls = this.system.attributes.hd.classes.find(c => c.system.hd.value);
        if ( !cls ) return null;
        config.denomination = cls.system.hd.denomination;
      }

      // Otherwise, locate a class (if any) which has an available hit die of the requested denomination
      else cls = this.system.attributes.hd.classes.find(i => {
        return (i.system.hd.denomination === config.denomination) && i.system.hd.value;
      });

      // If no class is available, display an error notification
      if ( !cls ) {
        ui.notifications.error(game.i18n.format("DND5E.HitDiceWarn", {name: this.name, formula: config.denomination}));
        return null;
      }
    }
    const rulesVersion = game.settings.get("dnd5e", "rulesVersion");
    const minimumValue = rulesVersion === "modern" ? 1 : 0;
    formula ??= `max(${minimumValue}, 1${config.denomination} + @abilities.con.mod)`;
    const rollConfig = foundry.utils.deepClone(config);
    rollConfig.hookNames = [...(config.hookNames ?? []), "hitDie"];
    rollConfig.rolls = [{ parts: [formula], data: this.getRollData() }].concat(config.rolls ?? []);
    rollConfig.subject = this;

    const dialogConfig = foundry.utils.mergeObject({
      configure: false
    }, dialog);

    const flavor = game.i18n.localize("DND5E.HitDiceRoll");
    const messageConfig = foundry.utils.mergeObject({
      rollMode: game.settings.get("core", "rollMode"),
      data: {
        speaker: ChatMessage.implementation.getSpeaker({actor: this}),
        flavor,
        title: `${flavor}: ${this.name}`,
        "flags.dnd5e.roll": {type: "hitDie"}
      }
    }, message);

    const rolls = await CONFIG.Dice.BasicRoll.build(rollConfig, dialogConfig, messageConfig);
    if ( !rolls.length ) return null;
    const returnValue = oldFormat && rolls?.length ? rolls[0] : rolls;

    const updates = { actor: {}, class: {} };
    if ( rollConfig.modifyHitDice !== false ) {
      if ( cls ) updates.class["system.hd.spent"] = cls.system.hd.spent + 1;
      else updates.actor["system.attributes.hd.spent"] = this.system.attributes.hd.spent + 1;
    }
    const hp = this.system.attributes.hp;
    if ( rollConfig.modifyHitPoints !== false ) {
      const dhp = Math.min(Math.max(0, hp.effectiveMax) - hp.value, rolls.reduce((t, r) => t + r.total, 0));
      updates.actor["system.attributes.hp.value"] = hp.value + dhp;
    }

    /**
     * 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 {BasicRoll[]} rolls          The resulting rolls.
     * @param {object} data
     * @param {Actor5e} data.subject       Actor for which the hit die has been rolled.
     * @param {object} data.updates
     * @param {object} data.updates.actor  Updates that will be applied to the actor.
     * @param {object} data.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", rolls, { subject: this, updates }) === false ) return returnValue;
    if ( Hooks.call("dnd5e.rollHitDieV2", rolls, { subject: this, updates }) === false ) return returnValue;

    // Perform updates
    if ( !foundry.utils.isEmpty(updates.actor) ) await this.update(updates.actor);
    if ( !foundry.utils.isEmpty(updates.class) ) await cls.update(updates.class);

    /**
     * A hook event that fires after a hit die has been rolled for an Actor and updates have been performed.
     * @function dnd5e.postRollHitDie
     * @memberof hookEvents
     * @param {BasicRoll[]} rolls     The resulting rolls.
     * @param {object} data
     * @param {Actor5e} data.subject  Actor for which the roll was performed.
     */
    Hooks.callAll("dnd5e.postRollHitDie", rolls, { subject: this });

    return returnValue;
  }

  /* -------------------------------------------- */

  /**
   * 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.hd.denomination}`,
      data: item.getRollData(),
      chatMessage
    };
    const flavor = game.i18n.format("DND5E.ADVANCEMENT.HitPoints.Roll", { class: item.name });
    const messageData = {
      title: `${flavor}: ${this.name}`,
      flavor,
      speaker: ChatMessage.implementation.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();

    /**
     * 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();

    /**
     * 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                                     */
  /* -------------------------------------------- */

  /**
   * Take a short rest, possibly spending hit dice and recovering resources, item uses, and relevant spell slots.
   * @param {Partial<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={}) {
    if ( this.type === "vehicle" ) return;
    if ( !game.user.isGM && !game.settings.get("dnd5e", "allowRests") && !config.request ) {
      ui.notifications.warn("DND5E.REST.Warning.OnlyByRequest", { localize: true, log: false });
      return;
    }

    const clone = this.clone();
    const restConfig = CONFIG.DND5E.restTypes.short;
    config = foundry.utils.mergeObject({
      type: "short", dialog: true, chat: true, newDay: false, advanceTime: false, autoHD: false, autoHDThreshold: 3,
      duration: CONFIG.DND5E.restTypes.short.duration[game.settings.get("dnd5e", "restVariant")],
      recoverTemp: restConfig.recoverTemp, recoverTempMax: restConfig.recoverTempMax,
      exhaustionDelta: restConfig.exhaustionDelta
    }, 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 = foundry.utils.getProperty(this, "system.attributes.hd.value");
    const hp0 = foundry.utils.getProperty(this, "system.attributes.hp.value");

    // Display a Dialog for rolling hit dice
    if ( config.dialog ) {
      try {
        Object.assign(config, await ShortRestDialog.configure(this, config));
      } catch(err) { return; }
    }

    /**
     * A hook event that fires after a short rest has started, after the configuration is complete.
     * @function dnd5e.shortRest
     * @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 continued.
     */
    if ( Hooks.call("dnd5e.shortRest", this, config) === false ) return;

    // Automatically spend hit dice
    if ( config.autoHD ) await this.autoSpendHitDice({ threshold: config.autoHDThreshold });

    // Return the rest result
    const dhd = foundry.utils.getProperty(this, "system.attributes.hd.value") - hd0;
    const dhp = foundry.utils.getProperty(this, "system.attributes.hp.value") - hp0;
    return this._rest(config, { clone, dhd, dhp });
  }

  /* -------------------------------------------- */

  /**
   * Take a long rest, recovering hit points, hit dice, resources, item uses, and spell slots.
   * @param {Partial<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={}) {
    if ( this.type === "vehicle" ) return;
    if ( !game.user.isGM && !game.settings.get("dnd5e", "allowRests") && !config.request ) {
      ui.notifications.warn("DND5E.REST.Warning.OnlyByRequest", { localize: true, log: false });
      return;
    }

    const clone = this.clone();
    const restConfig = CONFIG.DND5E.restTypes.long;
    config = foundry.utils.mergeObject({
      type: "long", dialog: true, chat: true, newDay: true, advanceTime: false,
      duration: restConfig.duration[game.settings.get("dnd5e", "restVariant")],
      recoverTemp: restConfig.recoverTemp, recoverTempMax: restConfig.recoverTempMax,
      exhaustionDelta: restConfig.exhaustionDelta
    }, 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 {
        Object.assign(config, await LongRestDialog.configure(this, config));
      } catch(err) { return; }
    }

    /**
     * A hook event that fires after a long rest has started, after the configuration is complete.
     * @function dnd5e.longRest
     * @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 continued.
     */
    if ( Hooks.call("dnd5e.longRest", this, config) === false ) return;

    return this._rest(config, { clone });
  }

  /* -------------------------------------------- */

  /**
   * Handle resting an actor from a request.
   * @param {Actor5e} actor                  Actor to rest.
   * @param {ChatMessage5e} request          Request chat message.
   * @param {RestConfiguration} config       Configuration data for the rest requested.
   * @returns {Promise<ChatMessage5e|null>}
   */
  static async handleRestRequest(actor, request, config) {
    const result = await actor[config.type === "short" ? "shortRest" : "longRest"]({
      ...config, request, advanceBastionTurn: false, advanceTime: false
    });
    return result?.message ?? null;
  }

  /* -------------------------------------------- */

  /**
   * Perform all of the changes needed for a short or long rest.
   *
   * @param {RestConfiguration} config         Configuration data for the rest occurring.
   * @param {Partial<RestResult>} [result={}]  Results of the rest operation being built.
   * @returns {Promise<RestResult|void>}       Consolidated results of the rest workflow.
   * @private
   */
  async _rest(config, result={}) {
    if ( (foundry.utils.getType(this.system.rest) === "function")
      && (await this.system.rest(config, result) === false) ) return;

    result = foundry.utils.mergeObject({
      type: config.type,
      deleteItems: [],
      deltas: {
        hitPoints: 0,
        hitDice: 0
      },
      newDay: config.newDay === true,
      request: config.request,
      rolls: [],
      updateData: {},
      updateItems: []
    }, result);
    result.clone ??= this.clone();
    if ( "dhp" in result ) result.deltas.hitPoints = result.dhp;
    if ( "dhd" in result ) result.deltas.hitDice = result.dhd;

    this._getRestHitDiceRecovery(config, result);
    this._getRestHitPointRecovery(config, result);
    this._getRestResourceRecovery(config, result);
    this._getRestSpellRecovery(config, result);
    await this._getRestItemUsesRecovery(config, result);

    result.dhp = result.deltas.hitPoints;
    result.dhd = result.deltas.hitDice;
    result.longRest = result.type === "long";

    if ( config.exhaustionDelta && !result.clone.hasConditionEffect("malnourished")
      && !result.clone.hasConditionEffect("dehydrated") ) {
      const path = "system.attributes.exhaustion";
      const value = foundry.utils.getProperty(result.clone, path) ?? 0;
      foundry.utils.mergeObject(result.updateData, { [path]: Math.max(0, value + config.exhaustionDelta) });
    }

    /**
     * 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.
     * @param {RestConfiguration} config  Configuration data for the rest occurring.
     * @returns {boolean}                 Explicitly return `false` to prevent the rest updates from being performed.
     */
    if ( Hooks.call("dnd5e.preRestCompleted", this, result, config) === false ) return result;

    // Perform updates
    await this.update(result.updateData, { isRest: true });
    await this.deleteEmbeddedDocuments("Item", result.deleteItems, { isRest: true });
    await this.updateEmbeddedDocuments("Item", result.updateItems, { isRest: true });

    // Advance the game clock
    if ( config.advanceTime && (config.duration > 0) && game.user.isGM ) await game.time.advance(60 * config.duration);

    // Display a Chat Message summarizing the rest effects
    if ( config.chat ) result.message = await this._displayRestResultMessage(config, result);

    /**
     * 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.
     * @param {RestConfiguration} config  Configuration data for that occurred.
     */
    Hooks.callAll("dnd5e.restCompleted", this, result, config);

    if ( config.advanceBastionTurn && game.user.isGM && game.settings.get("dnd5e", "bastionConfiguration").enabled
      && this.itemTypes.facility.length ) await dnd5e.bastion.advanceAllFacilities(this);

    // Return data summarizing the rest effects
    return result;
  }

  /* -------------------------------------------- */

  /**
   * Display a chat message with the result of a rest.
   *
   * @param {RestConfiguration} config  Rest configuration.
   * @param {RestResult} result         Result of the rest operation.
   * @returns {Promise<ChatMessage>}    Chat message that was created.
   * @protected
   */
  async _displayRestResultMessage(config, result) {
    let { dhd, dhp } = result;
    if ( config.type === "short" ) dhd *= -1;
    const diceRestored = dhd !== 0;
    const healthRestored = dhp !== 0;
    const longRest = config.type === "long";
    const length = longRest ? "Long" : "Short";
    const typeConfig = CONFIG.DND5E.restTypes[config.type] ?? {};

    // Determine the chat message to display
    let message;
    if ( diceRestored && healthRestored ) message = `DND5E.REST.${length}.Result.Full`;
    else if ( longRest && !diceRestored && healthRestored ) message = "DND5E.REST.Long.Result.HitPoints";
    else if ( longRest && diceRestored && !healthRestored ) message = "DND5E.REST.Long.Result.HitDice";
    else message = `DND5E.REST.${length}.Result.Short`;

    // Create a chat message
    const pr = new Intl.PluralRules(game.i18n.lang);
    let chatData = {
      content: game.i18n.format(message, {
        name: this.name,
        dice: game.i18n.format(`DND5E.HITDICE.Counted.${pr.select(dhd)}`, { number: formatNumber(dhd) }),
        health: game.i18n.format(`DND5E.HITPOINTS.Counted.${pr.select(dhp)}`, { number: formatNumber(dhp) })
      }),
      flavor: this.createRestFlavor(config, result),
      type: "rest",
      rolls: result.rolls,
      speaker: ChatMessage.getSpeaker({ actor: this, alias: this.name }),
      system: {
        activations: ActivationsField.getActivations(this, typeConfig?.activationPeriods ?? []),
        deltas: ActorDeltasField.getDeltas(result.clone, {
          actor: result.updateData, delete: result.deleteItems, item: result.updateItems
        }),
        request: config.request,
        type: result.type
      }
    };
    if ( config.request ) foundry.utils.setProperty(chatData, "flags.dnd5e.requestResult", {
      actorUuid: this.uuid, requestId: config.request.id
    });
    ChatMessage.applyRollMode(chatData, game.settings.get("core", "rollMode"));
    return ChatMessage.create(chatData);
  }

  /* -------------------------------------------- */

  /**
   * Generate rest flavor text based on the provided configuration.
   * @param {RestConfiguration} config  Rest configuration.
   * @param {RestResult} [result]       Result of the rest operation.
   * @returns {string}
   */
  createRestFlavor(config, result) {
    const typeConfig = CONFIG.DND5E.restTypes[config.type] ?? {};
    const duration = convertTime(config.duration, "minute");
    const parts = [formatTime(duration.value, duration.unit)];
    if ( result?.newDay ?? config.newDay ) parts.push(game.i18n.localize("DND5E.REST.NewDay.Label").toLowerCase());
    return `${typeConfig.label} (${game.i18n.getListFormatter({ type: "unit" }).format(parts)})`;
  }

  /* -------------------------------------------- */

  /**
   * 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 }={}) {
    if ( !this.system.attributes.hp ) return;
    const hp = this.system.attributes.hp;
    const max = Math.max(0, hp.effectiveMax);
    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 class hit dice during a long rest.
   *
   * @param {RestConfiguration} [config]
   * @param {number} [config.maxHitDice]  Maximum number of hit dice to recover.
   * @param {number} [config.fraction]    Fraction of max hit dice to recover. Used for NPC recovery and for PCs if
   *                                      `maxHitDice` isn't specified.
   * @param {RestResult} [result={}]      Rest result being constructed.
   * @protected
   */
  _getRestHitDiceRecovery({ maxHitDice, fraction, ...config }={}, result={}) {
    const restConfig = CONFIG.DND5E.restTypes[config.type];
    if ( !this.system.attributes.hd || !restConfig?.recoverHitDice ) return;
    fraction ??= game.settings.get("dnd5e", "rulesVersion") === "modern" ? 1 : 0.5;

    // Handle simpler HD recovery for NPCs
    if ( this.type === "npc" ) {
      const hd = this.system.attributes.hd;
      const recovered = Math.min(
        Math.max(1, Math.floor(hd.max * fraction)), hd.spent, maxHitDice ?? Infinity
      );
      foundry.utils.mergeObject(result, {
        deltas: {
          hitDice: (result.deltas?.hitDice ?? 0) + recovered
        },
        updateData: {
          "system.attributes.hd.spent": hd.spent - recovered
        }
      });
      return;
    }

    this.system.attributes.hd.createHitDiceUpdates({ maxHitDice, fraction, ...config }, result);
  }

  /* -------------------------------------------- */

  /**
   * Recovers actor hit points and eliminates any temp HP.
   * @param {RestConfiguration} [config={}]
   * @param {boolean} [config.recoverTemp=true]     Reset temp HP to zero.
   * @param {boolean} [config.recoverTempMax=true]  Reset temp max HP to zero.
   * @param {RestResult} [result={}]                Rest result being constructed.
   * @protected
   */
  _getRestHitPointRecovery({ recoverTemp, recoverTempMax, ...config }={}, result={}) {
    const restConfig = CONFIG.DND5E.restTypes[config.type ?? "long"];
    const hp = this.system.attributes?.hp;
    if ( !hp || !restConfig.recoverHitPoints ) return;

    let max = hp.max;
    result.updateData ??= {};
    if ( recoverTempMax ) result.updateData["system.attributes.hp.tempmax"] = 0;
    else max = Math.max(0, hp.effectiveMax);
    result.updateData["system.attributes.hp.value"] = max;
    if ( recoverTemp ) result.updateData["system.attributes.hp.temp"] = 0;
    foundry.utils.setProperty(
      result, "deltas.hitPoints", (result.deltas?.hitPoints ?? 0) + Math.max(0, max - hp.value)
    );
  }

  /* -------------------------------------------- */

  /**
   * Recovers actor resources.
   * @param {object} [config={}]
   * @param {boolean} [config.recoverShortRestResources]  Recover resources that recharge on a short rest.
   * @param {boolean} [config.recoverLongRestResources]   Recover resources that recharge on a long rest.
   * @param {RestResult} [result={}]                      Rest result being constructed.
   * @protected
   */
  _getRestResourceRecovery({recoverShortRestResources, recoverLongRestResources, ...config}={}, result={}) {
    recoverShortRestResources ??= config.type === "short";
    recoverLongRestResources ??= config.type === "long";
    for ( let [k, r] of Object.entries(this.system.resources ?? {}) ) {
      if ( Number.isNumeric(r.max) && ((recoverShortRestResources && r.sr) || (recoverLongRestResources && r.lr)) ) {
        result.updateData[`system.resources.${k}.value`] = Number(r.max);
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Recovers expended spell slots.
   * @param {RestConfiguration} [config={}]
   * @param {boolean} [config.recoverShort]    Recover slots that return on short rests.
   * @param {boolean} [config.recoverLong]     Recover slots that return on long rests.
   * @param {RestResult} [result={}]           Rest result being constructed.
   * @protected
   */
  _getRestSpellRecovery({ recoverShort, recoverLong, ...config }={}, result={}) {
    const restConfig = CONFIG.DND5E.restTypes[config.type];
    if ( !this.system.spells ) return;
    const types = restConfig.recoverSpellSlotTypes;
    if ( !types?.size ) return;
    for ( const [key, slot] of Object.entries(this.system.spells) ) {
      if ( !types.has(slot.type) ) continue;
      result.updateData[`system.spells.${key}.value`] = slot.max;
    }
  }

  /* -------------------------------------------- */

  /**
   * Recovers item uses during short or long rests.
   * @param {object} [config]
   * @param {boolean} [config.recoverShortRestUses=true]  Recover uses for items that recharge after a short rest.
   * @param {boolean} [config.recoverLongRestUses=true]   Recover uses for items that recharge after a long rest.
   * @param {boolean} [config.recoverDailyUses=true]      Recover uses for items that recharge on a new day.
   * @param {RestResult} [result={}]                      Rest result being constructed.
   * @protected
   */
  async _getRestItemUsesRecovery({
    recoverShortRestUses, recoverLongRestUses, recoverDailyUses, ...config
  }={}, result={}) {
    const restConfig = CONFIG.DND5E.restTypes[config.type];
    const recovery = Array.from(restConfig.recoverPeriods ?? []);
    if ( recoverShortRestUses ) recovery.unshift("sr");
    if ( recoverLongRestUses ) recovery.unshift("lr");
    if ( recoverDailyUses || config.newDay ) recovery.unshift("day", "dawn", "dusk");

    result.updateItems ??= [];
    result.rolls ??= [];
    for ( const item of this.items ) {
      if ( (item.dependentOrigin?.active === false)
        || (foundry.utils.getType(item.system.recoverUses) !== "function") ) continue;
      const rollData = item.getRollData();
      const { updates, rolls, destroy } = await item.system.recoverUses(recovery, rollData);
      if ( destroy ) {
        result.deleteItems.push(item.id);
      } else if ( !foundry.utils.isEmpty(updates) ) {
        const updateTarget = result.updateItems.find(i => i._id === item.id);
        if ( updateTarget ) foundry.utils.mergeObject(updateTarget, updates);
        else result.updateItems.push({ _id: item.id, ...updates });
      }
      result.rolls.push(...rolls);
    }
  }

  /* -------------------------------------------- */
  /*  Property Attribution                        */
  /* -------------------------------------------- */

  /**
   * Format an HTML breakdown for a given property.
   * @param {string} attribution      The property.
   * @param {object} [options]
   * @param {string} [options.title]  A title for the breakdown.
   * @returns {Promise<string>}
   */
  async getAttributionData(attribution, { title }={}) {
    switch ( attribution ) {
      case "attributes.ac": return this._prepareArmorClassAttribution({ title });
      case "attributes.movement": return this._prepareMovementAttribution();
      default: return "";
    }
  }

  /* -------------------------------------------- */

  /**
   * Prepare a movement breakdown.
   * @returns {string}
   * @protected
   */
  _prepareMovementAttribution() {
    const { movement } = this.system.attributes;
    const units = movement.units || defaultUnits("length");
    const unit = CONFIG.DND5E.movementUnits[units]?.formattingUnit;
    const formatValue = value => `<span class="value">${
      unit ? formatLength(value ?? 0, unit, { parts: true })
        : `${value ?? 0} <span class="units">${units}</span>`
    }</span>`;
    return Object.entries(CONFIG.DND5E.movementTypes).reduce((html, [k, { label }]) => {
      const value = movement[k];
      if ( value || (k === "walk") ) html += `
        <div class="row">
          <i class="fas ${k}"></i>
          ${formatValue(value)}
          <span class="label">${label}</span>
        </div>
      `;
      return html;
    }, "");
  }

  /* -------------------------------------------- */

  /**
   * Prepare an AC breakdown.
   * @param {object} [options]
   * @param {string} [options.title]  A title for the breakdown.
   * @returns {Promise<string>}
   * @protected
   */
  async _prepareArmorClassAttribution({ title }={}) {
    const rollData = this.getRollData({ deterministic: true });
    const ac = rollData.attributes.ac;
    const cfg = CONFIG.DND5E.armorClasses[ac.calc];
    const attribution = [];

    if ( ac.calc === "flat" ) {
      attribution.push({
        label: game.i18n.localize("DND5E.ArmorClassFlat"),
        mode: CONST.ACTIVE_EFFECT_MODES.OVERRIDE,
        value: ac.flat
      });
      return new PropertyAttribution(this, attribution, "attributes.ac", { title }).renderTooltip();
    }

    // Base AC Attribution
    switch ( ac.calc ) {

      // 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.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.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
    });

    if ( attribution.length ) {
      return new PropertyAttribution(this, attribution, "attributes.ac", { title }).renderTooltip();
    }

    return "";
  }

  /* -------------------------------------------- */

  /**
   * 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) {
    const rollData = this.getRollData({ deterministic: true });
    const attributions = [];
    for ( const e of this.allApplicableEffects() ) {
      let source = e.sourceName;
      if ( !e.origin || (e.origin === this.uuid) ) source = e.name;
      if ( !source || e.disabled || e.isSuppressed ) continue;
      const value = e.changes.reduce((n, change) => {
        if ( (ActiveEffect5e.SHIM_FIELDS[change.key]?.key ?? change.key) !== target ) return n;
        if ( change.mode !== CONST.ACTIVE_EFFECT_MODES.ADD ) return n;
        return n + simplifyBonus(change.value, rollData);
      }, 0);
      if ( value ) attributions.push({ value, label: source, document: e, mode: CONST.ACTIVE_EFFECT_MODES.ADD });
    }
    return attributions;
  }

  /* -------------------------------------------- */
  /*  Conversion & Transformation                 */
  /* -------------------------------------------- */

  /**
   * Fetch stats from the original actor for data preparation.
   * @returns {{ originalSaves: object|null, originalSkills: object|null }}
   */
  getOriginalStats() {
    // Retrieve data for polymorphed actors
    let originalSaves = null;
    let originalSkills = null;
    if ( this.isPolymorphed ) {
      const transformOptions = this.flags.dnd5e?.transformOptions;
      const original = game.actors?.get(this.flags.dnd5e?.originalActor);
      if ( original ) {
        if ( transformOptions.mergeSaves ) originalSaves = original.system.abilities;
        if ( transformOptions.mergeSkills ) originalSkills = original.system.skills;
      }
    }
    return { originalSaves, originalSkills };
  }

  /* -------------------------------------------- */

  /**
   * Transform this Actor into another one.
   *
   * @param {Actor5e} source                       The actor being transformed into.
   * @param {TransformationSetting} [settings]     Options that determine how the transformation is performed.
   * @param {object} [options]
   * @param {boolean} [options.renderSheet]        Render the sheet of the transformed actor after the polymorph.
   * @returns {Promise<Array<Token>>|null}         Updated token if the transformation was performed.
   */
  async transformInto(source, settings=new TransformationSetting(), options={}) {
    // Ensure the player is allowed to polymorph
    const allowed = game.settings.get("dnd5e", "allowPolymorphing");
    if ( !allowed && !game.user.isGM ) {
      ui.notifications.warn("DND5E.TRANSFORM.Warning.NoPermission", { localize: true });
      return null;
    }

    // Get the original Actor data and the new source data
    const o = this.toObject();
    o.flags.dnd5e = o.flags.dnd5e || {};
    o.flags.dnd5e.transformOptions = {
      ...settings.toObject(),
      mergeSaves: settings.merge.has("saves"),
      mergeSkills: settings.merge.has("skills")
    };
    const sourceData = source.toObject();
    const rollData = { ...this.getRollData(), source: source.getRollData() };

    if ( settings.keep.has("self") ) {
      o.img = sourceData.img;
      o.name = o.prototypeToken.name = `${o.name} (${game.i18n.localize("DND5E.TRANSFORM.Preset.Appearance.Label")})`;
    }

    // Prepare new data to merge from the source
    const d = foundry.utils.mergeObject(foundry.utils.deepClone({
      type: o.type, // Remain the same actor type
      name: `${o.name} (${sourceData.name})`, // Append the new shape to your old name
      system: sourceData.system, // Get the systemdata model of your new form
      items: sourceData.items, // Get the items of your new form
      effects: o.effects.concat(sourceData.effects), // Combine active effects from both forms
      img: sourceData.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} (${sourceData.name})`, texture: {}, sight: {}, detectionModes: [] } // Set a new empty token
    }), settings.keep.has("self") ? o : {}); // Keeps most of original actor

    // Specifically delete some data attributes
    delete d.system.resources; // Don't change your resource pools
    delete d.system.currency; // Don't lose currency
    delete d.system.bonuses; // Don't lose global bonuses

    if ( settings.keep.has("spells") || settings.spellLists.size ) {
      // Keep spellcasting ability if retaining spells.
      d.system.attributes.spellcasting = o.system.attributes.spellcasting;
    }

    // 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 = source.system.attributes.ac.value; // Override AC

    // Token appearance updates
    const tokenPropsFromSource = ["width", "height", "alpha", "lockRotation", "ring"];
    const tokenTexturePropsFromSource = ["offsetX", "offsetY", "scaleX", "scaleY", "src", "tint"];
    const tokenPropsFromSelf = [
      "bar1", "bar2", "displayBars", "displayName", "disposition", "rotation", "elevation", "hidden"
    ];

    for ( const k of tokenPropsFromSource ) {
      d.prototypeToken[k] = sourceData.prototypeToken[k];
    }
    for ( const k of tokenTexturePropsFromSource ) {
      d.prototypeToken.texture[k] = sourceData.prototypeToken.texture[k];
    }
    for ( const k of tokenPropsFromSelf ) {
      d.prototypeToken[k] = o.prototypeToken[k];
    }

    if ( !settings.keep.has("self") ) {
      const sightSource = settings.keep.has("vision") ? o.prototypeToken : sourceData.prototypeToken;
      for ( const k of ["range", "angle", "visionMode", "color", "attenuation", "brightness", "saturation", "contrast"] ) {
        d.prototypeToken.sight[k] = sightSource.sight[k];
      }
      d.prototypeToken.sight.enabled = o.prototypeToken.sight.enabled;
      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 ( settings.keep.has("physical") && (type === "physical") ) abilities[k] = oa;
        else if ( settings.keep.has("mental") && (type === "mental") ) abilities[k] = oa;

        // Set saving throw proficiencies.
        if ( settings.keep.has("saves") && oa ) abilities[k].proficient = oa.proficient;
        else if ( settings.merge.has("saves") && oa ) abilities[k].proficient = Math.max(prof, oa.proficient);
        else abilities[k].proficient = sourceData.system.abilities[k].proficient;
      }

      // Transfer skills
      if ( settings.keep.has("skills") ) d.system.skills = o.system.skills;
      else if ( settings.merge.has("skills") ) {
        for ( let [k, s] of Object.entries(d.system.skills) ) {
          s.value = Math.max(s.value, o.system.skills[k]?.value ?? 0);
        }
      }

      // Keep armor, weapon, & tool proficiencies
      if ( settings.keep.has("gearProf") ) {
        d.system.traits.armorProf = o.system.traits.armorProf;
        d.system.traits.weaponProf = o.system.traits.weaponProf;
        d.system.tools = o.system.tools;
      }

      // Keep languages
      if ( settings.keep.has("languages") ) d.system.traits.languages = o.system.traits.languages;

      // Keep specific items from the original data
      const spellIdentifiers = settings.spellLists.size ? new Set(
        Array.from(settings.spellLists)
          .map(id => dnd5e.registry.spellLists.forType(id))
          .filter(list => this.identifiedItems.get(list?.metadata.identifier, list?.metadata.type)?.size)
          .flatMap(list => Array.from(list.identifiers))
      ) : null;
      const profDiff = source.system.attributes.prof - this.system.attributes.prof;
      d.items = d.items.map(i => {
        if ( settings.keep.has("class") && ((i.type === "feat") || (i.type === "weapon")) && profDiff ) {
          // Items gained from the source should use the source's proficiency bonus.
          Object.values(i.system.activities).forEach(activity => {
            if ( activity.type === "attack" ) {
              activity.attack.bonus ??= "";
              activity.attack.bonus += ` ${profDiff < 0 ? "" : "+"}${profDiff}`;
            }
          });
        }
        return i;
      }).concat(o.items.filter(i => {
        switch ( i.type ) {
          case "class":
          case "subclass": return settings.keep.has("class") || settings.keep.has("hp");
          case "feat": return settings.keep.has("feats");
          case "spell": return spellIdentifiers?.has(i.system.identifier)
            || (!spellIdentifiers && settings.keep.has("spells"));
          case "race": return settings.keep.has("type");
          default: return settings.keep.has("items");
        }
      }));

      // Transfer classes for NPCs
      if ( !settings.keep.has("class") && ("cr" in d.system.details) ) {
        if ( settings.keep.has("hp") ) {
          let profOverride = d.effects.findSplice(e => e._id === staticID("dnd5eTransformProf"));
          if ( !profOverride ) profOverride = new ActiveEffect.implementation({
            _id: staticID("dnd5eTransformProf"),
            name: game.i18n.localize("DND5E.Proficiency"),
            img: "icons/skills/social/diplomacy-peace-alliance.webp",
            disabled: false
          }).toObject();
          profOverride.changes = [{
            key: "system.attributes.prof",
            mode: CONST.ACTIVE_EFFECT_MODES.OVERRIDE,
            value: source.system.attributes.prof
          }];
          d.effects.push(profOverride);
        } else {
          const cls = new dnd5e.dataModels.item.ClassData({ levels: d.system.details.cr });
          d.items.push({
            type: "class",
            name: game.i18n.localize("DND5E.TRANSFORM.TemporaryClass"),
            system: cls.toObject()
          });
        }
      }

      // Keep biography
      if ( settings.keep.has("bio") ) d.system.details.biography = o.system.details.biography;

      // Keep senses
      if ( settings.keep.has("vision") ) d.system.traits.senses = o.system.traits.senses;

      // Keep creature type
      if ( settings.keep.has("type") ) d.system.details.type = o.system.details.type;

      // Keep HP & HD
      if ( settings.keep.has("hp") ) d.system.attributes.hp = {
        ...o.system.attributes.hp,
        max: this.system.attributes.hp.max
      };

      // Keep damage resistances
      if ( settings.keep.has("resistances") ) {
        d.system.traits.di = o.system.traits.di;
        d.system.traits.dr = o.system.traits.dr;
        d.system.traits.dv = o.system.traits.dv;
        d.system.traits.dm = o.system.traits.dm;
      }

      // Add temporary hit points
      const tempHp = simplifyBonus(settings.tempFormula, rollData);
      if ( tempHp ) d.system.attributes.hp.temp = tempHp;

      // Set armor class
      const minimumAC = simplifyBonus(settings.minimumAC, rollData);
      if ( minimumAC > source.system.attributes.ac.value ) {
        d.system.attributes.ac.calc = "natural";
        d.system.attributes.ac.flat = minimumAC;
      }

      // 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 ( settings.effects.has("all") ) return true;
        if ( settings.keep.has("hp") && !settings.keep.has("class") && (e._id === staticID("dnd5eTransformProf")) ) {
          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 settings.effects.has("origin");
        if ( !isOriginEffect && !originIsSelf ) return settings.effects.has("otherOrigin");
        switch ( origin.type ) {
          case "spell": return settings.effects.has("spell");
          case "feat": return settings.effects.has("feat");
          case "background": return settings.effects.has("background");
          case "class":
          case "subclass": return settings.effects.has("class");
          case "equipment":
          case "weapon":
          case "tool":
          case "loot":
          case "container": return settings.effects.has("equipment");
          default: return true;
        }
      });

      // Copy favorites
      if ( "favorites" in o.system ) d.system.favorites = o.system.favorites.filter(f => {
        if ( !["activity", "item"].includes(f.type) ) return true;
        const [, itemId] = foundry.utils.parseUuid(f.id, { relative: this, strict: false })?.embedded ?? [];
        return d.items.find(i => i._id === itemId);
      });
    }

    // Set a random image if source is configured that way
    if ( sourceData.prototypeToken.randomImg ) {
      const images = await source.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);

    // If `renderSheet` isn't specified, only render if non-transformed sheet is open
    options.renderSheet ??= this.sheet?.rendered ?? false;

    // Update unlinked Tokens, and grab a copy of any actorData adjustments to re-apply
    if ( this.isToken ) {
      const tokenData = d.prototypeToken;
      delete d.prototypeToken;
      for ( const k of tokenPropsFromSelf ) {
        tokenData[k] = this.token[k];
      }
      if ( !this.token.flags.dnd5e?.previousActorData ) {
        const previousActorData = this.token.delta.toObject();
        const previousTokenData = { texture: {} };
        for ( const k of [...tokenPropsFromSource, ...tokenPropsFromSelf, "name"] ) {
          previousTokenData[k] = this.token[k];
        }
        for ( const k of tokenTexturePropsFromSource ) {
          previousTokenData.texture[k] = this.token.texture[k];
        }
        foundry.utils.setProperty(tokenData, "flags.dnd5e.previousActorData", previousActorData);
        foundry.utils.setProperty(tokenData, "flags.dnd5e.previousTokenData", previousTokenData);
      }
      await this.sheet?.close();
      const update = await this.token.update(tokenData);
      // TODO: We have to make do with these extra server hits until #12768 or #12769 is resolved.
      const itemIds = new Set(d.items.map(i => i._id));
      const effectIds = new Set(d.effects.map(e => e._id));
      // An invocation like this is the only thing that (currently) triggers correct tombstoning on the ActorDelta.
      await this.token.actor.deleteEmbeddedDocuments("Item", o.items.reduce((ids, { _id: id }) => {
        if ( !itemIds.has(id) ) ids.push(id);
        return ids;
      }, []));
      await this.token.actor.deleteEmbeddedDocuments("ActiveEffect", o.effects.reduce((ids, { _id: id }) => {
        if ( !effectIds.has(id) ) ids.push(id);
        return ids;
      }, []));
      await this.token.actor.update(d);
      if ( options.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} host                    The original actor before transformation.
     * @param {Actor5e} source                  The source actor into which to transform.
     * @param {object} data                     The data that will be used to create the new transformed actor.
     * @param {TransformationSetting} settings  Settings that determine how the transformation is performed.
     * @param {object} options                  Rendering options passed to the actor creation.
     */
    Hooks.callAll("dnd5e.transformActor", this, source, d, settings, options);
    Hooks.callAll("dnd5e.transformActorV2", this, source, d, settings, options);

    // Create new Actor with transformed data
    const newActor = await this.constructor.create(d, options);

    // Update placed Token instances
    if ( !settings.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;
      for ( const k of tokenPropsFromSelf ) {
        newTokenData[k] = t.document[k];
      }

      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);
      if ( !t.document.flags.dnd5e?.previousTokenData ) {
        const previousTokenData = { texture: {} };
        for ( const k of [...tokenPropsFromSource, ...tokenPropsFromSelf, "name"] ) {
          previousTokenData[k] = t.document[k];
        }
        for ( const k of tokenTexturePropsFromSource ) {
          previousTokenData.texture[k] = t.document.texture[k];
        }
        foundry.utils.setProperty(newTokenData, "flags.dnd5e.previousTokenData", previousTokenData);
      }
      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(options={}) {
    if ( !this.isPolymorphed ) return;
    if ( !this.isOwner ) {
      ui.notifications.warn("DND5E.TRANSFORM.Warning.NoOwnership", { localize: true });
      return null;
    }

    options.renderSheet ??= true;

    /**
     * A hook event that fires just before the actor is reverted to original form.
     * @function dnd5e.revertOriginalForm
     * @memberof hookEvents
     * @param {Actor} actor                  The original actor before transformation.
     * @param {object} options
     * @param {boolean} options.renderSheet  Render the reverted actor sheet.
     */
    Hooks.callAll("dnd5e.revertOriginalForm", this, options);

    const transformOptions = this.getFlag("dnd5e", "transformOptions");
    const previousActorIds = this.getFlag("dnd5e", "previousActorIds") ?? [];
    const isRendered = this.sheet.rendered;

    // Obtain a reference to the original actor
    const original = game.actors.get(this.getFlag("dnd5e", "originalActor"));

    const update = {};
    if ( transformOptions?.keep?.includes("hp") ) {
      foundry.utils.setProperty(update, "system.attributes.hp.value", this.system.attributes.hp.value);
    }
    if ( transformOptions?.keep?.includes("tempHP") ) {
      foundry.utils.setProperty(update, "system.attributes.hp.temp", this.system.attributes.hp.temp);
    }
    if ( transformOptions?.keep?.includes("spells") || transformOptions?.spellLists?.length ) {
      Object.entries(this.system.spells ?? {}).forEach(([k, v]) => {
        if ( v.max ) update[`system.spells.${k}.value`] = v.value;
      });
    }

    // 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.TRANSFORM.Warning.OriginalActor", {
          reference: this.getFlag("dnd5e", "originalActor")
        }));
        return;
      }
      const prototypeTokenData = (await baseActor.getTokenDocument()).toObject();
      const actorData = this.token.getFlag("dnd5e", "previousActorData");
      foundry.utils.mergeObject(actorData, update);
      const tokenUpdate = this.token.toObject();
      actorData._id = tokenUpdate.delta._id;
      tokenUpdate.delta = actorData;

      foundry.utils.mergeObject(tokenUpdate, this.token.getFlag("dnd5e", "previousTokenData"));
      tokenUpdate.sight = prototypeTokenData.sight;
      tokenUpdate.detectionModes = prototypeTokenData.detectionModes;
      delete tokenUpdate.flags.dnd5e.previousActorData;
      delete tokenUpdate.flags.dnd5e.previousTokenData;

      await this.sheet.close();
      const token = await TokenDocument.implementation.create(tokenUpdate, { parent: this.token.parent, render: true });
      await this.token.delete({ replacements: { [this.token._id]: token.uuid } });
      if ( isRendered && options.renderSheet ) token.actor?.sheet?.render(true);
      return token;
    }

    if ( !original ) {
      ui.notifications.warn(game.i18n.format("DND5E.TRANSFORM.Warning.OriginalActor", {
        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()).toObject();
      const tokenUpdates = tokens.map(t => {
        const update = foundry.utils.deepClone(tokenData);
        update._id = t.id;
        update.elevation = t.document.elevation;
        update.hidden = t.document.hidden;
        update.rotation = t.document.rotation;
        foundry.utils.mergeObject(update, t.document.getFlag("dnd5e", "previousTokenData"));
        delete update.x;
        delete update.y;
        return update;
      });
      await canvas.scene.updateEmbeddedDocuments("Token", tokenUpdates, { diff: false, recursive: false });
    }

    const polymorphedActorIds = previousActorIds.filter(id =>
      id !== original.id // Is not original Actor Id
      && game.actors?.get(id) // Actor still exists
    ).concat([this.id]); // Add this id
    // Delete the polymorphed version(s) of the actor, if possible
    if ( game.user.isGM ) {
      await Actor.implementation.deleteDocuments(polymorphedActorIds);
    } else {
      // Remove the flags
      const actorUpdates = polymorphedActorIds.filter(id => game.actors.get(id).isOwner).map(p => {
        return { _id: p, "flags.dnd5e": { "-=isPolymorphed": null, "-=previousActorIds": null } };
      });
      await Actor.implementation.updateDocuments(actorUpdates);

      if ( isRendered ) this.sheet?.close();
    }
    if ( !foundry.utils.isEmpty(update) ) await original.update(update, { dnd5e: { concentrationCheck: false } });
    if ( isRendered && options.renderSheet ) original.sheet?.render(isRendered);
    return original;
  }

  /* -------------------------------------------- */

  /**
   * Add additional system-specific sidebar directory context menu options for Actor documents
   * @param {ApplicationV2} app   The application being displayed.
   * @param {Array} entryOptions  The default array of context menu options
   */
  static addDirectoryContextOptions(app, entryOptions) {
    if ( app instanceof foundry.applications.sidebar.apps.Compendium ) return;
    entryOptions.push({
      name: "DND5E.TRANSFORM.Action.Restore",
      icon: '<i class="fa-solid fa-backward"></i>',
      callback: li => {
        const actor = game.actors.get(li.dataset.documentId ?? li.dataset.entryId);
        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.dataset.documentId ?? li.dataset.entryId);
        return actor && actor.isPolymorphed;
      },
      group: "system"
    }, {
      name: "DND5E.Group.Primary.Set",
      icon: '<i class="fa-solid fa-star"></i>',
      callback: li => {
        game.settings.set("dnd5e", "primaryParty", { actor: game.actors.get(li.dataset.documentId ?? li.dataset.entryId) });
      },
      condition: li => {
        const actor = game.actors.get(li.dataset.documentId ?? li.dataset.entryId);
        const primary = game.actors.party;
        return game.user.isGM && (actor?.type === "group") && (actor !== primary);
      },
      group: "system"
    }, {
      name: "DND5E.Group.Primary.Remove",
      icon: '<i class="fa-regular fa-star"></i>',
      callback: li => {
        game.settings.set("dnd5e", "primaryParty", { actor: null });
      },
      condition: li => {
        const actor = game.actors.get(li.dataset.documentId ?? li.dataset.entryId);
        return game.user.isGM && (actor === game.actors.party);
      },
      group: "system"
    });
  }

  /* -------------------------------------------- */

  /**
   * Add class to actor entry representing the primary group.
   * @param {HTMLElement} html
   */
  static onRenderActorDirectory(html) {
    const primaryParty = game.actors.party;
    if ( primaryParty ) {
      const element = html?.querySelector(`[data-entry-id="${primaryParty.id}"]`);
      element?.classList.add("primary-party");
    }
  }

  /* -------------------------------------------- */

  /**
   * 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 if ( typeData.value in CONFIG.DND5E.creatureTypes ) {
      const code = CONFIG.DND5E.creatureTypes[typeData.value];
      localizedType = game.i18n.localize(typeData.swarm ? code.plural : code.label);
    }
    let type = localizedType;
    if ( typeData.swarm ) {
      type = game.i18n.format("DND5E.CreatureSwarmPhrase", {
        size: game.i18n.localize(CONFIG.DND5E.actorSizes[typeData.swarm].label),
        type: localizedType
      });
    }
    if (typeData.subtype) type = `${type} (${typeData.subtype})`;
    return type;
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @override */
  static async createDialog(data={}, createOptions={}, dialogOptions={}) {
    CreateDocumentDialog.migrateOptions(createOptions, dialogOptions);
    return CreateDocumentDialog.prompt(this, data, createOptions, dialogOptions);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onUpdate(data, options, userId) {
    super._onUpdate(data, options, userId);

    const isHpUpdate = !!data.system?.attributes?.hp;

    if ( userId === game.userId ) {
      if ( isHpUpdate ) await this.updateBloodied(options);
      await this.updateEncumbrance(options);
      this._onUpdateExhaustion(data, options);
    }

    const hp = options.dnd5e?.hp;
    if ( isHpUpdate && hp && !options.isRest && !options.isAdvancement ) {
      const curr = this.system.attributes.hp;
      const changes = {
        hp: curr.value - hp.value,
        temp: curr.temp - hp.temp
      };
      changes.total = changes.hp + changes.temp;

      if ( Number.isInteger(changes.total) && (changes.total !== 0) ) {
        this._displayTokenEffect(changes);
        if ( !game.settings.get("dnd5e", "disableConcentration") && (userId === game.userId)
          && (options.dnd5e?.concentrationCheck !== false)
          && (changes.total < 0) && ((changes.temp < 0) || (curr.value < curr.effectiveMax)) ) {
          this.challengeConcentration({ dc: this.getConcentrationDC(-changes.total) });
        }

        /**
         * A hook event that fires when an actor is damaged or healed by any means. The actual name
         * of the hook will depend on the change in hit points.
         * @function dnd5e.damageActor
         * @memberof hookEvents
         * @param {Actor5e} actor                                       The actor that had their hit points reduced.
         * @param {{hp: number, temp: number, total: number}} changes   The changes to hit points.
         * @param {object} update                                       The original update delta.
         * @param {string} userId                                       Id of the user that performed the update.
         */
        Hooks.callAll(`dnd5e.${changes.total > 0 ? "heal" : "damage"}Actor`, this, changes, data, userId);
      }
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDelete(options, userId) {
    // Remove any group sheet apps so they aren't also closed.
    for ( const id in this.apps ) {
      const app = this.apps[id];
      if ( app instanceof dnd5e.applications.actor.GroupActorSheet ) delete this.apps[id];
    }

    super._onDelete(options, userId);

    const origin = this.getFlag("dnd5e", "summon.origin");
    if ( origin ) {
      const { collection, primaryId } = foundry.utils.parseUuid(origin);
      dnd5e.registry.summons.untrack(collection?.get?.(primaryId)?.uuid, this.uuid);
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onCreateDescendantDocuments(parent, collection, documents, data, options, userId) {
    if ( userId === game.userId ) {
      if ( (collection === "effects") && documents.find(d => d.id === ActiveEffect5e.ID.EXHAUSTION)
        && !this._source.system.attributes?.exhaustion ) {
        await this.update({ "system.attributes.exhaustion": 1 });
      }
      if ( collection === "items" ) await this.updateEncumbrance(options);
    }
    super._onCreateDescendantDocuments(parent, collection, documents, data, options, userId);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId) {
    if ( (userId === game.userId) && (collection === "items") ) await this.updateEncumbrance(options);
    super._onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId) {
    if ( userId === game.userId ) {
      if ( (collection === "effects") && ids.includes(ActiveEffect5e.ID.EXHAUSTION) ) {
        await this.update({ "system.attributes.exhaustion": 0 });
      }
      if ( collection === "items" ) await this.updateEncumbrance(options);
      await this._clearFavorites(documents);
    }
    super._onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId);
  }

  /* -------------------------------------------- */

  /**
   * Flash ring & display changes to health as scrolling combat text.
   * @param {object} changes          Object of changes to hit points.
   * @param {number} changes.hp       Changes to `hp.value`.
   * @param {number} changes.temp     The change to `hp.temp`.
   * @param {number} changes.total    The total change to hit points.
   * @protected
   */
  _displayTokenEffect(changes) {
    let key;
    let value;
    if ( changes.hp < 0 ) {
      key = "damage";
      value = changes.total;
    } else if ( changes.hp > 0 ) {
      key = "healing";
      value = changes.total;
    } else if ( changes.temp ) {
      key = "temp";
      value = changes.temp;
    }
    if ( !key || !value ) return;

    const tokens = this.isToken ? [this.token] : this.getActiveTokens(true, true);
    if ( !tokens.length ) return;

    const pct = Math.clamp(Math.abs(value) / this.system.attributes.hp.max, 0, 1);
    const fill = CONFIG.DND5E.tokenHPColors[key];

    for ( const token of tokens ) {
      if ( !token.object?.visible || token.isSecret ) continue;
      if ( token.hasDynamicRing ) token.flashRing(key);
      const t = token.object;
      canvas.interface.createScrollingText(t.center, value.signedString(), {
        anchor: CONST.TEXT_ANCHOR_POINTS.TOP,
        // Adapt the font size relative to the Actor's HP total to emphasize more significant blows
        fontSize: 16 + (32 * pct), // Range between [16, 48]
        fill: fill,
        stroke: 0x000000,
        strokeThickness: 4,
        jitter: 0.25
      });
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async toggleStatusEffect(statusId, options) {
    const created = await super.toggleStatusEffect(statusId, options);
    const status = CONFIG.statusEffects.find(e => e.id === statusId);
    if ( !(created instanceof ActiveEffect) || !status.exclusiveGroup ) return created;

    const others = CONFIG.statusEffects
      .filter(e => (e.id !== statusId) && (e.exclusiveGroup === status.exclusiveGroup) && this.effects.has(e._id));
    if ( others.length ) await this.deleteEmbeddedDocuments("ActiveEffect", others.map(e => e._id));

    return created;
  }

  /* -------------------------------------------- */

  /**
   * TODO: Perform this as part of Actor._preUpdateOperation instead when it becomes available in v12.
   * Handle syncing the Actor's exhaustion level with the ActiveEffect.
   * @param {object} data                          The Actor's update delta.
   * @param {DocumentModificationContext} options  Additional options supplied with the update.
   * @returns {Promise<ActiveEffect|void>}
   * @protected
   */
  async _onUpdateExhaustion(data, options) {
    const level = foundry.utils.getProperty(data, "system.attributes.exhaustion");
    if ( !Number.isFinite(level) ) return;
    let effect = this.effects.get(ActiveEffect5e.ID.EXHAUSTION);
    if ( level < 1 ) return effect?.delete();
    else if ( effect ) {
      const originalExhaustion = foundry.utils.getProperty(options, "dnd5e.originalExhaustion");
      return effect.update({ "flags.dnd5e.exhaustionLevel": level }, { dnd5e: { originalExhaustion } });
    } else {
      effect = await ActiveEffect.implementation.fromStatusEffect("exhaustion", { parent: this });
      effect.updateSource({ "flags.dnd5e.exhaustionLevel": level });
      return ActiveEffect.implementation.create(effect, { parent: this, keepId: true });
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle applying/removing the bloodied status.
   * @param {DocumentModificationContext} options  Additional options supplied with the update.
   * @returns {Promise<ActiveEffect>|void}
   */
  updateBloodied(options) {
    const hp = this.system.attributes?.hp;
    if ( !hp?.effectiveMax || (game.settings.get("dnd5e", "bloodied") === "none") ) return;

    const effect = this.effects.get(ActiveEffect5e.ID.BLOODIED);
    if ( hp.value > hp.effectiveMax * CONFIG.DND5E.bloodied.threshold ) return effect?.delete();
    if ( effect ) return;

    return ActiveEffect.implementation.create({
      _id: ActiveEffect5e.ID.BLOODIED,
      name: game.i18n.localize(CONFIG.DND5E.bloodied.name),
      img: CONFIG.DND5E.bloodied.img,
      statuses: ["bloodied"]
    }, { parent: this, keepId: true });
  }

  /* -------------------------------------------- */

  /**
   * Handle applying/removing encumbrance statuses.
   * @param {DocumentModificationContext} options  Additional options supplied with the update.
   * @returns {Promise<ActiveEffect>|void}
   */
  updateEncumbrance(options) {
    const encumbrance = this.system.attributes?.encumbrance;
    if ( !encumbrance || (game.settings.get("dnd5e", "encumbrance") === "none") ) return;
    const statuses = [];
    const variant = game.settings.get("dnd5e", "encumbrance") === "variant";
    if ( encumbrance.value > encumbrance.thresholds.maximum ) statuses.push("exceedingCarryingCapacity");
    if ( (encumbrance.value > encumbrance.thresholds.heavilyEncumbered) && variant ) statuses.push("heavilyEncumbered");
    if ( (encumbrance.value > encumbrance.thresholds.encumbered) && variant ) statuses.push("encumbered");

    const effect = this.effects.get(ActiveEffect5e.ID.ENCUMBERED);
    if ( !statuses.length ) return effect?.delete();

    const effectData = { ...CONFIG.DND5E.encumbrance.effects[statuses[0]], statuses };
    if ( effect ) {
      const originalEncumbrance = effect.statuses.first();
      return effect.update(effectData, { dnd5e: { originalEncumbrance } });
    }

    return ActiveEffect.implementation.create(
      { _id: ActiveEffect5e.ID.ENCUMBERED, ...effectData },
      { parent: this, keepId: true }
    );
  }

  /* -------------------------------------------- */

  /**
   * Handle clearing favorited entries that were deleted.
   * @param {Document[]} documents  The deleted Documents.
   * @returns {Promise<Actor5e>|void}
   * @protected
   */
  _clearFavorites(documents) {
    if ( !("favorites" in this.system) ) return;
    const ids = new Set(documents.map(d => d.getRelativeUUID(this)));
    const favorites = this.system.favorites.filter(f => !ids.has(f.id));
    return this.update({ "system.favorites": favorites });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static getDefaultArtwork(actorData={}) {
    const img = CONFIG.DND5E.defaultArtwork.Actor[actorData.type];
    return img ? { img, texture: { src: img } } : super.getDefaultArtwork(actorData);
  }

  /* -------------------------------------------- */
  /*  Deprecations                                */
  /* -------------------------------------------- */

  /**
   * @deprecated since 5.1
   * @ignore
   */
  static computeLeveledProgression(progression, actor, cls, spellcasting, count) {
    foundry.utils.logCompatibilityWarning("Actor5e.computeLeveledProgression is deprecated. Please use "
      + "SpellcastingModel#computeProgression instead.", { since: "DnD5e 5.1", until: "DnD5e 5.4" });
    CONFIG.DND5E.spellcasting.spell.computeProgression(progression, actor, cls, spellcasting, count);
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since 5.1
   * @ignore
   */
  static computePactProgression(progression, actor, cls, spellcasting, count) {
    foundry.utils.logCompatibilityWarning("Actor5e.computePactProgression is deprecated. Please use "
      + "SpellcastingModel#computeProgression instead.", { since: "DnD5e 5.1", until: "DnD5e 5.4" });
    CONFIG.DND5E.spellcasting.pact.computeProgression(progression, actor, cls, spellcasting, count);
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since 5.1
   * @ignore
   */
  static prepareAltSlots(spells, actor, progression, key, table) {
    foundry.utils.logCompatibilityWarning("Actor.prepareAltSlots is deprecated. Please use "
      + "SpellcastingModel#prepareSlots instead.", { since: "DnD5e 5.1", until: "DnD5e 5.4" });
    const model = CONFIG.DND5E.spellcasting[key];
    if ( !model ) return;
    if ( table ) model.table = table;
    model.prepareSlots(spells, actor, progression);
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since 5.1
   * @ignore
   */
  static prepareLeveledSlots(spells, actor, progression) {
    foundry.utils.logCompatibilityWarning("Actor.prepareLeveledSlots is deprecated. Please use "
      + "SpellcastingModel#prepareSlots instead.", { since: "DnD5e 5.1", until: "DnD5e 5.4" });
    CONFIG.DND5E.spellcasting.spell.prepareSlots(spells, actor, progression);
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since 5.1
   * @ignore
   */
  static preparePactSlots(spells, actor, progression) {
    foundry.utils.logCompatibilityWarning("Actor.preparePactSlots is deprecated. Please use "
      + "SpellcastingModel#prepareSlots instead.", { since: "DnD5e 5.1", until: "DnD5e 5.4" });
    CONFIG.DND5E.spellcasting.pact.prepareSlots(spells, actor, progression);
  }
}

/* -------------------------------------------- */

/**
 * @extends {Map<string, Set<Item5e>>}
 */
class IdentifiedItemsMap extends Map {
  /** @inheritDoc */
  get(key, { type }={}) {
    if ( !key ) return;
    if ( key.includes(":") && !type ) [type, key] = key.split(":", 2);
    const result = super.get(key);
    if ( !result?.size || !type ) return result;
    return result.filter(i => i.type === type);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  set(key, value) {
    if ( !this.has(key) ) super.set(key, new Set());
    this.get(key).add(value);
    return this;
  }
}

/* -------------------------------------------- */

/**
 * @extends {Map<string, Set<Item5e>>}
 */
class SourcedItemsMap extends Map {
  /** @inheritDoc */
  get(key, { remap=true }={}) {
    if ( !key ) return;
    if ( remap ) ({ uuid: key } = foundry.utils.parseUuid(key) ?? {});
    return super.get(key);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  set(key, value) {
    const { uuid } = foundry.utils.parseUuid(key);
    if ( !this.has(uuid) ) super.set(uuid, new Set());
    this.get(uuid, { remap: false }).add(value);
    return this;
  }

  /* -------------------------------------------- */

  /**
   * Adjust keys once compendium UUID redirects have been initialized.
   */
  _redirectKeys() {
    for ( const [key, value] of this.entries() ) {
      const { uuid } = foundry.utils.parseUuid(key);
      if ( key !== uuid ) {
        this.set(uuid, value);
        this.delete(key);
      }
    }
  }
}

/**
 * Configuration application for ability score improvements.
 */
class AbilityScoreImprovementConfig extends AdvancementConfig$1 {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["ability-score-improvement"],
    actions: {
      decrease: AbilityScoreImprovementConfig.#adjustValue,
      increase: AbilityScoreImprovementConfig.#adjustValue,
      lock: AbilityScoreImprovementConfig.#lockValue
    }
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  static PARTS = {
    ...super.PARTS,
    details: {
      template: "systems/dnd5e/templates/advancement/ability-score-improvement-config-details.hbs"
    },
    scores: {
      template: "systems/dnd5e/templates/advancement/ability-score-improvement-config-scores.hbs",
      templates: ["systems/dnd5e/templates/advancement/parts/advancement-ability-score-control-v2.hbs"]
    }
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);

    context.abilities = Object.entries(CONFIG.DND5E.abilities).reduce((obj, [key, data]) => {
      if ( !this.advancement.canImprove(key) ) return obj;
      const fixed = this.advancement.configuration.fixed[key] ?? 0;
      const locked = this.advancement.configuration.locked.has(key);
      obj[key] = {
        key,
        name: `configuration.fixed.${key}`,
        label: data.label,
        locked: {
          value: locked,
          hint: `DND5E.ADVANCEMENT.AbilityScoreImprovement.FIELDS.locked.${locked ? "locked" : "unlocked"}`
        },
        value: fixed,
        canIncrease: true,
        canDecrease: true
      };
      return obj;
    }, {});

    context.points = {
      key: "points",
      name: "configuration.points",
      label: game.i18n.localize("DND5E.ADVANCEMENT.AbilityScoreImprovement.FIELDS.points.label"),
      min: 0,
      value: this.advancement.configuration.points,
      canIncrease: true,
      canDecrease: this.advancement.configuration.points > 0
    };

    return context;
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle clicking the plus and minus buttons.
   * @this {AbilityScoreImprovementConfig}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static #adjustValue(event, target) {
    const action = target.dataset.action;
    const input = target.closest("li").querySelector("input[type=number]");

    if ( action === "decrease" ) input.valueAsNumber -= 1;
    else if ( action === "increase" ) input.valueAsNumber += 1;

    this.submit();
  }

  /* -------------------------------------------- */

  /**
   * Handle locking or unlocking an ability.
   * @this {AbilityScoreImprovementConfig}
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   */
  static #lockValue(event, target) {
    const parent = target.closest("[data-score]");
    const { score } = parent.dataset;
    const input = parent.querySelector(`[name="configuration.locked.${score}"]`);
    input.value = input.value === "true" ? "false" : "true";
    this.submit();
  }

  /* -------------------------------------------- */
  /*  Form Handling                               */
  /* -------------------------------------------- */

  /** @override */
  async prepareConfigurationUpdate(configuration) {
    configuration.locked = Object.entries(configuration.locked).reduce((arr, [k, v]) => {
      if ( v ) arr.push(k);
      return arr;
    }, []);
    return configuration;
  }
}

/**
 * Inline application that presents the player with a choice between ability score improvement and taking a feat.
 */
class AbilityScoreImprovementFlow extends AdvancementFlow {

  /**
   * Player assignments to abilities.
   * @type {Object<string, number>}
   */
  assignments = {};

  /* -------------------------------------------- */

  /**
   * The dropped feat item.
   * @type {Item5e}
   */
  feat;

  /* -------------------------------------------- */

  /** @inheritDoc */
  static _customElements = super._customElements.concat(["dnd5e-checkbox"]);

  /* -------------------------------------------- */

  /** @inheritDoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      dragDrop: [{ dropSelector: "form" }],
      template: "systems/dnd5e/templates/advancement/ability-score-improvement-flow.hbs"
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async retainData(data) {
    await super.retainData(data);
    this.assignments = this.retainedData.assignments ?? {};
    const featUuid = Object.values(this.retainedData.feat ?? {})[0];
    if ( featUuid ) this.feat = await fromUuid(featUuid);
    else if ( !foundry.utils.isEmpty(this.assignments) ) this.feat = { isASI: true };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getData() {
    const points = {
      assigned: Object.keys(CONFIG.DND5E.abilities).reduce((assigned, key) => {
        if ( !this.advancement.canImprove(key) || this.advancement.configuration.locked.has(key) ) return assigned;
        return assigned + (this.assignments[key] ?? 0);
      }, 0),
      cap: this.advancement.configuration.cap ?? Infinity,
      total: this.advancement.configuration.points
    };
    points.available = points.total - points.assigned;

    const formatter = new Intl.NumberFormat(game.i18n.lang, { signDisplay: "always" });

    const lockImprovement = this.feat && !this.feat.isASI;
    const abilities = Object.entries(CONFIG.DND5E.abilities).reduce((obj, [key, data]) => {
      if ( !this.advancement.canImprove(key) ) return obj;
      const ability = this.advancement.actor.system.abilities[key];
      const assignment = this.assignments[key] ?? 0;
      const fixed = this.advancement.configuration.fixed[key] ?? 0;
      const locked = this.advancement.configuration.locked.has(key);
      const abilityMax = Math.max(ability.max, this.advancement.configuration.max ?? -Infinity);
      const value = Math.min(ability.value + fixed + assignment, abilityMax);
      const max = locked ? value : Math.min(value + points.available, abilityMax);
      const min = Math.min(ability.value + fixed, abilityMax);
      obj[key] = {
        key, max, min, value,
        name: `abilities.${key}`,
        label: data.label,
        initial: ability.value + fixed,
        delta: (value - ability.value) ? formatter.format(value - ability.value) : null,
        showDelta: true,
        isDisabled: lockImprovement,
        isLocked: !!locked || (ability.value >= abilityMax),
        canIncrease: (value < max) && ((fixed + assignment) < points.cap) && !locked && !lockImprovement,
        canDecrease: (value > (ability.value + fixed)) && !locked && !lockImprovement
      };
      return obj;
    }, {});

    let recommendation = this.advancement.isEpicBoon ? fromUuidSync(this.advancement.configuration.recommendation)
      : null;
    if ( recommendation ) {
      const { img, name, uuid } = recommendation;
      recommendation = {
        img, name, uuid,
        checked: this.feat?.uuid === uuid,
        locked: this.feat && (this.feat.uuid !== uuid)
      };
    }

    const modernRules = game.settings.get("dnd5e", "rulesVersion") === "modern";
    const pluralRules = new Intl.PluralRules(game.i18n.lang);
    return foundry.utils.mergeObject(super.getData(), {
      abilities, lockImprovement, points, recommendation,
      feat: this.feat,
      pointCap: game.i18n.format(
        `DND5E.ADVANCEMENT.AbilityScoreImprovement.CapDisplay.${pluralRules.select(points.cap)}`, { points: points.cap }
      ),
      pointsRemaining: game.i18n.format(
        `DND5E.ADVANCEMENT.AbilityScoreImprovement.PointsRemaining.${pluralRules.select(points.available)}`,
        {points: points.available}
      ),
      showASIFeat: modernRules && this.advancement.allowFeat,
      showImprovement: !modernRules || !this.advancement.allowFeat || this.feat?.isASI,
      staticIncrease: !this.advancement.configuration.points
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  activateListeners(html) {
    super.activateListeners(html);
    html.find(".adjustment-button").click(this._onClickButton.bind(this));
    html.find("[data-action='browse']").click(this._onBrowseCompendium.bind(this));
    html.find("[data-action='delete']").click(this._onItemDelete.bind(this));
    html.find("[data-action='viewItem']").click(this._onClickFeature.bind(this));
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onChangeInput(event) {
    super._onChangeInput(event);
    const input = event.currentTarget;
    if ( input.name === "asi-selected" ) {
      if ( input.checked ) this.feat = { isASI: true };
      else {
        if ( this.feat?.isASI ) this.assignments = {};
        this.feat = null;
      }
    } else if ( input.name === "recommendation-selected" ) {
      if ( input.checked ) this.feat = await fromUuid(this.advancement.configuration.recommendation);
      else this.feat = null;
    } else {
      const key = input.closest("[data-score]").dataset.score;
      if ( isNaN(input.valueAsNumber) ) this.assignments[key] = 0;
      else {
        this.assignments[key] = Math.min(
          Math.clamp(input.valueAsNumber, Number(input.min), Number(input.max)) - Number(input.dataset.initial),
          (this.advancement.configuration.cap - (this.advancement.configuration.fixed[key] ?? 0)) ?? Infinity
        );
      }
    }
    this.render();
  }

  /* -------------------------------------------- */

  /**
   * Handle opening the compendium browser and displaying the result.
   * @param {PointerEvent} event  The triggering event.
   * @protected
   */
  async _onBrowseCompendium(event) {
    event.preventDefault();
    const filters = {
      locked: {
        additional: { category: { feat: 1 } },
        types: new Set(["feat"])
      }
    };
    const result = await CompendiumBrowser.selectOne({ filters, tab: "feats" });
    if ( !result ) return;

    // TODO: Remove this unnecessary check when https://github.com/foundryvtt/dnd5e/issues/5139 is implemented
    const item = await fromUuid(result);
    const isValid = item.system.validatePrerequisites?.(this.advancement.actor, { showMessage: true });
    if ( isValid === true ) {
      this.feat = item;
      this.render();
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle clicking the plus and minus buttons.
   * @param {Event} event  Triggering click event.
   */
  _onClickButton(event) {
    event.preventDefault();
    const action = event.currentTarget.dataset.action;
    const key = event.currentTarget.closest("li").dataset.score;

    this.assignments[key] ??= 0;
    if ( action === "decrease" ) this.assignments[key] -= 1;
    else if ( action === "increase" ) this.assignments[key] += 1;
    else return;

    this.render();
  }

  /* -------------------------------------------- */

  /**
   * Handle clicking on a feature during item grant to preview the feature.
   * @param {MouseEvent} event  The triggering event.
   * @protected
   */
  async _onClickFeature(event) {
    event.preventDefault();
    const uuid = event.target.closest("[data-uuid]")?.dataset.uuid;
    const item = await fromUuid(uuid);
    item?.sheet.render(true);
  }

  /* -------------------------------------------- */

  /**
   * Handle deleting a dropped feat.
   * @param {Event} event  The originating click event.
   * @protected
   */
  async _onItemDelete(event) {
    event.preventDefault();
    this.feat = null;
    this.render();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _updateObject(event, formData) {
    await this.advancement.apply(this.level, {
      type: (this.feat && !this.feat.isASI) ? "feat" : "asi",
      assignments: this.assignments,
      featUuid: this.feat?.uuid,
      retainedItems: this.retainedData?.retainedItems
    });
  }

  /* -------------------------------------------- */
  /*  Drag & Drop                                 */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onDrop(event) {
    if ( !this.advancement.allowFeat ) return false;

    // Try to extract the data
    let data;
    try {
      data = JSON.parse(event.dataTransfer.getData("text/plain"));
    } catch(err) {
      return false;
    }

    if ( data.type !== "Item" ) return false;
    const item = await Item.implementation.fromDropData(data);

    if ( (item.type !== "feat") || (item.system.type.value !== "feat") ) {
      ui.notifications.error("DND5E.ADVANCEMENT.AbilityScoreImprovement.Warning.Type", {localize: true});
      return null;
    }

    const isValid = item.system.validatePrerequisites?.(this.advancement.actor, { showMessage: true });
    if ( isValid !== true ) return null;

    this.feat = item;
    this.render();
  }
}

const { DocumentUUIDField: DocumentUUIDField$5, NumberField: NumberField$s, SetField: SetField$n, StringField: StringField$H } = foundry.data.fields;

/**
 * @import {
 *   AbilityScoreImprovementAdvancementConfigurationData, AbilityScoreImprovementAdvancementValueData
 * } from "./_types.mjs";
 */

/**
 * Data model for the Ability Score Improvement advancement configuration.
 * @extends {foundry.abstract.DataModel<AbilityScoreImprovementAdvancementConfigurationData>}
 * @mixes AbilityScoreImprovementAdvancementConfigurationData
 */
class AbilityScoreImprovementConfigurationData extends foundry.abstract.DataModel {

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.ADVANCEMENT.AbilityScoreImprovement"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return {
      cap: new NumberField$s({ integer: true, min: 1, initial: 2 }),
      fixed: new MappingField(new NumberField$s({ nullable: false, integer: true, initial: 0 })),
      locked: new SetField$n(new StringField$H()),
      max: new NumberField$s({ integer: true, min: 1 }),
      points: new NumberField$s({ integer: true, min: 0, initial: 0 }),
      recommendation: new DocumentUUIDField$5({ type: "Item" })
    };
  }
}

/**
 * Data model for the Ability Score Improvement advancement value.
 * @extends {SparseDataModel<AbilityScoreImprovementAdvancementValueData>}
 * @mixes AbilityScoreImprovementAdvancementValueData
 */
class AbilityScoreImprovementValueData extends SparseDataModel$1 {
  /** @inheritDoc */
  static defineSchema() {
    return {
      type: new StringField$H({ required: true, initial: "asi", choices: ["asi", "feat"] }),
      assignments: new MappingField(new NumberField$s({
        nullable: false, integer: true
      }), { required: false, initial: undefined }),
      feat: new MappingField(new StringField$H(), { required: false, initial: undefined, label: "DND5E.Feature.Feat" })
    };
  }
}

/**
 * Advancement that presents the player with the option of improving their ability scores or selecting a feat.
 */
class AbilityScoreImprovementAdvancement extends Advancement {

  /** @inheritDoc */
  static get metadata() {
    return foundry.utils.mergeObject(super.metadata, {
      dataModels: {
        configuration: AbilityScoreImprovementConfigurationData,
        value: AbilityScoreImprovementValueData
      },
      order: 20,
      icon: "icons/magic/symbols/star-solid-gold.webp",
      typeIcon: "systems/dnd5e/icons/svg/ability-score-improvement.svg",
      title: game.i18n.localize("DND5E.ADVANCEMENT.AbilityScoreImprovement.Title"),
      hint: game.i18n.localize("DND5E.ADVANCEMENT.AbilityScoreImprovement.Hint"),
      apps: {
        config: AbilityScoreImprovementConfig,
        flow: AbilityScoreImprovementFlow
      }
    });
  }

  /* -------------------------------------------- */

  /**
   * Level above which any ASI will be considered an Epic Boon when using the modern rules.
   * @type {number}
   */
  static EPIC_BOON_LEVEL = 19;

  /* -------------------------------------------- */
  /*  Preparation Methods                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  get _defaultTitle() {
    if ( this.isEpicBoon ) return game.i18n.localize("DND5E.ADVANCEMENT.AbilityScoreImprovement.TitleEpic");
    return super._defaultTitle;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _preCreate(data) {
    if ( super._preCreate(data) === false ) return false;
    if ( this.item.type !== "class" || foundry.utils.hasProperty(data, "configuration.points") ) return;
    this.updateSource({ "configuration.points": 2 });
  }

  /* -------------------------------------------- */
  /*  Instance Properties                         */
  /* -------------------------------------------- */

  /**
   * Does this advancement allow feats, or just ability score improvements?
   * @type {boolean}
   */
  get allowFeat() {
    return (this.item.type === "class") && (game.settings.get("dnd5e", "allowFeats")
      || game.settings.get("dnd5e", "rulesVersion") === "modern");
  }

  /* -------------------------------------------- */

  /**
   * Should this be considered an epic boon feat?
   * @type {boolean}
   */
  get isEpicBoon() {
    return (this.level >= AbilityScoreImprovementAdvancement.EPIC_BOON_LEVEL)
      && (this.item.type === "class")
      && (this.item.system.source?.rules ? (this.item.system.source.rules === "2024")
        : (game.settings.get("dnd5e", "rulesVersion") === "modern"));
  }

  /* -------------------------------------------- */

  /**
   * Information on the ASI points available.
   * @type {{ assigned: number, total: number }}
   */
  get points() {
    return {
      assigned: Object.entries(this.value.assignments ?? {}).reduce((n, [abl, c]) => {
        if ( this.canImprove(abl) ) n += c;
        return n;
      }, 0),
      total: this.configuration.points + Object.entries(this.configuration.fixed).reduce((t, [abl, v]) => {
        if ( this.canImprove(abl) ) t += v;
        return t;
      }, 0)
    };
  }

  /* -------------------------------------------- */
  /*  Instance Methods                            */
  /* -------------------------------------------- */

  /**
   * Is this ability allowed to be improved?
   * @param {string} ability  The ability key.
   * @returns {boolean}
   */
  canImprove(ability) {
    return CONFIG.DND5E.abilities[ability]?.improvement !== false;
  }

  /* -------------------------------------------- */
  /*  Display Methods                             */
  /* -------------------------------------------- */

  /** @inheritDoc */
  titleForLevel(level, { configMode=false }={}) {
    if ( this.value.selected !== "feat" ) return this.title;
    return game.i18n.localize("DND5E.Feature.Feat");
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  summaryForLevel(level, { configMode=false }={}) {
    const formatter = new Intl.NumberFormat(game.i18n.lang, { signDisplay: "always" });
    if ( configMode && this.isEpicBoon ) {
      return dnd5e.utils.linkForUuid(this.configuration.recommendation);
    }

    else if ( configMode ) {
      const entries = Object.entries(this.configuration.fixed).map(([key, value]) => {
        if ( !value ) return null;
        const name = CONFIG.DND5E.abilities[key]?.label ?? key;
        return `<span class="tag">${name} <strong>${formatter.format(value)}</strong></span>`;
      });
      if ( this.configuration.points ) entries.push(`<span class="tag">${
        game.i18n.localize("DND5E.ADVANCEMENT.AbilityScoreImprovement.FIELDS.points.label")}: <strong>${
        this.configuration.points}</strong></span>`
      );
      return entries.filterJoin("\n");
    }

    else if ( (this.value.type === "feat") && this.value.feat ) {
      const id = Object.keys(this.value.feat)[0];
      const feat = this.actor.items.get(id);
      if ( feat ) return feat.toAnchor({classes: ["content-link"]}).outerHTML;
    }

    else if ( (this.value.type === "asi") && this.value.assignments ) {
      return Object.entries(this.value.assignments).reduce((html, [key, value]) => {
        const name = CONFIG.DND5E.abilities[key]?.label ?? key;
        html += `<span class="tag">${name} <strong>${formatter.format(value)}</strong></span>\n`;
        return html;
      }, "");
    }

    return "";
  }

  /* -------------------------------------------- */
  /*  Application Methods                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async apply(level, data) {
    if ( data.type === "asi" ) {
      const assignments = Object.keys(CONFIG.DND5E.abilities).reduce((obj, key) => {
        obj[key] = (this.configuration.fixed[key] ?? 0) + (data.assignments[key] ?? 0);
        return obj;
      }, {});
      const updates = {};
      for ( const key of Object.keys(assignments) ) {
        const ability = this.actor.system.abilities[key];
        const source = this.actor.system.toObject().abilities[key] ?? {};
        if ( !ability || !this.canImprove(key) ) continue;
        const max = Math.max(ability.max, this.configuration.max ?? -Infinity);
        assignments[key] = Math.min(assignments[key], max - source.value);
        if ( assignments[key] ) updates[`system.abilities.${key}.value`] = source.value + assignments[key];
        else delete assignments[key];
      }
      data.assignments = assignments;
      data.feat = null;
      this.actor.updateSource(updates);
    }

    else {
      let itemData = data.retainedItems?.[data.featUuid];
      if ( !itemData ) itemData = await this.createItemData(data.featUuid);
      data.assignments = null;
      if ( itemData ) {
        data.feat = { [itemData._id]: data.featUuid };
        this.actor.updateSource({items: [itemData]});
      }
    }

    delete data.featUuid;
    delete data.retainedItems;
    this.updateSource({value: data});
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  restore(level, data) {
    data.featUuid = Object.values(data.feat ?? {})[0];
    this.apply(level, data);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  reverse(level) {
    const source = this.value.toObject();

    if ( this.value.type === "asi" ) {
      const updates = {};
      for ( const [key, change] of Object.entries(this.value.assignments ?? {}) ) {
        const ability = this.actor.system.toObject().abilities[key];
        if ( !ability || !this.canImprove(key) ) continue;
        updates[`system.abilities.${key}.value`] = ability.value - change;
        source.assignments[key] -= (this.configuration.fixed[key] ?? 0);
      }
      this.actor.updateSource(updates);
    }

    else {
      const [id, uuid] = Object.entries(this.value.feat ?? {})[0] ?? [];
      const item = this.actor.items.get(id);
      if ( item ) source.retainedItems = {[uuid]: item.toObject()};
      this.actor.items.delete(id);
    }

    this.updateSource({ "value.assignments": null, "value.feat": null });
    return source;
  }
}

/**
 * Configuration application for hit points.
 */
let HitPointsConfig$1 = class HitPointsConfig extends AdvancementConfig$1 {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["hit-points"]
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  static PARTS = {
    ...super.PARTS,
    hitPoints: {
      template: "systems/dnd5e/templates/advancement/hit-points-config.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    context.hitDie = this.advancement.hitDie;
    return context;
  }
};

/**
 * 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(".average-checkbox")?.addEventListener("change", event => {
      this.form.querySelector(".roll-result").disabled = event.target.checked;
      this.form.querySelector(".roll-button").disabled = event.target.checked;
      this._updateRollResult();
    });
    this.form.querySelector(".roll-button")?.addEventListener("click", async () => {
      const roll = await this.advancement.actor.rollClassHitPoints(this.advancement.item);
      this.form.querySelector(".roll-result").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.ADVANCEMENT.HitPoints.Warning.${errorType}`));
  }

}

/**
 * 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: "icons/magic/life/heart-pink.webp",
      typeIcon: "systems/dnd5e/icons/svg/hit-points.svg",
      title: game.i18n.localize("DND5E.ADVANCEMENT.HitPoints.Title"),
      hint: game.i18n.localize("DND5E.ADVANCEMENT.HitPoints.Hint"),
      multiLevel: true,
      apps: {
        config: HitPointsConfig$1,
        flow: HitPointsFlow
      }
    });
  }

  /* -------------------------------------------- */
  /*  Instance Properties                         */
  /* -------------------------------------------- */

  /**
   * The amount gained if the average is taken.
   * @type {number}
   */
  get average() {
    return (this.hitDieValue / 2) + 1;
  }

  /* -------------------------------------------- */

  /** @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() {
    if ( this.actor?.type === "npc" ) return `d${this.actor.system.attributes.hd.denomination}`;
    return this.item.system.hd.denomination;
  }

  /* -------------------------------------------- */

  /**
   * 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, legacyDisplay=false }={}) {
    const hp = this.valueForLevel(level);
    if ( !hp || configMode || !legacyDisplay ) 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.defaultAbilities.hitPoints || "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 });
  }

  /* -------------------------------------------- */

  /** @override */
  automaticApplicationValue(level) {
    if ( (level === 1) && this.item.isOriginalClass ) return { [level]: "max" };
    if ( this.value[level - 1] === "avg" ) return { [level]: "avg" };
    return false;
  }

  /* -------------------------------------------- */

  /** @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 choices.
 */
class ItemChoiceConfig extends AdvancementConfig$1 {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["item-choice", "three-column"],
    dropKeyPath: "pool",
    position: {
      width: 800
    }
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  static PARTS = {
    config: {
      container: { classes: ["column-container"], id: "column-left" },
      template: "systems/dnd5e/templates/advancement/advancement-controls-section.hbs"
    },
    details: {
      container: { classes: ["column-container"], id: "column-left" },
      template: "systems/dnd5e/templates/advancement/item-choice-config-details.hbs"
    },
    spellConfig: {
      container: { classes: ["column-container"], id: "column-left" },
      template: "systems/dnd5e/templates/advancement/advancement-spell-config-section.hbs"
    },
    items: {
      container: { classes: ["column-container"], id: "column-center" },
      template: "systems/dnd5e/templates/advancement/item-choice-config-items.hbs"
    },
    levels: {
      container: { classes: ["column-container"], id: "column-right" },
      template: "systems/dnd5e/templates/advancement/item-choice-config-levels.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);

    context.items = this.advancement.configuration.pool.map(data => ({
      data,
      fields: this.advancement.configuration.schema.fields.pool.element.fields,
      index: fromUuidSync(data.uuid)
    }));

    context.abilityOptions = Object.entries(CONFIG.DND5E.abilities).map(([value, { label }]) => ({ value, label }));
    context.choices = context.levels.reduce((obj, { value, label }) => {
      obj[value] = { label, ...this.advancement.configuration.choices[value] };
      return obj;
    }, {});
    context.levelRestrictionOptions = [
      { value: "", label: "" },
      {
        value: "available",
        label: game.i18n.localize("DND5E.ADVANCEMENT.ItemChoice.FIELDS.restriction.level.Available")
      },
      { rule: true },
      ...Object.entries(CONFIG.DND5E.spellLevels).map(([value, label]) => ({ value, label }))
    ];
    context.listRestrictionOptions = dnd5e.registry.spellLists.options;
    context.showContainerWarning = context.items.some(i => i.index?.type === "container");
    context.showSpellConfig = this.advancement.configuration.type === "spell";

    const { spell } = this.advancement.configuration;
    const model = CONFIG.DND5E.spellcasting[spell?.method];
    context.showRequireSpellSlot = !spell?.method || model?.slots;
    context.canPrepare = model?.prepares;
    context.spellcastingMethods = Object.values(CONFIG.DND5E.spellcasting).map(({ key, label }) => {
      return { label, value: key };
    });
    if ( spell?.method && !(spell.method in CONFIG.DND5E.spellcasting) ) {
      context.spellcastingMethods.push({ label: spell.method, value: spell.method });
    }

    context.typeOptions = [
      { value: "", label: game.i18n.localize("DND5E.ADVANCEMENT.ItemChoice.FIELDS.type.Any") },
      { rule: true },
      ...this.advancement.constructor.VALID_TYPES
        .map(value => ({ value, label: game.i18n.localize(CONFIG.Item.typeLabels[value]) }))
    ];

    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: [
          { value: "", label: "" },
          ...Object.entries(CONFIG.DND5E.featureTypes).map(([value, { label }]) => ({ value, label }))
        ],
        subtypeLabel: game.i18n.format("DND5E.ItemFeatureSubtype", {category: selectedType?.label}),
        subtypeOptions: selectedType?.subtypes ? [
          { value: "", label: "" },
          ...Object.entries(selectedType.subtypes).map(([value, label]) => ({ value, label }))
        ] : null
      };
    }

    return context;
  }

  /* -------------------------------------------- */
  /*  Form Handling                               */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async prepareConfigurationUpdate(configuration) {
    if ( configuration.choices ) configuration.choices = this.constructor._cleanedObject(configuration.choices);
    if ( configuration.spell ) configuration.spell.ability ??= [];
    if ( configuration.pool ) configuration.pool = Object.values(configuration.pool);

    // Ensure items are still valid if type restriction or spell restriction are changed
    const pool = [];
    for ( const item of (configuration.pool ?? this.advancement.configuration.pool) ) {
      if ( this.advancement._validateItemType(await fromUuid(item.uuid), {
        type: false, strict: false
      }) ) pool.push(item);
    }
    configuration.pool = pool;

    return configuration;
  }

  /* -------------------------------------------- */
  /*  Drag & Drop                                 */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _validateDroppedItem(event, item) {
    this.advancement._validateItemType(item, { type: false });
  }
}

/**
 * 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;
    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: config.items.map(i => {
        const item = foundry.utils.deepClone(fromUuidSync(i.uuid));
        if ( !item ) return null;
        item.checked = added ? checked.has(item.uuid) : (config.optional && !i.optional);
        item.optional = config.optional || i.optional;
        return item;
      }, []).filter(i => i),
      abilities: this.getSelectAbilities()
    };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getData(options={}) {
    return foundry.utils.mergeObject(super.getData(options), await this.getContext());
  }

  /* -------------------------------------------- */

  /**
   * Get the context information for selected spell abilities.
   * @returns {object}
   */
  getSelectAbilities() {
    const config = this.advancement.configuration;
    return {
      options: config.spell?.ability.size > 1 ? config.spell.ability.reduce((obj, k) => {
        obj[k] = CONFIG.DND5E.abilities[k]?.label;
        return obj;
      }, {}) : null,
      selected: this.ability ?? this.retainedData?.ability ?? this.advancement.value.ability
        ?? config.spell?.ability.first()
    };
  }

  /* -------------------------------------------- */

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

/**
 * Inline application that presents the player with a choice of items.
 */
class ItemChoiceFlow extends ItemGrantFlow {

  /**
   * Currently selected ability.
   * @type {string}
   */
  ability;

  /**
   * Set of selected UUIDs.
   * @type {Set<string>}
   */
  selected;

  /**
   * Cached items from the advancement's pool.
   * @type {Item5e[]}
   */
  pool;

  /**
   * ID of item to be replaced.
   * @type {string}
   */
  replacement;

  /**
   * List of dropped items.
   * @type {Item5e[]}
   */
  dropped;

  /* -------------------------------------------- */

  /**
   * Level that will be used to evaluate feature prerequisites.
   * @type {number}
   */
  get featureLevel() {
    return this.level || this.advancement.actor.system.details?.level;
  }

  /* -------------------------------------------- */

  /** @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 retainData(data) {
    await super.retainData(data);
    this.replacement = data.replaced?.original;
    this.selected = new Set(data.items.map(i => foundry.utils.getProperty(i, "flags.dnd5e.sourceId")));
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getContext() {
    const context = {};
    this.selected ??= new Set(Object.values(this.advancement.value.added?.[this.level] ?? {}));
    this.pool ??= (await Promise.all(this.advancement.configuration.pool.map(i => fromUuid(i.uuid)))).filter(_ => _);
    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 levelConfig = this.advancement.configuration.choices[this.level];
    let max = levelConfig.count ?? 0;
    context.replaceable = levelConfig.replacement;
    context.noReplacement = !this.advancement.actor.items.has(this.replacement);
    if ( context.replaceable && !context.noReplacement ) max++;
    if ( this.selected.size > max ) {
      const [kept, lost] = Array.from(Array.from(this.selected).entries()).reduce(([kept, lost], [index, value]) => {
        if ( index < max ) kept.push(value);
        else lost.push(value);
        return [kept, lost];
      }, [[], []]);
      this.selected = new Set(kept);
      this.dropped = this.dropped.filter(i => !lost.includes(i.uuid));
    }
    context.choices = { max, current: this.selected.size, full: this.selected.size >= max };

    context.previousLevels = {};
    const previouslySelected = new Set();
    // FIXME: We should not offer options for replacement if replacing the item would render the character ineligible
    // for items they had already picked *at earlier levels*. Becoming ineligible for an item that is pending addition
    // at this level is already handled by _evaluatePrerequisites.
    for ( const level of Array.fromRange(this.level) ) {
      const added = this.advancement.value.added[level];
      if ( added ) context.previousLevels[level] = Object.entries(added).map(([id, uuid]) => {
        const item = fromUuidSync(uuid);
        previouslySelected.add(uuid);
        return {
          ...item, id, uuid,
          checked: id === this.replacement,
          replaced: false
        };
      });
      const replaced = this.advancement.value.replaced[level];
      if ( replaced ) {
        const match = context.previousLevels[replaced.level].find(v => v.id === replaced.original);
        if ( match ) {
          match.replaced = true;
          previouslySelected.delete(match.uuid);
        }
      }
    }

    const spellLevel = this.advancement.configuration.restriction.level;
    const maxSlot = this._maxSpellSlotLevel();
    const validateSpellLevel = (this.advancement.configuration.type === "spell") && (spellLevel === "available");
    const replaced = this.advancement.actor.items.get(this.replacement);
    const removed = replaced ? [replaced] : [];
    const added = [...this.dropped, ...this.pool.filter(item => this.selected.has(item.uuid))];

    context.items = [...this.pool, ...this.dropped].reduce((items, i) => {
      if ( i ) {
        i.checked = this.selected.has(i.uuid);
        i.disabled = !i.checked && context.choices.full;
        const validFeature = !i.system.validatePrerequisites || (i.system.validatePrerequisites(
          this.advancement.actor, { added, removed, level: this.featureLevel }
        ) === true);
        const validSpell = !validateSpellLevel || (i.system.level <= maxSlot);
        if ( validFeature && validSpell ) items.push(i);
      }
      return items;
    }, []);

    context.abilities = this.getSelectAbilities();
    context.abilities.disabled = previouslySelected.size;
    this.ability ??= context.abilities.selected;

    if ( this.advancement.configuration.type ) {
      let type = game.i18n.localize(CONFIG.Item.typeLabels[this.advancement.configuration.type]);
      if ( (this.advancement.configuration.type === "feat") && this.advancement.configuration.restriction.type ) {
        const typeConfig = CONFIG.DND5E.featureTypes[this.advancement.configuration.restriction.type];
        const subtype = typeConfig.subtypes?.[this.advancement.configuration.restriction.subtype];
        if ( subtype ) type = subtype;
        else type = typeConfig.label;
      }
      context.selectLabel = game.i18n.format("DND5E.ADVANCEMENT.ItemChoice.Action.SelectSpecific", { type });
    } else {
      context.selectLabel = game.i18n.localize("DND5E.ADVANCEMENT.ItemChoice.Action.SelectGeneric");
    }

    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  activateListeners(html) {
    super.activateListeners(html);
    html.find('[data-action="browse"]').click(this._onBrowseCompendium.bind(this));
    html.find('[data-action="delete"]').click(this._onItemDelete.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * Handle opening the compendium browser and displaying the result.
   * @param {Event} event  The originating click event.
   * @protected
   */
  async _onBrowseCompendium(event) {
    event.preventDefault();

    // Determine how many items can be selected
    const config = this.advancement.configuration;
    let max = config.choices[this.level].count ?? 0;
    if ( config.choices[this.level].replacement && this.advancement.actor.items.has(this.replacement) ) max++;
    const current = this.selected.size;
    if ( current >= max ) {
      ui.notifications.warn("DND5E.ADVANCEMENT.ItemChoice.Warning.MaxSelected", { localize: true });
      return;
    }

    const filters = { locked: { additional: {}, documentClass: "Item" } };

    // Apply restrictions based on type
    if ( config.type ) {
      filters.locked.types = new Set([config.type]);
      if ( (config.type === "feat") && config.restriction.type ) {
        const typeConfig = CONFIG.DND5E.featureTypes[config.restriction.type];
        const subtype = typeConfig.subtypes?.[config.restriction.subtype];
        filters.locked.additional.category = { [config.restriction.type]: 1 };
        if ( subtype ) filters.locked.additional.subtype = { [config.restriction.subtype]: 1 };
      }
    } else {
      filters.locked.types = this.advancement.constructor.VALID_TYPES;
    }

    // Apply restrictions based on level
    if ( config.type === "feat" ) {
      filters.locked.arbitrary = [{ k: "system.prerequisites.level", o: "lte", v: this.featureLevel }];
    } else if ( (config.type === "spell") && (config.restriction.level !== "") ) {
      filters.locked.additional.level = {
        min: config.restriction.level === "available" ? undefined : Number(config.restriction.level),
        max: config.restriction.level === "available" ? this._maxSpellSlotLevel() : Number(config.restriction.level)
      };
    }

    // Apply restrictions based on spell list
    if ( (config.type === "spell") && config.restriction.list.size ) {
      filters.locked.additional.spelllist = config.restriction.list.reduce((obj, list) => {
        obj[list] = 1;
        return obj;
      }, {});
    }

    const result = await CompendiumBrowser.select({ filters, selection: { min: 1, max: max - current } });
    if ( !result?.size ) return;

    const items = await Promise.all(Array.from(result).map(uuid => fromUuid(uuid)));
    for ( const item of items ) {
      if ( this.selected.has(item.uuid) ) continue;
      this.selected.add(item.uuid);
      if ( !this.pool.find(i => i.uuid === item.uuid) ) {
        this.dropped.push(item);
        item.dropped = true;
      }
    }

    this._evaluatePrerequisites();
    this.render();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onChangeInput(event) {
    if ( event.target.tagName === "DND5E-CHECKBOX" ) {
      if ( event.target.checked ) this.selected.add(event.target.name);
      else this.selected.delete(event.target.name);
    }
    else if ( event.target.type === "radio" ) this.replacement = event.target.value;
    else if ( event.target.name === "ability" ) this.ability = event.target.value;
    this._evaluatePrerequisites();
    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("[data-uuid]")?.dataset.uuid;
    if ( !uuidToDelete ) return;
    this.dropped.findSplice(i => i.uuid === uuidToDelete);
    this.selected.delete(uuidToDelete);
    this._evaluatePrerequisites();
    this.render();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onDrop(event) {
    const levelConfig = this.advancement.configuration.choices[this.level];
    let max = levelConfig.count ?? 0;
    if ( levelConfig.replacement && this.advancement.actor.items.has(this.replacement) ) max++;
    if ( this.selected.size >= max ) 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) {
      ui.notifications.error(err.message);
      return null;
    }

    // If the item is already been marked as selected, no need to go further
    if ( this.selected.has(item.uuid) ) return false;

    // 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 ) {
        ui.notifications.error(game.i18n.format("DND5E.ADVANCEMENT.ItemChoice.Warning.SpellLevelAvailable", {
          level: CONFIG.DND5E.spellLevels[maxSlot]
        }));
        return null;
      }
    }

    // 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._evaluatePrerequisites();
    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?.type ) {
      const progression = Object.fromEntries(Object.keys(CONFIG.DND5E.spellcasting).map(k => [k, 0]));
      const maxSpellLevel = Object.keys(CONFIG.DND5E.spellLevels).length - 1;
      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;

    return Object.values(spells).reduce((slot, { max, level }) => {
      if ( !max ) return slot;
      return Math.max(slot, level || -1);
    }, 0);
  }

  /* -------------------------------------------- */

  /**
   * Evaluate selected item prerequisites and update state appropriately.
   * @protected
   */
  _evaluatePrerequisites() {
    const replaced = this.advancement.actor.items.get(this.replacement);
    const removed = replaced ? [replaced] : [];
    for ( let i = 0; i < 100; i++ ) {
      const itemsBefore = this.selected.size;
      const added = [...this.dropped, ...this.pool.filter(item => this.selected.has(item.uuid))];
      this.dropped = this.dropped.filter(item => {
        const isValid = item.system.validatePrerequisites?.(this.advancement.actor, {
          added, removed, level: this.featureLevel, showMessage: true
        }) ?? true;
        if ( isValid !== true ) this.selected.delete(item.uuid);
        return isValid === true;
      });
      for ( const item of this.pool ) {
        if ( !this.selected.has(item.uuid) ) continue;
        const isValid = item.system.validatePrerequisites?.(this.advancement.actor, {
          added, removed, level: this.level, showMessage: true
        }) ?? true;
        if ( isValid !== true ) this.selected.delete(item.uuid);
      }
      if ( itemsBefore === this.selected.size ) break;
    }
  }
}

const { BooleanField: BooleanField$p, NumberField: NumberField$r, SchemaField: SchemaField$x, SetField: SetField$m, StringField: StringField$G } = foundry.data.fields;

/**
 * @import { AdvancementSpellConfigurationData } from "./_types.mjs";
 */

/**
 * Embedded data model describing changes to granted spells.
 * @extends {foundry.abstract.DataModel<AdvancementSpellConfigurationData>}
 * @mixes AdvancementSpellConfigurationData
 */
class SpellConfigurationData extends foundry.abstract.DataModel {
  /** @inheritDoc */
  static defineSchema() {
    return {
      ability: new SetField$m(new StringField$G()),
      method: new StringField$G(),
      prepared: new NumberField$r({ required: true, nullable: false, integer: true, min: 0, initial: 0 }),
      uses: new SchemaField$x({
        max: new FormulaField({ deterministic: true }),
        per: new StringField$G(),
        requireSlot: new BooleanField$p()
      })
    };
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The item this advancement data belongs to.
   * @returns {Item5e}
   */
  get item() {
    return this.parent?.parent?.item;
  }

  /* -------------------------------------------- */
  /*  Data Migration                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static migrateData(source) {
    if ( foundry.utils.getType(source.ability) === "string" ) source.ability = source.ability ? [source.ability] : [];
    if ( !("preparation" in source) ) return;
    const { preparation } = source;
    if ( preparation === "always" ) {
      if ( !("method" in source) ) source.method = "spell";
      if ( !("prepared" in source) ) source.prepared = 2;
    } else {
      if ( !("method" in source) ) {
        if ( preparation === "prepared" ) source.method = "spell";
        else if ( preparation ) source.method = preparation;
      }
      if ( !("prepared" in source) ) source.prepared = 0;
    }
    delete source.preparation;
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Apply changes to a spell item based on this spell configuration.
   * @param {object} itemData          Data for the item to modify.
   * @param {object} [config={}]
   * @param {string} [config.ability]  Spellcasting ability selected during advancement process.
   */
  applySpellChanges(itemData, { ability }={}) {
    ability = this.ability.size ? this.ability.has(ability) ? ability : this.ability.first() : null;
    if ( ability ) foundry.utils.setProperty(itemData, "system.ability", ability);

    if ( this.method ) {
      foundry.utils.setProperty(itemData, "system.method", this.method);
      foundry.utils.setProperty(itemData, "system.prepared", this.prepared);
      const model = CONFIG.DND5E.spellcasting[this.method];
      const hasClass = (this.item?.type === "class") || (this.item?.type === "subclass");

      // Set source class.
      if ( model.slots && hasClass ) {
        const identifier = this.item.type === "class" ? this.item.identifier : this.item.system.classIdentifier;
        if ( identifier ) foundry.utils.setProperty(itemData, "system.sourceClass", identifier);
      }
    }

    if ( this.uses.max && this.uses.per ) {
      foundry.utils.setProperty(itemData, "system.uses.max", this.uses.max);
      itemData.system.uses.recovery ??= [];
      itemData.system.uses.recovery.push({ period: this.uses.per, type: "recoverAll" });

      const spellcasting = CONFIG.DND5E.spellcasting[itemData.system.method];
      const createForwardActivity = !this.uses.requireSlot && spellcasting?.slots;

      for ( const activity of Object.values(itemData.system.activities ?? {}) ) {
        if ( !activity.consumption?.spellSlot ) continue;

        // Create a forward activity
        if ( createForwardActivity ) {
          const newActivity = {
            _id: foundry.utils.randomID(),
            type: "forward",
            name: `${activity.name ?? game.i18n.localize(
              CONFIG.DND5E.activityTypes[activity.type]?.documentClass.metadata.title
            )} (${game.i18n.localize("DND5E.ADVANCEMENT.SPELLCONFIG.FreeCasting").toLowerCase()})`,
            sort: (activity.sort ?? 0) + 1,
            activity: {
              id: activity._id
            },
            consumption: {
              targets: [{ type: "itemUses", target: "", value: "1" }]
            }
          };
          foundry.utils.setProperty(itemData, `system.activities.${newActivity._id}`, newActivity);
        }

        // Modify existing activity
        else {
          const activityData = foundry.utils.deepClone(activity);
          activityData.consumption.targets ??= [];
          activityData.consumption.targets.push({ type: "itemUses", target: "", value: "1" });
          foundry.utils.setProperty(itemData, `system.activities.${activityData._id}`, activityData);
        }
      }
    }
  }
}

const {
  ArrayField: ArrayField$c, BooleanField: BooleanField$o, EmbeddedDataField: EmbeddedDataField$3, ForeignDocumentField: ForeignDocumentField$5, NumberField: NumberField$q, SchemaField: SchemaField$w, SetField: SetField$l, StringField: StringField$F
} = foundry.data.fields;

/**
 * @import { ItemChoiceAdvancementConfigurationData, ItemChoiceAdvancementValueData } from "./_types.mjs";
 */

/**
 * Configuration data for Item Choice advancement.
 * @extends {foundry.abstract.DataModel<ItemChoiceAdvancementConfigurationData>}
 * @mixes ItemChoiceAdvancementConfigurationData
 */
class ItemChoiceConfigurationData extends foundry.abstract.DataModel {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.ADVANCEMENT.ItemChoice", "DND5E.ADVANCEMENT.SPELLCONFIG"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return {
      allowDrops: new BooleanField$o({ initial: true }),
      choices: new MappingField(new SchemaField$w({
        count: new NumberField$q({integer: true, min: 0}),
        replacement: new BooleanField$o()
      })),
      pool: new ArrayField$c(new SchemaField$w({ uuid: new StringField$F() })),
      restriction: new SchemaField$w({
        level: new StringField$F(),
        list: new SetField$l(new StringField$F()),
        subtype: new StringField$F(),
        type: new StringField$F()
      }),
      spell: new EmbeddedDataField$3(SpellConfigurationData, { nullable: true, initial: null }),
      type: new StringField$F({ blank: false, nullable: true, initial: null })
    };
  }

  /* -------------------------------------------- */
  /*  Data Migration                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static migrateData(source) {
    if ( "choices" in source ) Object.entries(source.choices).forEach(([k, c]) => {
      if ( foundry.utils.getType(c) === "number" ) source.choices[k] = { count: c };
    });
    if ( "pool" in source ) {
      source.pool = source.pool.map(i => foundry.utils.getType(i) === "string" ? { uuid: i } : i);
    }
    if ( source.spell ) SpellConfigurationData.migrateData(source.spell);
    return source;
  }
}

/**
 * Value data for Item Choice advancement.
 * @extends {foundry.abstract.DataModel<ItemChoiceAdvancementValueData>}
 * @mixes ItemChoiceAdvancementValueData
 */
class ItemChoiceValueData extends foundry.abstract.DataModel {
  /** @inheritDoc */
  static defineSchema() {
    return {
      ability: new StringField$F(),
      added: new MappingField(new MappingField(new StringField$F())),
      replaced: new MappingField(new SchemaField$w({
        level: new NumberField$q({integer: true, min: 0}),
        original: new ForeignDocumentField$5(foundry.documents.BaseItem, {idOnly: true}),
        replacement: new ForeignDocumentField$5(foundry.documents.BaseItem, {idOnly: true})
      }))
    };
  }
}

/**
 * Configuration application for item grants.
 */
class ItemGrantConfig extends AdvancementConfig$1 {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["item-grant"],
    dropKeyPath: "items"
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  static PARTS = {
    ...super.PARTS,
    details: {
      template: "systems/dnd5e/templates/advancement/item-grant-config-details.hbs"
    },
    spellConfig: {
      template: "systems/dnd5e/templates/advancement/advancement-spell-config-section.hbs"
    },
    items: {
      template: "systems/dnd5e/templates/advancement/item-grant-config-items.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);

    context.items = this.advancement.configuration.items.map(data => ({
      data,
      fields: this.advancement.configuration.schema.fields.items.element.fields,
      index: fromUuidSync(data.uuid)
    }));

    context.abilityOptions = Object.entries(CONFIG.DND5E.abilities).map(([value, { label }]) => ({ value, label }));
    context.showContainerWarning = context.items.some(i => i.index?.type === "container");
    context.showSpellConfig = context.items.some(i => i.index?.type === "spell");

    const { spell } = this.advancement.configuration;
    const model = CONFIG.DND5E.spellcasting[spell?.method];
    context.showRequireSpellSlot = !spell?.method || model?.slots;
    context.canPrepare = model?.prepares;
    context.spellcastingMethods = Object.values(CONFIG.DND5E.spellcasting).map(({ key, label }) => {
      return { label, value: key };
    });
    if ( spell?.method && !(spell.method in CONFIG.DND5E.spellcasting) ) {
      context.spellcastingMethods.push({ label: spell.method, value: spell.method });
    }

    return context;
  }

  /* -------------------------------------------- */
  /*  Form Handling                               */
  /* -------------------------------------------- */

  /** @override */
  async prepareConfigurationUpdate(configuration) {
    if ( configuration.spell ) configuration.spell.ability ??= [];
    return configuration;
  }

  /* -------------------------------------------- */
  /*  Drag & Drop                                 */
  /* -------------------------------------------- */

  /** @override */
  _validateDroppedItem(event, item) {
    this.advancement._validateItemType(item);
  }
}

const { ArrayField: ArrayField$b, BooleanField: BooleanField$n, EmbeddedDataField: EmbeddedDataField$2, SchemaField: SchemaField$v, StringField: StringField$E } = foundry.data.fields;

/**
 * @import { ItemGrantAdvancementConfigurationData } from "./_types.mjs";
 */

/**
 * Configuration data for the Item Grant advancement.
 * @extends {foundry.abstract.DataModel<ItemGrantAdvancementConfigurationData>}
 * @mixes ItemGrantAdvancementConfigurationData
 */
class ItemGrantConfigurationData extends foundry.abstract.DataModel {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.ADVANCEMENT.ItemGrant", "DND5E.ADVANCEMENT.SPELLCONFIG"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return {
      items: new ArrayField$b(new SchemaField$v({
        uuid: new StringField$E(),
        optional: new BooleanField$n()
      }), { required: true }),
      optional: new BooleanField$n({ required: true }),
      spell: new EmbeddedDataField$2(SpellConfigurationData, { required: true, nullable: true, initial: null })
    };
  }

  /* -------------------------------------------- */
  /*  Data Migration                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static migrateData(source) {
    if ( "items" in source ) {
      source.items = source.items.map(i => foundry.utils.getType(i) === "string" ? { uuid: i } : i);
    }
    if ( source.spell ) SpellConfigurationData.migrateData(source.spell);
    return source;
  }
}

/**
 * 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: "icons/sundries/books/book-open-purple.webp",
      typeIcon: "systems/dnd5e/icons/svg/item-grant.svg",
      title: game.i18n.localize("DND5E.ADVANCEMENT.ItemGrant.Title"),
      hint: game.i18n.localize("DND5E.ADVANCEMENT.ItemGrant.Hint"),
      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", "container", "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.filter(i => fromUuidSync(i.uuid))
      .reduce((html, i) => html + dnd5e.utils.linkForUuid(i.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.
   * @returns {object}
   */
  async apply(level, data, retainedData={}) {
    const items = [];
    const updates = {};
    for ( const uuid of filteredKeys(data) ) {
      let itemData = retainedData[uuid];
      if ( !itemData ) {
        itemData = await this.createItemData(uuid);
        if ( !itemData ) continue;
      }
      if ( itemData.type === "spell" ) this.configuration.spell?.applySpellChanges(itemData, {
        ability: data.ability ?? this.retainedData?.ability ?? this.value?.ability
      });

      items.push(itemData);
      updates[itemData._id] = uuid;
    }
    if ( items.length ) {
      this.actor.updateSource({ items });
      this.updateSource({
        "value.ability": data.ability,
        [this.storagePath(level)]: updates
      });
    }
    return updates;
  }

  /* -------------------------------------------- */

  /** @override */
  automaticApplicationValue(level) {
    if ( this.configuration.optional
      || (this.configuration.spell?.ability?.size > 1)
      || this.configuration.items.some(i => i.optional) ) return false;
    return Object.fromEntries(this.configuration.items.map(({ uuid }) => [uuid, true]));
  }

  /* -------------------------------------------- */

  /** @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({
      "value.ability": data.ability,
      [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 { ability: this.value?.ability, 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 {Error}                        An error if the item is invalid and strict is `true`.
   */
  _validateItemType(item, { strict=true }={}) {
    if ( !item ) return false;
    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;
  }
}

/**
 * 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,
        value: ItemChoiceValueData
      },
      order: 50,
      icon: "icons/magic/symbols/cog-orange-red.webp",
      typeIcon: "systems/dnd5e/icons/svg/item-choice.svg",
      title: game.i18n.localize("DND5E.ADVANCEMENT.ItemChoice.Title"),
      hint: game.i18n.localize("DND5E.ADVANCEMENT.ItemChoice.Hint"),
      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) || !this.configuration.choices[level]?.count;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  titleForLevel(level, { configMode=false }={}) {
    const data = this.configuration.choices[level] ?? {};
    let tag;
    if ( data.count ) tag = game.i18n.format("DND5E.ADVANCEMENT.ItemChoice.Choose", { count: data.count });
    else if ( data.replacement ) tag = game.i18n.localize("DND5E.ADVANCEMENT.ItemChoice.Replacement.Title");
    else return this.title;
    return `${this.title} <em>(${tag})</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                         */
  /* -------------------------------------------- */

  /** @override */
  storagePath(level) {
    return `value.added.${level}`;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async apply(level, { replace: original, ...data }, retainedData={}) {
    let replacement;
    if ( retainedData.replaced ) ({ original, replacement } = retainedData.replaced);

    const updates = await super.apply(level, data, retainedData);

    replacement ??= Object.keys(updates).pop();
    if ( original && replacement ) {
      const replacedLevel = Object.entries(this.value.added).reverse().reduce((level, [l, v]) => {
        if ( (original in v) && (Number(l) > level) ) return Number(l);
        return level;
      }, 0);
      if ( Number.isFinite(replacedLevel) ) {
        this.actor.items.delete(original);
        this.updateSource({ [`value.replaced.${level}`]: { level: replacedLevel, original, replacement } });
      }
    }
  }

  /* -------------------------------------------- */

  /** @override */
  automaticApplicationValue(level) {
    return false;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  restore(level, data) {
    const original = this.actor.items.get(data.replaced?.original);
    if ( data.replaced && !original ) data.items = data.items.filter(i => i._id !== data.replaced.replacement);

    super.restore(level, data);

    if ( data.replaced ) {
      if ( !original ) {
        throw new ItemChoiceAdvancement.ERROR(game.i18n.localize("DND5E.ADVANCEMENT.ItemChoice.Warning.NoOriginal"));
      }
      this.actor.items.delete(data.replaced.original);
      this.updateSource({ [`value.replaced.${level}`]: data.replaced });
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async reverse(level) {
    const retainedData = await super.reverse(level);

    const replaced = retainedData.replaced = this.value.replaced[level];
    if ( replaced ) {
      const uuid = this.value.added[replaced.level][replaced.original];
      const itemData = await this.createItemData(uuid, replaced.original);
      if ( itemData ) {
        if ( itemData.type === "spell" ) {
          foundry.utils.mergeObject(itemData, this.configuration.spell?.spellChanges ?? {});
        }
        this.actor.updateSource({ items: [itemData] });
        this.updateSource({ [`value.replaced.-=${level}`]: null });
      }
    }

    return retainedData;
  }

  /* -------------------------------------------- */

  /**
   * 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|false} [config.type]    Type restriction on this advancement, or `false` to not validate type.
   * @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 {Error}                        An error if the item is invalid and strict is `true`.
   */
  _validateItemType(item, { type, restriction, strict=true }={}) {
    if ( !item ) return false;
    super._validateItemType(item, { strict });
    type ??= this.configuration.type;
    restriction ??= this.configuration.restriction;

    const handleError = (localizationKey, data) => {
      if ( strict ) throw new Error(game.i18n.format(localizationKey, data));
      return false;
    };

    // Type restriction is set and the item type does not match the selected type
    if ( type && (type !== item.type) ) {
      type = game.i18n.localize(CONFIG.Item.typeLabels[restriction]);
      return handleError("DND5E.ADVANCEMENT.ItemChoice.Warning.InvalidType", { type: typeLabel });
    }

    // 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 ) return handleError("DND5E.ADVANCEMENT.ItemChoice.Warning.InvalidType", { type: errorLabel });
    }

    // 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];
      return handleError("DND5E.ADVANCEMENT.ItemChoice.Warning.SpellLevelSpecific", { level });
    }

    // If spell list is specified, ensure the spell is on that list
    if ( (type === "spell") && restriction.list.size ) {
      const lists = Array.from(restriction.list)
        .map(l => dnd5e.registry.spellLists.forType(l))
        .filter(_ => _);
      if ( !lists.some(l => l.has(item)) ) return handleError("DND5E.ADVANCEMENT.ItemChoice.Warning.SpellList", {
        lists: game.i18n.getListFormatter({ type: "disjunction" }).format(lists.map(l => l.name))
      });
    }

    return true;
  }
}

const { BooleanField: BooleanField$m, NumberField: NumberField$p, SchemaField: SchemaField$u, SetField: SetField$k, StringField: StringField$D } = foundry.data.fields;

/**
 * @import {
 *   ScaleValueAdvancementConfigurationData, ScaleValueDiceTypeData, ScaleValueNumberTypeData,
 *   ScaleValueStringTypeData, ScaleValueTypeMetadata
 * } from "./_types.mjs";
 */

/**
 * Data model for the Scale Value advancement type.
 * @extends {foundry.abstract.DataModel<ScaleValueAdvancementConfigurationData>}
 * @mixes ScaleValueAdvancementConfigurationData
 */
class ScaleValueConfigurationData extends foundry.abstract.DataModel {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.ADVANCEMENT.ScaleValue"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return {
      identifier: new IdentifierField({ required: true }),
      type: new StringField$D({ required: true, initial: "string", choices: TYPES }),
      distance: new SchemaField$u({ units: new StringField$D({ required: true }) }),
      scale: new MappingField(new ScaleValueEntryField(), { required: true })
    };
  }

  /* -------------------------------------------- */
  /*  Data Migration                              */
  /* -------------------------------------------- */

  /** @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, options={}) {
    const cls = TYPES[model.type];
    if ( !value || !cls ) return value;
    return new cls(value, {parent: model, ...options});
  }

  /* -------------------------------------------- */

  /** @override */
  toObject(value) {
    return value.toObject(false);
  }
}


/**
 * Base scale value data type that stores generic string values.
 * @extends {foundry.abstract.DataModel<ScaleValueStringTypeData>}
 * @mixes ScaleValueStringTypeData
 */
class ScaleValueType extends foundry.abstract.DataModel {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.ADVANCEMENT.ScaleValue.Type.String"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return {
      value: new StringField$D({ required: true })
    };
  }

  /* -------------------------------------------- */

  /**
   * Configuration information for this scale value type.
   * @type {ScaleValueTypeMetadata}
   */
  static get metadata() {
    return {
      label: "DND5E.ADVANCEMENT.ScaleValue.Type.String.Label",
      hint: "DND5E.ADVANCEMENT.ScaleValue.Type.String.Hint",
      identifier: "DND5E.ADVANCEMENT.ScaleValue.Type.String.Identifier",
      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);
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * 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;
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Retrieve field data with associated values.
   * @param {number} level                Level for which this data is being prepared.
   * @param {ScaleValueType} [value]      Value for the field at this level.
   * @param {ScaleValueType} [lastValue]  Previous value used to generate placeholders.
   * @returns {Record<string, object>}
   */
  static getFields(level, value, lastValue) {
    const fields = {};
    for ( const [name, field] of Object.entries(this.schema.fields) ) {
      if ( field.options.hidden ) continue;
      fields[name] = {
        field,
        input: field instanceof BooleanField$m ? createCheckboxInput : null,
        name: `configuration.scale.${level}.${name}`,
        placeholder: this.getPlaceholder(name, lastValue),
        value: value?.[name]
      };
    }
    return fields;
  }

  /* -------------------------------------------- */

  /**
   * Create a placeholder value for the provided field.
   * @param {string} name                 Name of the field.
   * @param {ScaleValueType} [lastValue]  Scale value from a lower level.
   * @returns {string}
   */
  static getPlaceholder(name, lastValue) {
    return lastValue?.[name] ?? "";
  }
}


/**
 * Scale value data type that stores numeric values.
 * @extends {ScaleValueType<ScaleValueNumberTypeData>}
 * @mixes ScaleValueNumberTypeData
 */
class ScaleValueTypeNumber extends ScaleValueType {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return {
      value: new NumberField$p({ required: true })
    };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static get metadata() {
    return foundry.utils.mergeObject(super.metadata, {
      label: "DND5E.ADVANCEMENT.ScaleValue.Type.Number.Label",
      hint: "DND5E.ADVANCEMENT.ScaleValue.Type.Number.Hint",
      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.
 * @extends {ScaleValueType<ScaleValueNumberTypeData>}
 * @mixes ScaleValueNumberTypeData
 */
class ScaleValueTypeCR extends ScaleValueTypeNumber {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.ADVANCEMENT.ScaleValue.Type.CR"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return {
      value: new NumberField$p({ required: true, min: 0 })
      // TODO: Add CR validator
    };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static get metadata() {
    return foundry.utils.mergeObject(super.metadata, {
      label: "DND5E.ADVANCEMENT.ScaleValue.Type.CR.Label",
      hint: "DND5E.ADVANCEMENT.ScaleValue.Type.CR.Hint"
    });
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  get display() {
    switch ( this.value ) {
      case 0.125: return "&frac18;";
      case 0.25: return "&frac14;";
      case 0.5: return "&frac12;";
      default: return super.display;
    }
  }
}


/**
 * Scale value data type that stores dice values.
 * @extends {ScaleValueType<ScaleValueDiceTypeData>}
 * @mixes ScaleValueDiceTypeData
 */
class ScaleValueTypeDice extends ScaleValueType {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.ADVANCEMENT.ScaleValue.Type.Dice"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return {
      number: new NumberField$p({ nullable: true, integer: true }),
      faces: new NumberField$p({ required: true, integer: true }),
      modifiers: new SetField$k(new StringField$D({ required: true }), { hidden: true })
    };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static get metadata() {
    return foundry.utils.mergeObject(super.metadata, {
      label: "DND5E.ADVANCEMENT.ScaleValue.Type.Dice.Label",
      hint: "DND5E.ADVANCEMENT.ScaleValue.Type.Dice.Hint",
      identifier: "DND5E.ADVANCEMENT.ScaleValue.Type.Dice.Identifier"
    });
  }

  /* -------------------------------------------- */

  /**
   * 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);
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  get formula() {
    if ( !this.faces ) return null;
    return `${this.number ?? ""}${this.die}`;
  }

  /* -------------------------------------------- */

  /**
   * The entire die, with leading "d" and any modifiers, e.g., "d4" or "d4r1".
   * @type {string}
   */
  get die() {
    if ( !this.faces ) return "";
    return `d${this.faces}${this.mods}`;
  }

  /* -------------------------------------------- */

  /**
   * The die modifiers.
   * @type {string}
   */
  get mods() {
    if ( !this.modifiers ) return "";
    return this.modifiers.reduce((acc, mod) => {
      return acc + (dnd5e.utils.isValidDieModifier(mod) ? mod : "");
    }, "");
  }

  /* -------------------------------------------- */

  /**
   * The die value to be rolled with the leading "d" (e.g. "d4").
   * @type {string}
   */
  get denom() {
    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;
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static getFields(level, value, lastValue) {
    const fields = super.getFields(level, value, lastValue);
    fields.faces.options = [
      { value: "", label: fields.faces.placeholder },
      { rule: true },
      ...this.FACES.map(value => ({ value, label: `d${value}` }))
    ];
    return fields;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static getPlaceholder(name, lastValue) {
    if ( (name === "faces") && lastValue?.faces ) return `d${lastValue.faces}`;
    return super.getPlaceholder(name, lastValue);
  }
}


/**
 * Scale value data type that stores distance values.
 * @extends {ScaleValueType<ScaleValueNumberTypeData>}
 * @mixes ScaleValueNumberTypeData
 */
class ScaleValueTypeDistance extends ScaleValueTypeNumber {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static get metadata() {
    return foundry.utils.mergeObject(super.metadata, {
      label: "DND5E.ADVANCEMENT.ScaleValue.Type.Distance.Label",
      hint: "DND5E.ADVANCEMENT.ScaleValue.Type.Distance.Hint"
    });
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  get display() {
    return formatLength(this.value, 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$1 {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["scale-value"],
    position: {
      width: 540
    }
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  static PARTS = {
    config: {
      container: { classes: ["column-container"], id: "column-left" },
      template: "systems/dnd5e/templates/advancement/advancement-controls-section.hbs"
    },
    details: {
      container: { classes: ["column-container"], id: "column-left" },
      template: "systems/dnd5e/templates/advancement/scale-value-config-details.hbs"
    },
    levels: {
      container: { classes: ["column-container"], id: "column-right" },
      template: "systems/dnd5e/templates/advancement/scale-value-config-levels.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    const config = this.advancement.configuration;
    const type = TYPES[config.type];

    context.distanceOptions = Object.entries(CONFIG.DND5E.movementUnits)
      .map(([value, { label }]) => ({ value, label }));
    context.identifier = {
      placeholder: config.identifier
        || formatIdentifier(this.advancement.title || this.advancement.constructor.metadata.title)
    };
    context.identifier.hint = game.i18n.format(type.metadata.identifier, {
      class: this.item.identifier, identifier: context.identifier.placeholder
    });
    context.levels = this._prepareLevelData();
    context.type = {
      ...type.metadata,
      fields: type.schema.fields,
      options: Object.entries(TYPES).map(([value, d]) => ({ value, label: game.i18n.localize(d.metadata.label )}))
    };

    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare the data to display at each of the scale levels.
   * @returns {object}
   * @protected
   */
  _prepareLevelData() {
    let lastValue = null;
    let levels = Array.fromRange(CONFIG.DND5E.maxLevel + 1);
    if ( ["class", "subclass"].includes(this.advancement.item.type) ) levels = levels.slice(1);
    return levels.reduce((obj, level) => {
      const value = this.advancement.configuration.scale[level]?.clone();
      obj[level] = {
        fields: TYPES[this.advancement.configuration.type].getFields(level, value, lastValue),
        value: null
      };
      if ( value ) {
        this._mergeScaleValues(value, lastValue);
        obj[level].className = "new-scale-value";
        obj[level].value = value;
        lastValue = value;
      }
      return obj;
    }, {});
  }

  /* -------------------------------------------- */

  /**
   * 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];
    }
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @inheritDoc */
  activateListeners(html) {
    super.activateListeners(html);
    this.form.querySelector("input[name='title']").addEventListener("input", this._onChangeTitle.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * 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 = formatIdentifier(event.target.value || this.advancement.constructor.metadata.title);
    this.form.querySelector("input[name='configuration.identifier']").placeholder = slug;
  }

  /* -------------------------------------------- */
  /*  Form Handling                               */
  /* -------------------------------------------- */

  /** @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 */
  async _processSubmitData(event, submitData) {
    const typeChange = foundry.utils.hasProperty(submitData, "configuration.type");
    if ( typeChange && (submitData.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;
      }, {}));
      submitData.configuration.scale ??= {};
      const OriginalType = TYPES[this.advancement.configuration.type];
      const NewType = TYPES[submitData.configuration.type];
      for ( const [lvl, data] of Object.entries(submitData.configuration.scale) ) {
        const original = new OriginalType(data, { parent: this.advancement, strict: false });
        submitData.configuration.scale[lvl] = NewType.convertFrom(original)?.toObject();
      }
    }
    return super._processSubmitData(event, submitData);
  }

  /* -------------------------------------------- */

  /** @override */
  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;
    }, {});
  }
}

/**
 * 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: "icons/sundries/gaming/dice-pair-white-green.webp",
      typeIcon: "systems/dnd5e/icons/svg/scale-value.svg",
      title: game.i18n.localize("DND5E.ADVANCEMENT.ScaleValue.Title"),
      hint: game.i18n.localize("DND5E.ADVANCEMENT.ScaleValue.Hint"),
      multiLevel: true,
      apps: {
        config: ScaleValueConfig,
        flow: ScaleValueFlow
      }
    });
  }

  /* -------------------------------------------- */

  /**
   * The available types of scaling value.
   * @enum {ScaleValueType}
   */
  static TYPES = TYPES;

  /* -------------------------------------------- */

  /** @inheritDoc */
  static localize() {
    super.localize();
    Object.values(TYPES).forEach(v => foundry.helpers.Localization.localizeDataModel(v));
  }

  /* -------------------------------------------- */
  /*  Instance Properties                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  get levels() {
    return Array.from(Object.keys(this.configuration.scale).map(l => Number(l)))
      .filter(l => !["class", "subclass"].includes(this.item.type) ? true : l !== 0);
  }

  /* -------------------------------------------- */

  /**
   * Identifier for this scale value, either manual value or the slugified title.
   * @type {string}
   */
  get identifier() {
    return this.configuration.identifier || formatIdentifier(this.title);
  }

  /* -------------------------------------------- */
  /*  Display Methods                             */
  /* -------------------------------------------- */

  /** @inheritDoc */
  titleForLevel(level, { configMode=false, legacyDisplay=false }={}) {
    const value = this.valueForLevel(level)?.display;
    if ( !value || !legacyDisplay ) 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;
  }

  /* -------------------------------------------- */
  /*  Application Methods                         */
  /* -------------------------------------------- */

  /** @override */
  automaticApplicationValue(level) {
    return {};
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @inheritDoc*/
  getContextMenuOptions() {
    const options = super.getContextMenuOptions();
    options.push({
      name: "DND5E.ADVANCEMENT.ScaleValue.Action.CopyFormula",
      icon: '<i class="fa-solid fa-copy"></i>',
      callback: () => {
        const value = `@scale.${this.item.identifier}.${this.identifier}`;
        game.clipboard.copyPlainText(value);
        ui.notifications.info(game.i18n.format("DND5E.Copied", { value }), { console: false });
      },
      group: "copy"
    });
    return options;
  }
}

const { BooleanField: BooleanField$l } = foundry.data.fields;

/**
 * Configuration application for size advancement.
 */
class SizeConfig extends AdvancementConfig$1 {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["size"]
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  static PARTS = {
    ...super.PARTS,
    details: {
      template: "systems/dnd5e/templates/advancement/size-config-details.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    return foundry.utils.mergeObject(context, {
      default: {
        hint: this.advancement.automaticHint
      },
      showLevelSelector: false,
      sizes: Object.entries(CONFIG.DND5E.actorSizes).reduce((obj, [key, { label }]) => {
        obj[key] = {
          field: new BooleanField$l({ label }),
          input: context.inputs.createCheckboxInput,
          value: this.advancement.configuration.sizes.has(key)
        };
        return obj;
      }, {})
    });
  }

  /* -------------------------------------------- */
  /*  Form Handling                               */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async prepareConfigurationUpdate(configuration) {
    configuration.sizes = filteredKeys(configuration.sizes ?? {});
    return configuration;
  }
}

/**
 * Inline application that displays size advancement.
 */
class SizeFlow extends AdvancementFlow {

  /** @inheritDoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      template: "systems/dnd5e/templates/advancement/size-flow.hbs"
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  getData() {
    const sizes = this.advancement.configuration.sizes;
    return foundry.utils.mergeObject(super.getData(), {
      singleSize: sizes.size === 1 ? sizes.first() : null,
      hint: this.advancement.hint || this.advancement.automaticHint,
      selectedSize: this.retainedData?.size ?? this.advancement.value.size,
      sizes: Array.from(sizes).reduce((obj, key) => {
        obj[key] = CONFIG.DND5E.actorSizes[key].label;
        return obj;
      }, {})
    });
  }
}

const { SetField: SetField$j, StringField: StringField$C } = foundry.data.fields;

/**
 * @import { SizeAdvancementConfigurationData, SizeAdvancementValueData } from "./_types.mjs";
 */

/**
 * Configuration data for the size advancement type.
 * @extends {foundry.abstract.DataModel<SizeAdvancementConfigurationData>}
 * @mixes SizeAdvancementConfigurationData
 */
class SizeConfigurationData extends foundry.abstract.DataModel {
  /** @inheritDoc */
  static defineSchema() {
    return {
      sizes: new SetField$j(new StringField$C(), { required: false, initial: ["med"], label: "DND5E.Size" })
    };
  }
}

/**
 * Value data for the size advancement type.
 * @extends {foundry.abstract.DataModel<SizeValueData>}
 * @mixes SizeValueData
 */
class SizeValueData extends foundry.abstract.DataModel {
  /** @inheritDoc */
  static defineSchema() {
    return {
      size: new StringField$C({ required: false, label: "DND5E.Size" })
    };
  }
}

/**
 * Advancement that handles player size.
 */
class SizeAdvancement extends Advancement {

  /** @inheritDoc */
  static get metadata() {
    return foundry.utils.mergeObject(super.metadata, {
      dataModels: {
        configuration: SizeConfigurationData,
        value: SizeValueData
      },
      order: 25,
      icon: "icons/environment/wilderness/tree-ash.webp",
      typeIcon: "systems/dnd5e/icons/svg/size.svg",
      title: game.i18n.localize("DND5E.ADVANCEMENT.Size.Title"),
      hint: game.i18n.localize("DND5E.ADVANCEMENT.Size.Hint"),
      apps: {
        config: SizeConfig,
        flow: SizeFlow
      }
    });
  }

  /* -------------------------------------------- */
  /*  Instance Properties                         */
  /* -------------------------------------------- */

  /**
   * Hint that will be displayed to players if none is entered.
   * @type {string}
   */
  get automaticHint() {
    if ( !this.configuration.sizes.size ) return "";
    if ( this.configuration.sizes.size === 1 ) return game.i18n.format("DND5E.ADVANCEMENT.Size.DefaultHint.Single", {
      size: CONFIG.DND5E.actorSizes[this.configuration.sizes.first()].label
    });

    const listFormatter = new Intl.ListFormat(game.i18n.lang, { type: "disjunction" });
    return game.i18n.format("DND5E.ADVANCEMENT.Size.DefaultHint.Multiple", {
      sizes: listFormatter.format(this.configuration.sizes.map(s => CONFIG.DND5E.actorSizes[s].label))
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  get levels() {
    return [0];
  }

  /* -------------------------------------------- */
  /*  Display Methods                             */
  /* -------------------------------------------- */

  /** @inheritDoc */
  summaryForLevel(level, { configMode=false }={}) {
    const sizes = configMode ? Array.from(this.configuration.sizes) : this.value.size ? [this.value.size] : [];
    return sizes.map(s => `<span class="tag">${CONFIG.DND5E.actorSizes[s].label}</span>`).join("");
  }

  /* -------------------------------------------- */
  /*  Editing Methods                             */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static availableForItem(item) {
    return !item.advancement.byType.Size?.length;
  }

  /* -------------------------------------------- */
  /*  Application Methods                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async apply(level, data) {
    this.actor.updateSource({ "system.traits.size": data.size ?? this.configuration.sizes.first() ?? "med" });
    this.updateSource({ value: data });
  }

  /* -------------------------------------------- */

  /** @override */
  automaticApplicationValue(level) {
    if ( this.configuration.sizes > 1 ) return false;
    return this.configuration.sizes.first() ?? "med";
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async restore(level, data) {
    this.apply(level, data);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async reverse(level) {
    this.actor.updateSource({"system.traits.size": "med"});
    this.updateSource({ "value.size": null });
  }
}

/**
 * Inline application that presents the player with a choice of subclass.
 */
class SubclassFlow extends AdvancementFlow {

  /**
   * Cached subclass dropped onto the advancement.
   * @type {Item5e|false}
   */
  subclass;

  /* -------------------------------------------- */

  /** @inheritdoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      dragDrop: [{ dropSelector: "form" }],
      template: "systems/dnd5e/templates/advancement/subclass-flow.hbs"
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async retainData(data) {
    await super.retainData(data);
    const uuid = foundry.utils.getProperty(data, "flags.dnd5e.sourceId");
    if ( uuid ) this.subclass = await fromUuid(uuid);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async getData() {
    const context = await super.getData();
    context.subclass = this.subclass;
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  activateListeners(jQuery) {
    super.activateListeners(jQuery);
    const [html] = jQuery;
    html.querySelector('[data-action="browse"]')?.addEventListener("click", this._onBrowseCompendium.bind(this));
    html.querySelector('[data-action="delete"]')?.addEventListener("click", this._onItemDelete.bind(this));
    html.querySelector("[data-action='viewItem']")?.addEventListener("click", this._onClickFeature.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * Handle opening the compendium browser and displaying the result.
   * @param {Event} event  The originating click event.
   * @protected
   */
  async _onBrowseCompendium(event) {
    event.preventDefault();
    const filters = {
      locked: {
        additional: { class: { [this.item.identifier]: 1 } },
        types: new Set(["subclass"])
      }
    };
    const result = await CompendiumBrowser.selectOne({ filters });
    if ( result ) this.subclass = await fromUuid(result);
    this.render();
  }

  /* -------------------------------------------- */

  /**
   * Handle clicking on a feature during item grant to preview the feature.
   * @param {MouseEvent} event  The triggering event.
   * @protected
   */
  async _onClickFeature(event) {
    event.preventDefault();
    const uuid = event.target.closest("[data-uuid]")?.dataset.uuid;
    const item = await fromUuid(uuid);
    item?.sheet.render(true);
  }

  /* -------------------------------------------- */

  /**
   * Handle deleting a dropped item.
   * @param {Event} event  The originating click event.
   * @protected
   */
  async _onItemDelete(event) {
    event.preventDefault();
    this.subclass = false;
    this.render();
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async _onDrop(event) {
    // 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);

    // Ensure the dropped item is a subclass
    if ( item.type !== "subclass" ) {
      ui.notifications.warn("DND5E.ADVANCEMENT.Subclass.Warning.InvalidType", { localize: true });
      return;
    }

    this.subclass = item;
    this.render();
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async _updateObject(event, formData) {
    if ( this.subclass ) await this.advancement.apply(this.level, { uuid: this.subclass.uuid }, this.retainedData);
  }
}

const { DocumentUUIDField: DocumentUUIDField$4 } = foundry.data.fields;

/**
 * @import { SubclassAdvancementValueData } from "./_types.mjs";
 */

/**
 * Value data for Subclass advancement.
 * @extends {foundry.abstract.DataModel<SubclassAdvancementValueData>}
 * @mixes SubclassAdvancementValueData
 */
class SubclassValueData extends foundry.abstract.DataModel {
  /** @override */
  static defineSchema() {
    return {
      document: new LocalDocumentField(foundry.documents.BaseItem),
      uuid: new DocumentUUIDField$4({ type: "Item" })
    };
  }
}

/**
 * Advancement that allows the player to select a subclass for their class. Only allowed on class items
 * and can only be taken once.
 */
class SubclassAdvancement extends Advancement {

  /** @inheritDoc */
  static get metadata() {
    return foundry.utils.mergeObject(super.metadata, {
      dataModels: {
        value: SubclassValueData
      },
      order: 70,
      icon: "icons/skills/trades/mining-pickaxe-yellow-blue.webp",
      typeIcon: "systems/dnd5e/icons/svg/subclass.svg",
      title: game.i18n.localize("DND5E.ADVANCEMENT.Subclass.Title"),
      hint: game.i18n.localize("DND5E.ADVANCEMENT.Subclass.Hint"),
      apps: {
        flow: SubclassFlow
      }
    });
  }

  /* -------------------------------------------- */
  /*  Display Methods                             */
  /* -------------------------------------------- */

  /** @inheritdoc */
  configuredForLevel(level) {
    return !foundry.utils.isEmpty(this.value);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  summaryforLevel(level, { configMode=false }={}) {
    const subclass = this.item.subclass;
    if ( configMode || !subclass ) return "";
    return subclass.toAnchor().outerHTML;
  }

  /* -------------------------------------------- */
  /*  Editing Methods                             */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static availableForItem(item) {
    return !item.advancement.byType.Subclass?.length;
  }

  /* -------------------------------------------- */
  /*  Application Methods                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async apply(level, data, retainedData) {
    const useRetained = data.uuid === foundry.utils.getProperty(retainedData, "flags.dnd5e.sourceId");
    let itemData = useRetained ? retainedData : null;
    if ( !itemData ) {
      itemData = await this.createItemData(data.uuid);
      delete itemData.flags?.dnd5e?.advancementOrigin;
      delete itemData.flags?.dnd5e?.advancementRoot;
      foundry.utils.setProperty(itemData, "system.classIdentifier", this.item.identifier);
    }
    if ( itemData ) {
      this.actor.updateSource({ items: [itemData] });
      this.updateSource({ value: { document: itemData._id, uuid: data.uuid } });
    }
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async restore(level, data) {
    if ( !data ) return;
    this.actor.updateSource({ items: [data] });
    this.updateSource({
      value: {
        document: data._id, uuid: data._stats?.compendiumSource ?? data.flags?.dnd5e?.sourceId
      }
    });
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async reverse(level) {
    const item = this.value.document ?? this.item.subclass;
    if ( !item ) return;
    this.actor.items.delete(item.id);
    this.updateSource({ value: { document: null, uuid: null } });
    return item.toObject();
  }
}

var _module$w = /*#__PURE__*/Object.freeze({
  __proto__: null,
  AbilityScoreImprovementAdvancement: AbilityScoreImprovementAdvancement,
  Advancement: Advancement,
  HitPointsAdvancement: HitPointsAdvancement,
  ItemChoiceAdvancement: ItemChoiceAdvancement,
  ItemGrantAdvancement: ItemGrantAdvancement,
  ScaleValueAdvancement: ScaleValueAdvancement,
  SizeAdvancement: SizeAdvancement,
  SubclassAdvancement: SubclassAdvancement,
  TraitAdvancement: TraitAdvancement
});

const { BooleanField: BooleanField$k, SetField: SetField$i, StringField: StringField$B } = foundry.data.fields;

/**
 * @import { TravelPace5e } from "../actor/fields/_types.mjs";
 */

/**
 * Field for storing movement data.
 */
class MovementField extends foundry.data.fields.SchemaField {
  constructor(fields={}, { initialUnits=null, ...options }={}) {
    fields = {
      walk: new FormulaField({ deterministic: true, label: "DND5E.MOVEMENT.Type.Speed", speed: true }),
      burrow: new FormulaField({ deterministic: true, label: "DND5E.MOVEMENT.Type.Burrow", speed: true }),
      climb: new FormulaField({ deterministic: true, label: "DND5E.MOVEMENT.Type.Climb", speed: true }),
      fly: new FormulaField({ deterministic: true, label: "DND5E.MOVEMENT.Type.Fly", speed: true }),
      swim: new FormulaField({ deterministic: true, label: "DND5E.MOVEMENT.Type.Swim", speed: true }),
      bonus: new FormulaField({ deterministic: true, label: "DND5E.MOVEMENT.FIELDS.bonus.label" }),
      special: new StringField$B({ label: "DND5E.MOVEMENT.FIELDS.special.label" }),
      units: new StringField$B({
        required: true, nullable: true, blank: false, initial: initialUnits, label: "DND5E.MOVEMENT.FIELDS.units.label"
      }),
      hover: new BooleanField$k({ required: true, label: "DND5E.MOVEMENT.Hover" }),
      ignoredDifficultTerrain: new SetField$i(new StringField$B(), {
        label: "DND5E.MOVEMENT.FIELDS.ignoredDifficultTerrain.label"
      }),
      ...fields
    };
    Object.entries(fields).forEach(([k, v]) => !v ? delete fields[k] : null);
    super(fields, { label: "DND5E.Movement", ...options });
  }

  /* -------------------------------------------- */

  /**
   * Apply rules for travel pace to the given skill.
   * @param {TravelPace5e} pace  The travel pace.
   * @param {string} skill       The skill.
   * @returns {{ advantage: boolean, disadvantage: boolean }}
   */
  static getTravelPaceMode(pace, skill) {
    foundry.utils.logCompatibilityWarning(
      "The `MovementField#getTravelPaceMode` has been moved to `TravelField#getTravelPaceMode.",
      { since: "DnD5e 5.2", until: "DnD5e 5.4", once: true }
    );
    return dnd5e.dataModels.actor.TravelField.getTravelPaceMode(pace, skill);
  }

  /* -------------------------------------------- */

  /**
   * Prepare movement data.
   * @this {MovementData}
   * @param {DataField} field  The movement field.
   */
  static prepareData(field) {
    foundry.utils.logCompatibilityWarning(
      "The `MovementField#prepareData` is now handled through `TravelField#prepareData`.",
      { since: "DnD5e 5.2", until: "DnD5e 5.4", once: true }
    );
  }
}

const { StringField: StringField$A, NumberField: NumberField$o, SchemaField: SchemaField$t } = foundry.data.fields;

/**
 * Field for storing data for a specific type of roll.
 */
class RollConfigField extends foundry.data.fields.SchemaField {
  constructor({roll={}, ability="", ...fields}={}, options={}) {
    const opts = { initial: null, nullable: true, min: 1, max: 20, integer: true };
    fields = {
      ability: (ability === false) ? null : new StringField$A({
        required: true,
        initial: ability,
        label: "DND5E.AbilityModifier"
      }),
      roll: new SchemaField$t({
        min: new NumberField$o({...opts, label: "DND5E.ROLL.Range.Minimum"}),
        max: new NumberField$o({...opts, label: "DND5E.ROLL.Range.Maximum"}),
        mode: new AdvantageModeField(),
        ...roll
      }),
      ...fields
    };
    Object.entries(fields).forEach(([k, v]) => !v ? delete fields[k] : null);
    super(fields, options);
  }
}

const { NumberField: NumberField$n, StringField: StringField$z } = foundry.data.fields;

/**
 * Field for storing senses data.
 */
class SensesField extends foundry.data.fields.SchemaField {
  constructor(fields={}, { initialUnits=null, ...options }={}) {
    const numberConfig = { required: true, nullable: true, integer: true, min: 0, initial: null };
    fields = {
      darkvision: new NumberField$n({ ...numberConfig, label: "DND5E.SenseDarkvision" }),
      blindsight: new NumberField$n({ ...numberConfig, label: "DND5E.SenseBlindsight" }),
      tremorsense: new NumberField$n({ ...numberConfig, label: "DND5E.SenseTremorsense" }),
      truesight: new NumberField$n({ ...numberConfig, label: "DND5E.SenseTruesight" }),
      units: new StringField$z({
        required: true, nullable: true, blank: false, initial: initialUnits, label: "DND5E.SenseUnits"
      }),
      special: new StringField$z({ required: true, label: "DND5E.SenseSpecial" }),
      ...fields
    };
    Object.entries(fields).forEach(([k, v]) => !v ? delete fields[k] : null);
    super(fields, { label: "DND5E.Senses", ...options });
  }
}

const { NumberField: NumberField$m, SchemaField: SchemaField$s, StringField: StringField$y } = foundry.data.fields;

/**
 * @import { ArmorClassData, AttributesCommonData, AttributesCreatureData, HitPointsData } from "./_types.mjs";
 */

/**
 * Shared contents of the attributes schema between various actor types.
 */
class AttributesFields {
  /**
   * Armor class fields shared between characters, NPCs, and vehicles.
   * @type {ArmorClassData}
   */
  static get armorClass() {
    return {
      calc: new StringField$y({ initial: "default", label: "DND5E.ArmorClassCalculation" }),
      flat: new NumberField$m({ required: true, integer: true, min: 0, label: "DND5E.ArmorClassFlat" }),
      formula: new FormulaField({ deterministic: true, label: "DND5E.ArmorClassFormula" })
    };
  }

  /* -------------------------------------------- */
  /**
   * Hit points fields shared between NPCs, objects, and vehicles.
   * @type {HitPointsData}
   */
  static get hitPoints() {
    return {
      dt: new NumberField$m({ integer: true, min: 0, label: "DND5E.DamageThreshold" }),
      max: new NumberField$m({ nullable: true, integer: true, min: 0, initial: null, label: "DND5E.HitPointsMax" }),
      temp: new NumberField$m({ integer: true, initial: 0, min: 0, label: "DND5E.HitPointsTemp" }),
      tempmax: new NumberField$m({
        integer: true, initial: 0, label: "DND5E.HitPointsTempMax", hint: "DND5E.HitPointsTempMaxHint"
      }),
      value: new NumberField$m({ nullable: true, integer: true, min: 0, initial: null, label: "DND5E.HitPointsCurrent" })
    };
  }

  /* -------------------------------------------- */

  /**
   * Fields shared between characters, NPCs, and vehicles.
   * @type {AttributesCommonData}
   */
  static get common() {
    return {
      ac: new SchemaField$s(this.armorClass, { label: "DND5E.ArmorClass" }),
      init: new RollConfigField({
        ability: "",
        bonus: new FormulaField({ required: true, label: "DND5E.InitiativeBonus" })
      }, { label: "DND5E.Initiative" }),
      movement: new MovementField()
    };
  }

  /* -------------------------------------------- */

  /**
   * Fields shared between characters and NPCs.
   * @type {AttributesCreatureData}
   */
  static get creature() {
    return {
      attunement: new SchemaField$s({
        max: new NumberField$m({
          required: true, nullable: false, integer: true, min: 0, initial: 3, label: "DND5E.AttunementMax"
        })
      }, { label: "DND5E.Attunement" }),
      senses: new SensesField(),
      spellcasting: new StringField$y({ required: true, blank: true, label: "DND5E.SpellAbility" }),
      exhaustion: new NumberField$m({
        required: true, nullable: false, integer: true, min: 0, initial: 0, label: "DND5E.Exhaustion"
      }),
      concentration: new RollConfigField({
        ability: "",
        bonuses: new SchemaField$s({
          save: new FormulaField({ required: true, label: "DND5E.ConcentrationBonus" })
        }),
        limit: new NumberField$m({ integer: true, min: 0, initial: 1, label: "DND5E.ConcentrationLimit" })
      }, { label: "DND5E.Concentration" }),
      loyalty: new SchemaField$s({
        value: new NumberField$m({ integer: true, min: 0, max: 20, label: "DND5E.Loyalty" })
      })
    };
  }

  /* -------------------------------------------- */
  /*  Data Migration                              */
  /* -------------------------------------------- */

  /**
   * 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}`;
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /**
   * Initialize derived AC fields for Active Effects to target.
   * @this {CharacterData|NPCData|VehicleData}
   */
  static prepareBaseArmorClass() {
    const ac = this.attributes.ac;
    ac.armor = 10;
    ac.shield = ac.cover = 0;
    ac.min = ac.bonus = "";
  }

  /* -------------------------------------------- */

  /**
   * Initialize base encumbrance fields to be targeted by active effects.
   * @this {CharacterData|NPCData|VehicleData}
   */
  static prepareBaseEncumbrance() {
    const encumbrance = this.attributes.encumbrance ??= {};
    encumbrance.multipliers = { encumbered: "1", heavilyEncumbered: "1", maximum: "1", overall: "1" };
    encumbrance.bonuses = { encumbered: "", heavilyEncumbered: "", maximum: "", overall: "" };
  }

  /* -------------------------------------------- */

  /**
   * Prepare a character's AC value from their equipped armor and shield.
   * @this {CharacterData|NPCData|VehicleData}
   * @param {object} rollData  The Actor's roll data.
   */
  static prepareArmorClass(rollData) {
    const ac = this.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 { armors, shields } = this.parent.itemTypes.equipment.reduce((obj, equip) => {
      if ( !equip.system.equipped || !(equip.system.type.value in CONFIG.DND5E.armorTypes)) return obj;
      if ( equip.system.type.value === "shield" ) obj.shields.push(equip);
      else obj.armors.push(equip);
      return obj;
    }, { armors: [], shields: [] });

    // Set stealth disadvantage
    if ( armors[0]?.system.properties.has("stealthDisadvantage") ) {
      AdvantageModeField.setMode(this, "skills.ste.roll.mode", -1);
    }

    ac.label = !["custom", "flat"].includes(ac.calc) ? CONFIG.DND5E.armorClasses[ac.calc]?.label : null;

    // 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.parent._preparationWarnings.push({
            message: game.i18n.localize("DND5E.WarnMultipleArmor"), type: "warning"
          });
          const armorData = armors[0].system.armor;
          const isHeavy = armors[0].system.type.value === "heavy";
          ac.armor = armorData.value ?? ac.armor;
          ac.dex = isHeavy ? 0 : Math.min(armorData.dex ?? Infinity, this.abilities.dex?.mod ?? 0);
          ac.equippedArmor = armors[0];
        }
        else ac.dex = this.abilities.dex?.mod ?? 0;

        if ( !ac.equippedArmor ) ac.label = null;

        rollData.attributes.ac = ac;
        try {
          const replaced = replaceFormulaData(formula, rollData, {
            actor: this, missing: null, property: game.i18n.localize("DND5E.ArmorClass")
          });
          ac.base = replaced ? new Roll(replaced).evaluateSync().total : 0;
        } catch(err) {
          this.parent._preparationWarnings.push({
            message: game.i18n.format("DND5E.WarnBadACFormula", { formula }), link: "armor", type: "error"
          });
          const replaced = Roll.replaceFormulaData(CONFIG.DND5E.armorClasses.default.formula, rollData);
          ac.base = new Roll(replaced).evaluateSync().total;
        }
        break;
    }

    // Equipped Shield
    if ( shields.length ) {
      if ( shields.length > 1 ) this.parent._preparationWarnings.push({
        message: game.i18n.localize("DND5E.WarnMultipleShields"), type: "warning"
      });
      ac.shield = shields[0].system.armor.value ?? 0;
      ac.equippedShield = shields[0];
    }

    // Compute cover.
    ac.cover = Math.max(ac.cover, this.parent.coverBonus);

    // Compute total AC and return
    ac.min = simplifyBonus(ac.min, rollData);
    ac.bonus = simplifyBonus(ac.bonus, rollData);
    ac.value = Math.max(ac.min, ac.base + ac.shield + ac.bonus + ac.cover);
  }

  /* -------------------------------------------- */

  /**
   * Prepare concentration data for an Actor.
   * @this {CharacterData|NPCData}
   * @param {object} rollData  The Actor's roll data.
   */
  static prepareConcentration(rollData) {
    const { concentration } = this.attributes;
    const abilityId = concentration.ability || CONFIG.DND5E.defaultAbilities.concentration;
    const ability = this.abilities?.[abilityId] || {};
    const bonus = simplifyBonus(concentration.bonuses.save, rollData);
    concentration.save = (ability.save?.value ?? 0) + bonus;
  }

  /* -------------------------------------------- */

  /**
   * Calculate encumbrance details for an Actor.
   * @this {CharacterData|NPCData|VehicleData}
   * @param {object} rollData  The Actor's roll data.
   * @param {object} [options]
   * @param {Function} [options.validateItem]  Determine whether an item's weight should count toward encumbrance.
   */
  static prepareEncumbrance(rollData, { validateItem }={}) {
    const config = CONFIG.DND5E.encumbrance;
    const encumbrance = this.attributes.encumbrance ??= {};
    const baseUnits = CONFIG.DND5E.encumbrance.baseUnits[this.parent.type]
      ?? CONFIG.DND5E.encumbrance.baseUnits.default;
    const unitSystem = game.settings.get("dnd5e", "metricWeightUnits") ? "metric" : "imperial";
    const { attributes } = this;

    // Get the total weight from items
    let weight = this.parent.items
      .filter(item => !item.container && (validateItem?.(item) ?? true))
      .reduce((weight, item) => weight + (item.system.totalWeightIn?.(baseUnits[unitSystem]) ?? 0), 0);

    // [Optional] add Currency Weight (for non-transformed actors)
    const currency = this.currency;
    if ( game.settings.get("dnd5e", "currencyWeight") && currency ) {
      const numCoins = Object.values(currency).reduce((val, denom) => val + Math.max(denom, 0), 0);
      const currencyPerWeight = config.currencyPerWeight[unitSystem];
      weight += convertWeight(
        numCoins / currencyPerWeight,
        config.baseUnits.default[unitSystem],
        baseUnits[unitSystem]
      );
    }

    // Determine the Encumbrance size class
    const keys = Object.keys(CONFIG.DND5E.actorSizes);
    const index = keys.findIndex(k => k === this.traits.size);
    const sizeConfig = CONFIG.DND5E.actorSizes[
      keys[this.parent.flags.dnd5e?.powerfulBuild ? Math.min(index + 1, keys.length - 1) : index]
    ];
    const sizeMod = sizeConfig?.capacityMultiplier ?? sizeConfig?.token ?? 1;
    let maximumMultiplier;

    const calculateThreshold = threshold => {
      let base = this.abilities.str?.value ?? 10;
      const bonus = simplifyBonus(encumbrance.bonuses?.[threshold], rollData)
        + simplifyBonus(encumbrance.bonuses?.overall, rollData);
      let multiplier = simplifyBonus(encumbrance.multipliers[threshold], rollData)
        * simplifyBonus(encumbrance.multipliers.overall, rollData);
      if ( threshold === "maximum" ) maximumMultiplier = multiplier;
      if ( this.parent.type === "vehicle" ) {
        const { cargo } = attributes.capacity;
        base = convertWeight(cargo.value || Infinity, cargo.units, baseUnits[unitSystem]);
      }
      else multiplier *= (config.threshold[threshold]?.[unitSystem] ?? 1) * sizeMod;
      return (base * multiplier).toNearest(0.1) + bonus;
    };

    // Populate final Encumbrance values
    encumbrance.value = weight.toNearest(0.1);
    encumbrance.thresholds = {
      encumbered: calculateThreshold("encumbered"),
      heavilyEncumbered: calculateThreshold("heavilyEncumbered"),
      maximum: calculateThreshold("maximum")
    };
    encumbrance.max = encumbrance.thresholds.maximum;
    encumbrance.mod = (sizeMod * maximumMultiplier).toNearest(0.1);
    encumbrance.stops = {
      encumbered: Number.isFinite(encumbrance.max)
        ? Math.clamp((encumbrance.thresholds.encumbered * 100) / encumbrance.max, 0, 100)
        : 0,
      heavilyEncumbered: Number.isFinite(encumbrance.max)
        ? Math.clamp((encumbrance.thresholds.heavilyEncumbered * 100) / encumbrance.max, 0, 100)
        : 0
    };
    encumbrance.pct = Math.clamp((encumbrance.value * 100) / encumbrance.max, 0, 100);
    encumbrance.encumbered = encumbrance.value > encumbrance.heavilyEncumbered;
  }

  /* -------------------------------------------- */

  /**
   * Adjust exhaustion level based on Active Effects.
   * @this {CharacterData|NPCData}
   */
  static prepareExhaustionLevel() {
    const exhaustion = this.parent.effects.get(ActiveEffect5e.ID.EXHAUSTION);
    const level = exhaustion?.getFlag("dnd5e", "exhaustionLevel");
    this.attributes.exhaustion = Number.isFinite(level) ? level : 0;
  }

  /* -------------------------------------------- */

  /**
   * Calculate maximum hit points, taking an provided advancement into consideration.
   * @param {object} hp                 HP object to calculate.
   * @param {object} [options={}]
   * @param {HitPointsAdvancement[]} [options.advancement=[]]  Advancement items from which to get hit points per-level.
   * @param {number} [options.bonus=0]  Additional bonus to add atop the calculated value.
   * @param {number} [options.mod=0]    Modifier for the ability to add to hit points from advancement.
   * @this {ActorDataModel}
   */
  static prepareHitPoints(hp, { advancement=[], mod=0, bonus=0 }={}) {
    const base = advancement.reduce((total, advancement) => total + advancement.getAdjustedTotal(mod), 0);
    hp.max = (hp.max ?? 0) + base + bonus;
    if ( this.parent.hasConditionEffect("halfHealth") ) hp.max = Math.floor(hp.max * 0.5);

    hp.effectiveMax = Math.max(hp.max + (hp.tempmax ?? 0), 0);
    hp.value = Math.min(hp.value, hp.effectiveMax);
    hp.damage = hp.effectiveMax - hp.value;
    hp.pct = Math.clamp(hp.effectiveMax ? (hp.value / hp.effectiveMax) * 100 : 0, 0, 100);
  }

  /* -------------------------------------------- */

  /**
   * Prepare the initiative data for an actor.
   * @this {CharacterData|NPCData|VehicleData}
   * @param {object} rollData  The Actor's roll data.
   */
  static prepareInitiative(rollData) {
    const init = this.attributes.init ??= {};
    const flags = this.parent.flags.dnd5e ?? {};
    const globalCheckBonus = simplifyBonus(this.bonuses?.abilities?.check, rollData);

    // Compute initiative modifier
    const abilityId = init.ability || CONFIG.DND5E.defaultAbilities.initiative;
    const ability = this.abilities?.[abilityId] || {};
    init.mod = ability.mod ?? 0;

    // Initiative proficiency
    const isLegacy = game.settings.get("dnd5e", "rulesVersion") === "legacy";
    const prof = this.attributes.prof ?? 0;
    const joat = flags.jackOfAllTrades && isLegacy;
    const ra = this.parent._isRemarkableAthlete(abilityId);
    const alert = flags.initiativeAlert && !isLegacy;
    init.prof = new Proficiency(prof, alert ? 1 : (joat || ra) ? 0.5 : 0, !ra);

    // Adjust rolling mode
    if ( (flags.remarkableAthlete && !isLegacy) || this.parent.hasConditionEffect("initiativeAdvantage") ) {
      AdvantageModeField.setMode(this, "attributes.init.roll.mode", 1);
    }
    if ( this.parent.hasConditionEffect("initiativeDisadvantage") ) {
      AdvantageModeField.setMode(this, "attributes.init.roll.mode", -1);
    }

    // Total initiative includes all numeric terms
    const initBonus = simplifyBonus(init.bonus, rollData);
    const abilityBonus = simplifyBonus(ability.bonuses?.check, rollData);
    const quality = this.attributes.quality?.value ?? 0;
    init.total = init.mod + initBonus + abilityBonus + globalCheckBonus + quality
      + (flags.initiativeAlert && isLegacy ? 5 : 0)
      + (Number.isNumeric(init.prof.term) ? init.prof.flat : 0);
    init.score = CONFIG.DND5E.skillPassive.base + init.total + (init.roll.mode * CONFIG.DND5E.skillPassive.modifier);
  }

  /* -------------------------------------------- */

  /**
   * Modify movement speeds taking exhaustion and any other conditions into account.
   * @this {CharacterData|NPCData}
   * @param {object} rollData  The Actor's roll data.
   */
  static prepareMovement(rollData=this.parent.getRollData()) {
    const statuses = this.parent.statuses;
    const noMovement = this.parent.hasConditionEffect("noMovement");
    const crawl = this.parent.hasConditionEffect("crawl");
    for ( const type of Object.keys(CONFIG.DND5E.movementTypes) ) {
      if ( noMovement || (crawl && (type !== "walk")) ) this.attributes.movement[type] = 0;
      else this.attributes.movement[type] = Math.max(0, simplifyBonus(this.attributes.movement[type], rollData));
      if ( type === "walk" ) this.attributes.movement.speed = this.attributes.movement.walk;
    }

    const halfMovement = this.parent.hasConditionEffect("halfMovement");
    const encumbered = statuses.has("encumbered");
    const heavilyEncumbered = statuses.has("heavilyEncumbered");
    const exceedingCarryingCapacity = statuses.has("exceedingCarryingCapacity");
    const units = this.attributes.movement.units ??= defaultUnits("length");
    let reduction = game.settings.get("dnd5e", "rulesVersion") === "modern"
      ? (this.attributes.exhaustion ?? 0) * (CONFIG.DND5E.conditionTypes.exhaustion?.reduction?.speed ?? 0) : 0;
    reduction = convertLength(reduction, CONFIG.DND5E.defaultUnits.length.imperial, units);
    const bonus = simplifyBonus(this.attributes.movement.bonus, rollData);
    this.attributes.movement.max = 0;
    for ( const type of Object.keys(CONFIG.DND5E.movementTypes) ) {
      let speed = Math.max(0, this.attributes.movement[type] - reduction);
      if ( speed ) {
        speed = Math.max(0, speed + bonus);
        if ( halfMovement ) speed *= 0.5;
        if ( heavilyEncumbered ) {
          speed = Math.max(0, speed - (CONFIG.DND5E.encumbrance.speedReduction.heavilyEncumbered[units] ?? 0));
        } else if ( encumbered ) {
          speed = Math.max(0, speed - (CONFIG.DND5E.encumbrance.speedReduction.encumbered[units] ?? 0));
        }
        if ( exceedingCarryingCapacity ) {
          speed = Math.min(speed, CONFIG.DND5E.encumbrance.speedReduction.exceedingCarryingCapacity[units] ?? 0);
        }
      }
      this.attributes.movement[type] = speed;
      this.attributes.movement.max = Math.max(speed, this.attributes.movement.max);
      if ( type === "walk" ) this.attributes.movement.speed = speed;
    }
    const baseSpeed = this._source.attributes.movement.walk || this.attributes.movement.fromSpecies?.walk;
    this.attributes.movement.slowed = this.attributes.movement.walk <= (simplifyBonus(baseSpeed, rollData) / 2);
  }

  /* -------------------------------------------- */

  /**
   * Apply movement and sense changes based on a race item. This method should be called during
   * the `prepareEmbeddedData` step of data preparation.
   * @param {Item5e} race                    Race item from which to get the stats.
   * @param {object} [options={}]
   * @param {boolean} [options.force=false]  Override any values on the actor.
   * @this {CharacterData|NPCData}
   */
  static prepareRace(race, { force=false }={}) {
    for ( const key of Object.keys(CONFIG.DND5E.movementTypes) ) {
      if ( !race.system.movement[key] || (!force && this.attributes.movement[key]) ) continue;
      this.attributes.movement.fromSpecies ??= {};
      this.attributes.movement[key] = this.attributes.movement.fromSpecies[key] = race.system.movement[key];
    }
    if ( race.system.movement.hover ) this.attributes.movement.hover = true;
    if ( force && race.system.movement.units ) this.attributes.movement.units = race.system.movement.units;
    else this.attributes.movement.units ??= race.system.movement.units;

    for ( const key of Object.keys(CONFIG.DND5E.senses) ) {
      if ( !race.system.senses[key] || (!force && (this.attributes.senses[key] !== null)) ) continue;
      this.attributes.senses[key] = race.system.senses[key];
    }
    this.attributes.senses.special = [this.attributes.senses.special, race.system.senses.special].filterJoin(";");
    if ( force && race.system.senses.units ) this.attributes.senses.units = race.system.senses.units;
    else this.attributes.senses.units ??= race.system.senses.units;
  }

  /* -------------------------------------------- */

  /**
   * Prepare spellcasting DC & modifier.
   * @this {CharacterData|NPCData}
   */
  static prepareSpellcastingAbility() {
    const ability = this.abilities?.[this.attributes.spellcasting];
    this.attributes.spell ??= {};
    this.attributes.spell.abilityLabel = CONFIG.DND5E.abilities[this.attributes.spellcasting]?.label ?? "";
    this.attributes.spell.attack = ability ? ability.attack : this.attributes.prof;
    this.attributes.spell.dc = ability ? ability.dc : 8 + this.attributes.prof;
    this.attributes.spell.mod = ability ? ability.mod : 0;
  }
}

/**
 * @import { CombatRecoveryResults } from "../../documents/_types.mjs";
 * @import { ActorDataModelMetadata } from "./_types.mjs";
 */

/**
 * Variant of the SystemDataModel with some extra actor-specific handling.
 */
let ActorDataModel$1 = class ActorDataModel extends SystemDataModel$1 {

  /** @type {ActorDataModelMetadata} */
  static metadata = Object.freeze(foundry.utils.mergeObject(super.metadata, {
    supportsAdvancement: false
  }, { inplace: false }));

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  get embeddedDescriptionKeyPath() {
    return "details.biography.value";
  }

  /* -------------------------------------------- */

  /**
   * Other actors that are available for currency transfers from this actor.
   * @type {Actor5e[]}
   */
  get transferDestinations() {
    const primaryParty = game.actors.party;
    if ( !primaryParty?.system.members.ids.has(this.parent.id) ) return [];
    const destinations = primaryParty.system.members.map(m => m.actor).filter(a => a.isOwner && a !== this.parent);
    if ( primaryParty.isOwner ) destinations.unshift(primaryParty);
    return destinations;
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /**
   * Data preparation steps to perform after item data has been prepared, but before active effects are applied.
   */
  prepareEmbeddedData() {
    this._prepareScaleValues();
  }

  /* -------------------------------------------- */

  /**
   * Derive any values that have been scaled by the Advancement system.
   * Mutates the value of the `system.scale` object.
   * @protected
   */
  _prepareScaleValues() {
    this.scale = this.parent.items.reduce((scale, item) => {
      const scaleValues = item.scaleValues;
      if ( !foundry.utils.isEmpty(scaleValues) ) scale[item.identifier] = scaleValues;
      return scale;
    }, {});
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Prepare a data object which defines the data schema used by dice roll commands against this Actor.
   * @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.
   * @returns {object}
   */
  getRollData({ deterministic=false }={}) {
    const data = { ...this };
    data.prof = new Proficiency(this.attributes?.prof ?? 0, 1);
    data.prof.deterministic = deterministic;
    return data;
  }

  /* -------------------------------------------- */

  /**
   * Reset combat-related uses.
   * @param {string[]} periods               Which recovery periods should be considered.
   * @param {CombatRecoveryResults} results  Updates to perform on the actor and containing items.
   */
  async recoverCombatUses(periods, results) {}
};

const { NumberField: NumberField$l, SchemaField: SchemaField$r } = foundry.data.fields;

/**
 * @import { CurrencyTemplateData } from "../../shared/_types.mjs";
 * @import { CommonTemplateData } from "./_types.mjs";
 */

/**
 * A template for all actors that share the common template.
 * @extends {ActorDataModel<CurrencyTemplate & CommonTemplateData>}
 * @mixes CurrencyTemplate
 * @mixes CommonTemplateData
 */
class CommonTemplate extends ActorDataModel$1.mixin(CurrencyTemplate) {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return this.mergeSchema(super.defineSchema(), {
      abilities: new MappingField(new SchemaField$r({
        value: new NumberField$l({
          required: true, nullable: false, integer: true, min: 0, initial: 10, label: "DND5E.AbilityScore"
        }),
        proficient: new NumberField$l({
          required: true, integer: true, min: 0, max: 1, initial: 0, label: "DND5E.ProficiencyLevel"
        }),
        max: new NumberField$l({
          required: true, integer: true, nullable: true, min: 0, initial: null, label: "DND5E.AbilityScoreMax"
        }),
        bonuses: new SchemaField$r({
          check: new FormulaField({ required: true, label: "DND5E.AbilityCheckBonus" }),
          save: new FormulaField({ required: true, label: "DND5E.SaveBonus" })
        }, { label: "DND5E.AbilityBonuses" }),
        check: new RollConfigField({ ability: false }),
        save: new RollConfigField({ ability: false })
      }), {
        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;
  }

  /* -------------------------------------------- */
  /*  Data Migration                              */
  /* -------------------------------------------- */

  /** @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;
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /**
   * Prepare modifiers and other values for abilities.
   * @param {object} [options={}]
   * @param {object} [options.rollData={}]    Roll data used to calculate bonuses.
   * @param {object} [options.originalSaves]  Original ability data for transformed actors.
   */
  prepareAbilities({ rollData={}, originalSaves }={}) {
    const flags = this.parent.flags.dnd5e ?? {};
    const { prof = 0, ac } = this.attributes ?? {};
    Object.values(this.abilities).forEach(a => a.mod = Math.floor((a.value - 10) / 2));
    const checkBonus = simplifyBonus(this.bonuses?.abilities?.check, rollData);
    const saveBonus = simplifyBonus(this.bonuses?.abilities?.save, rollData);
    const dcBonus = simplifyBonus(this.bonuses?.spell?.dc, rollData);
    for ( const [id, abl] of Object.entries(this.abilities) ) {
      if ( flags.diamondSoul ) abl.proficient = 1;  // Diamond Soul is proficient in all saves
      const originalAbility = originalSaves?.[id];
      if ( originalAbility?.proficient ) {
        abl.merged = true;
        abl.proficient = originalAbility?.proficient;
      }

      const calculatedProf = this.calculateAbilityCheckProficiency(0, id);
      abl.checkProf = originalAbility?.checkProf?.multiplier > calculatedProf.multiplier
        ? originalAbility.checkProf.clone() : calculatedProf;
      const saveBonusAbl = simplifyBonus(abl.bonuses?.save, rollData);

      const cover = id === "dex" ? Math.max(ac?.cover ?? 0, this.parent.coverBonus) : 0;
      abl.saveBonus = saveBonusAbl + saveBonus + cover;

      abl.saveProf = abl.merged ? originalAbility.saveProf.clone() : new Proficiency(prof, abl.proficient);
      const checkBonusAbl = simplifyBonus(abl.bonuses?.check, rollData);
      abl.checkBonus = checkBonusAbl + checkBonus;

      abl.save.value = abl.mod + abl.saveBonus;
      if ( Number.isNumeric(abl.saveProf.term) ) abl.save.value += abl.saveProf.flat;
      abl.attack = abl.mod + prof;
      abl.dc = 8 + abl.mod + prof + dcBonus;

      if ( !Number.isFinite(abl.max) ) abl.max = CONFIG.DND5E.maxAbilityScore;

      // Adjust rolling mode
      if ( this.parent.hasConditionEffect("abilityCheckDisadvantage") ) {
        AdvantageModeField.setMode(this, `abilities.${id}.check.roll.mode`, -1);
      }
      if ( this.parent.hasConditionEffect("abilitySaveDisadvantage")
        || ((id === "dex") && this.parent.hasConditionEffect("dexteritySaveDisadvantage")) ) {
        AdvantageModeField.setMode(this, `abilities.${id}.save.roll.mode`, -1);
      }
    }
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Create the proficiency object for an ability, skill, or tool, taking remarkable athlete and Jack of All Trades
   * into account.
   * @param {number} multiplier  Multiplier stored on the actor.
   * @param {string} ability     Ability associated with this proficiency.
   * @returns {Proficiency}
   */
  calculateAbilityCheckProficiency(multiplier, ability) {
    let roundDown = true;
    if ( multiplier < 1 ) {
      if ( this.parent._isRemarkableAthlete(ability) ) {
        multiplier = .5;
        roundDown = false;
      }
      else if ( this.parent.flags.dnd5e?.jackOfAllTrades ) multiplier = .5;
    }
    return new Proficiency(this.attributes.prof, multiplier, roundDown);
  }


  /* -------------------------------------------- */

  /**
   * Calculate proficiency, applying specific logic for tools.
   * @param {number} multiplier  Multiplier stored on the actor.
   * @param {string} ability     Ability associated with this proficiency.
   * @returns {Proficiency}
   */
  calculateToolProficiency(multiplier, ability) {
    if ( (multiplier === 1) && this.parent.flags.dnd5e?.toolExpertise ) {
      return new Proficiency(this.attributes.prof, 2, true);
    }
    return this.calculateAbilityCheckProficiency(multiplier, ability);
  }

  /* -------------------------------------------- */

  /**
   * Calculate proficiency for a given actor using either a skill, a tool, or both.
   * @param {Actpr5e} actor           The actor.
   * @param {string} abilityId        The ability used with the check.
   * @param {object} [options]
   * @param {string} [options.skill]  The skill.
   * @param {string} [options.tool]   The tool.
   * @returns {Proficiency|null}
   */
  static calculateSkillToolProficiency(actor, abilityId, options={}) {
    if ( !actor ) return null;
    const skill = actor.system.skills?.[options.skill];
    const tool = actor.system.tools?.[options.tool];
    const multiplier = Math.max(skill?.effectValue ?? 0, tool?.effectValue ?? 0);
    const calc = options.tool ? actor.system.calculateToolProficiency : actor.system.calculateAbilityCheckProficiency;
    return calc.call(actor.system, multiplier, abilityId);
  }
}

const { HTMLField: HTMLField$6, SchemaField: SchemaField$q, StringField: StringField$x } = foundry.data.fields;

/**
 * @import { DetailsCommonData, DetailsCreatureData } from "./_types.mjs";
 */

/**
 * Shared contents of the details schema between various actor types.
 */
class DetailsField {
  /**
   * Fields shared between characters, NPCs, and vehicles.
   * @type {DetailsCommonData}
   */
  static get common() {
    return {
      biography: new SchemaField$q({
        value: new HTMLField$6({label: "DND5E.Biography"}),
        public: new HTMLField$6({label: "DND5E.BiographyPublic"})
      }, {label: "DND5E.Biography"})
    };
  }

  /* -------------------------------------------- */

  /**
   * Fields shared between characters and NPCs.
   * @type {DetailsCreatureData}
   */
  static get creature() {
    return {
      alignment: new StringField$x({required: true, label: "DND5E.Alignment"}),
      ideal: new StringField$x({required: true, label: "DND5E.Ideals"}),
      bond: new StringField$x({required: true, label: "DND5E.Bonds"}),
      flaw: new StringField$x({required: true, label: "DND5E.Flaws"}),
      race: new LocalDocumentField(foundry.documents.BaseItem, {
        required: true, fallback: true, label: "DND5E.Species"
      })
    };
  }
}

const { SchemaField: SchemaField$p, SetField: SetField$h, StringField: StringField$w } = foundry.data.fields;

/**
 * Field for storing standard trait data.
 */
class SimpleTraitField extends SchemaField$p {
  constructor(fields={}, { initialValue=[], ...options }={}) {
    fields = {
      value: new SetField$h(new StringField$w(), { label: "DND5E.TraitsChosen", initial: initialValue }),
      custom: new StringField$w({ required: true, label: "DND5E.Special" }),
      ...fields
    };
    Object.entries(fields).forEach(([k, v]) => !v ? delete fields[k] : null);
    super(fields, options);
  }
}

const { SetField: SetField$g, StringField: StringField$v } = foundry.data.fields;

/**
 * Field for storing damage resistances, immunities, and vulnerabilities data.
 */
class DamageTraitField extends SimpleTraitField {
  constructor(fields={}, { initialBypasses=[], ...options }={}) {
    super({
      bypasses: new SetField$g(new StringField$v(), {
        label: "DND5E.DamagePhysicalBypass", hint: "DND5E.DamagePhysicalBypassHint", initial: initialBypasses
      })
    }, options);
  }
}

const { NumberField: NumberField$k, SchemaField: SchemaField$o, SetField: SetField$f, StringField: StringField$u } = foundry.data.fields;

/**
 * @import { TraitsCommonData, TraitsCreatureData } from "./_types.mjs";
 */

/**
 * Shared contents of the traits schema between various actor types.
 */
class TraitsField {
  /**
   * Fields shared between characters, NPCs, and vehicles.
   * @type {TraitsCommonData}
   */
  static get common() {
    return {
      size: new StringField$u({ required: true, initial: "med", label: "DND5E.Size" }),
      di: new DamageTraitField({}, { label: "DND5E.DamImm" }),
      dr: new DamageTraitField({}, { label: "DND5E.DamRes" }),
      dv: new DamageTraitField({}, { label: "DND5E.DamVuln" }),
      dm: new SchemaField$o({
        amount: new MappingField(new FormulaField({ deterministic: true }), { label: "DND5E.DamMod" }),
        bypasses: new SetField$f(new StringField$u(), {
          label: "DND5E.DamagePhysicalBypass", hint: "DND5E.DamagePhysicalBypassHint"
        })
      }),
      ci: new SimpleTraitField({}, { label: "DND5E.ConImm" })
    };
  }

  /* -------------------------------------------- */

  /**
   * Fields shared between characters and NPCs.
   * @type {TraitsCreatureData}
   */
  static get creature() {
    return {
      languages: new SimpleTraitField({
        communication: new MappingField(new SchemaField$o({
          units: new StringField$u({ initial: () => defaultUnits("length") }),
          value: new NumberField$k({ required: true, min: 0 })
        }))
      }, { label: "DND5E.Languages" })
    };
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /**
   * Prepare the language labels.
   * @this {CharacterData|NPCData}
   */
  static prepareLanguages() {
    const languages = this.traits.languages;
    const labels = languages.labels = { languages: [], ranged: [] };

    if ( languages.value.has("ALL") ) labels.languages.push(game.i18n.localize("DND5E.Language.All"));
    else {
      const processCategory = (key, data, group) => {
        // If key is within languages, don't bother with children
        if ( languages.value.has(key) ) (group?.children ?? labels.languages).push(data.label ?? data);

        // Display children as part of this group (e.g. "Primordial (Ignan)")
        else if ( data.children && (data.selectable !== false) ) {
          const topLevel = group === undefined;
          group ??= { label: data.label, children: [] };
          Object.entries(data.children).forEach(([k, d]) => processCategory(k, d, group));
          if ( topLevel && group.children.length ) labels.languages.push(
            `${data.label} (${game.i18n.getListFormatter({ type: "unit" }).format(group.children)})`
          );
        }

        // Display children alone if category isn't selectable
        else if ( data.children ) Object.entries(data.children).forEach(([k, d]) => processCategory(k, d));
      };

      for ( const [key, data] of Object.entries(CONFIG.DND5E.languages) ) {
        if ( data.children ) Object.entries(data.children).forEach(([k, d]) => processCategory(k, d));
        else processCategory(key, data);
      }
    }

    labels.languages.push(...splitSemicolons(languages.custom));

    for ( const [key, { label }] of Object.entries(CONFIG.DND5E.communicationTypes) ) {
      const data = languages.communication?.[key];
      if ( !data?.value ) continue;
      labels.ranged.push(`${label} ${formatLength(data.value, data.units)}`);
    }
  }

  /* -------------------------------------------- */

  /**
   * Prepare condition immunities & petrified condition.
   * @this {CharacterData|NPCData|VehicleData}
   */
  static prepareResistImmune() {
    // Apply condition immunities
    for ( const condition of this.traits.ci.value ) this.parent.statuses.delete(condition);

    // Apply petrified condition
    if ( this.parent.hasConditionEffect("petrification") ) {
      this.traits.dr.custom = game.i18n.localize("DND5E.DamageAll");
      Object.keys(CONFIG.DND5E.damageTypes).forEach(type => this.traits.dr.value.add(type));
      this.traits.dr.bypasses.clear();
      this.traits.di.value.add("poison");
      this.traits.ci.value.add("poisoned");
      this.traits.ci.value.add("diseased");
    }
  }
}

const { ArrayField: ArrayField$a, BooleanField: BooleanField$j, DocumentUUIDField: DocumentUUIDField$3, NumberField: NumberField$j, SchemaField: SchemaField$n, StringField: StringField$t } = foundry.data.fields;

/**
 * @import { PassengerData, VehicleActorSystemData } from "./_types.mjs";
 */

/**
 * System data definition for Vehicles.
 * @extends {CreatureTemplate<VehicleActorSystemData>}
 * @mixes VehicleActorSystemData
 */
class VehicleData extends CommonTemplate {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.SOURCE"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static _systemType = "vehicle";

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return this.mergeSchema(super.defineSchema(), {
      attributes: new SchemaField$n({
        ...AttributesFields.common,
        ac: new SchemaField$n({
          ...AttributesFields.armorClass,
          calc: new StringField$t({ initial: "flat", label: "DND5E.ArmorClassCalculation" })
        }, { label: "DND5E.ArmorClass" }),
        hp: new SchemaField$n({
          ...AttributesFields.hitPoints,
          mt: new NumberField$j({
            required: true, integer: true, min: 0, label: "DND5E.VEHICLE.Mishap.Threshold.label"
          })
        }, { label: "DND5E.HitPoints" }),
        actions: new SchemaField$n({
          max: new NumberField$j({
            required: true, nullable: false, integer: true, initial: 3, min: 0, max: 3,
            label: "DND5E.VEHICLE.FIELDS.attributes.actions.max.label"
          }),
          spent: new NumberField$j({
            required: true, nullable: false, integer: true, initial: 0, min: 0, max: 3,
            label: "DND5E.VEHICLE.FIELDS.attributes.actions.spent.label"
          }),
          stations: new BooleanField$j({
            required: true, initial: true, label: "DND5E.VEHICLE.FIELDS.attributes.actions.stations.label"
          }),
          thresholds: new SchemaField$n({
            2: new NumberField$j({
              required: true, integer: true, min: 0,
              label: "DND5E.VEHICLE.FIELDS.attributes.actions.thresholds.full.label"
            }),
            1: new NumberField$j({
              required: true, integer: true, min: 0,
              label: "DND5E.VEHICLE.FIELDS.attributes.actions.thresholds.mid.label"
            }),
            0: new NumberField$j({
              required: true, integer: true, min: 0,
              label: "DND5E.VEHICLE.FIELDS.attributes.actions.thresholds.min.label"
            })
          }, { label: "DND5E.VEHICLE.FIELDS.attributes.actions.thresholds.label" })
        }, { label: "DND5E.VEHICLE.FIELDS.attributes.actions.label" }),
        capacity: new SchemaField$n({
          cargo: new SchemaField$n({
            value: new NumberField$j({ min: 0, label: "DND5E.VEHICLE.FIELDS.attributes.capacity.cargo.value.label" }),
            units: new StringField$t({
              required: true, blank: false, label: "DND5E.UNITS.WEIGHT.Label", initial: () => defaultUnits("weight")
            })
          }, { label: "DND5E.VEHICLE.FIELDS.attributes.capacity.cargo.value.label" }),
          creature: new StringField$t({ required: true, label: "DND5E.VehicleCreatureCapacity" }) // FIXME: Leave in the model until we decide how to migrate it.
        }, { label: "DND5E.VEHICLE.FIELDS.attributes.capacity.label" }),
        price: new SchemaField$n({
          value: new NumberField$j({ initial: null, min: 0, label: "DND5E.Price" }),
          denomination: new StringField$t({ required: true, blank: false, initial: "gp", label: "DND5E.Currency" })
        }, { label: "DND5E.Price" }),
        quality: new SchemaField$n({
          value: new NumberField$j({ required: true, nullable: false, integer: true, min: -10, max: 10, initial: 4 })
        }),
        travel: new TravelField({ pace: false, }, {
          initialTime: () => CONFIG.DND5E.travelTimes.vehicle, initialUnits: () => defaultUnits("travel")
        })
      }, { label: "DND5E.Attributes" }),
      crew: new SchemaField$n({
        max: new NumberField$j({ min: 0, integer: true }),
        value: new ArrayField$a(new DocumentUUIDField$3({ type: "Actor" }))
      }),
      details: new SchemaField$n({
        ...DetailsField.common,
        type: new StringField$t({ required: true, blank: false, initial: "water", label: "DND5E.VEHICLE.Type.label" })
      }, { label: "DND5E.Details" }),
      draft: new SchemaField$n({
        value: new ArrayField$a(new DocumentUUIDField$3({ type: "Actor" }))
      }),
      passengers: new SchemaField$n({
        max: new NumberField$j({ min: 0, integer: true }),
        value: new ArrayField$a(new DocumentUUIDField$3({ type: "Actor" }))
      }),
      source: new SourceField(),
      traits: new SchemaField$n({
        ...TraitsField.common,
        size: new StringField$t({ required: true, blank: false, initial: "lg", label: "DND5E.Size" }),
        weight: new SchemaField$n({
          value: new NumberField$j({ min: 0, label: "DND5E.Weight" }),
          units: new StringField$t({
            required: true, blank: false, label: "DND5E.UNITS.WEIGHT.Label", initial: () => defaultUnits("weight")
          })
        }, { label: "DND5E.Weight" }),
        keel: new SchemaField$n({
          value: new NumberField$j({ min: 0, label: "DND5E.VEHICLE.FIELDS.traits.keel.value.label" }),
          units: new StringField$t({
            required: true, blank: false, label: "DND5E.UNITS.DISTANCE.Label", initial: () => defaultUnits("length")
          })
        }),
        beam: new SchemaField$n({
          value: new NumberField$j({ min: 0, label: "DND5E.VEHICLE.FIELDS.traits.beam.value.label" }),
          units: new StringField$t({
            required: true, blank: false, label: "DND5E.UNITS.DISTANCE.Label", initial: () => defaultUnits("length")
          })
        }),
        dimensions: new StringField$t({ required: true, label: "DND5E.Dimensions" }) // FIXME: Leave in the model until we decide how to migrate it.
      }, { label: "DND5E.Traits" }),
      cargo: new SchemaField$n({ // FIXME: Leave in the model until we decide how to migrate it.
        crew: new ArrayField$a(makePassengerData()),
        passengers: new ArrayField$a(makePassengerData())
      })
    });
  }

  /* -------------------------------------------- */
  /*  Data Migration                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static _migrateData(source) {
    super._migrateData(source);
    AttributesFields._migrateInitiative(source.attributes);
    VehicleData.#migrateSource(source);
    VehicleData.#migrateMovement(source);
    VehicleData.#migrateType(source);
    VehicleData.#migrateCargoCapacity(source);
    VehicleData.#migrateActions(source);
  }

  /* -------------------------------------------- */

  /**
   * Migrate actions from value to max.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateActions(source) {
    const actions = source.attributes?.actions;
    if ( !actions || (actions.max !== undefined) || (actions.spent !== undefined) || (actions.value === undefined) ) {
      return;
    }
    actions.max = actions.value;
    delete actions.value;
  }

  /* -------------------------------------------- */

  /**
   * Migrate cargo capacity from a number to an object with units.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateCargoCapacity(source) {
    const cargo = source.attributes?.capacity?.cargo;
    if ( typeof cargo !== "number" ) return;
    source.attributes.capacity.cargo = { value: cargo, units: "tn" };
  }

  /* -------------------------------------------- */

  /**
   * Migrate movement speeds to travel pace by taking the previous max speed and assigning it to a movement type
   * based on the vehicle's type.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateMovement(source) {
    const movement = source.attributes?.movement;
    const { vehicleType } = source;
    let newUnits;
    if ( movement?.units === "mi" ) newUnits = "mph";
    else if ( movement?.units === "km" ) newUnits = "kph";
    if ( !vehicleType || !movement || !newUnits || !("walk" in movement) || source.attributes?.travel ) return;
    let max = 0;
    for ( const p in CONFIG.DND5E.movementTypes ) {
      if ( movement[p] > max ) max = movement[p];
      delete movement[p];
    }
    source.attributes ??= {};
    source.attributes.travel = {
      [vehicleType === "space" ? "air" : vehicleType]: max,
      units: newUnits
    };
    movement.units = null;
  }

  /* -------------------------------------------- */

  /**
   * Convert source string into custom object & move to top-level.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateSource(source) {
    let custom;
    if ( ("details" in source) && ("source" in source.details) ) {
      if ( foundry.utils.getType(source.details?.source) === "string" ) custom = source.details.source;
      else source.source = source.details.source;
    }
    if ( custom ) source.source = { custom };
  }

  /* -------------------------------------------- */

  /**
   * Convert vehicle type.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateType(source) {
    if ( !source.vehicleType ) return;
    source.details ??= {};
    if ( source.details.type ) return;
    source.details.type = source.vehicleType;
    delete source.vehicleType;
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareBaseData() {
    this.attributes.prof = 0;
    AttributesFields.prepareBaseArmorClass.call(this);
    AttributesFields.prepareBaseEncumbrance.call(this);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareDerivedData() {
    const rollData = this.parent.getRollData({ deterministic: true });
    const { originalSaves } = this.parent.getOriginalStats();

    this.prepareAbilities({ rollData, originalSaves });
    AttributesFields.prepareArmorClass.call(this, rollData);
    if ( this.attributes.ac.value ) {
      this.attributes.ac.motionless = this.attributes.ac.value - Math.max(0, this.abilities.dex?.mod ?? 0);
    }
    AttributesFields.prepareEncumbrance.call(this, rollData, { validateItem: item => !item.isMountable });
    AttributesFields.prepareHitPoints.call(this, this.attributes.hp);
    AttributesFields.prepareInitiative.call(this, rollData);
    AttributesFields.prepareMovement.call(this, rollData);
    SourceField.prepareData.call(this.source, this.parent._stats?.compendiumSource ?? this.parent.uuid);
    TraitsField.prepareResistImmune.call(this);
    TravelField.prepareData.call(this, rollData);

    const { actions } = this.attributes;
    const crew = this.crew.value.length;

    if ( !actions.stations && actions.max ) {
      for ( let i = actions; i--; actions.max-- ) {
        const threshold = actions.thresholds[i];
        if ( Number.isFinite(threshold) && (crew >= threshold) ) break;
      }
    }

    actions.value = Math.clamp(actions.max - actions.spent, 0, actions.max);
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Adjust the crew quantity to some target value.
   * @param {string} area           The crew area.
   * @param {string} uuid           The crew member's UUID.
   * @param {string|number} target  The target value, which may be a delta.
   * @returns {Promise<Actor5e>}    The actor with updates applied.
   */
  async adjustCrew(area, uuid, target) {
    const updates = this.getCrewUpdates(area, uuid, target);
    if ( foundry.utils.isEmpty(updates) ) return this.parent;
    return this.parent.update(updates);
  }

  /* -------------------------------------------- */

  /**
   * Compute the update required in order to adjust the crew to some target quantity.
   * @param {string} area          The crew area.
   * @param {string} uuid          The crew member's UUID.
   * @param {string|number} target The target value, which may be a delta.
   * @returns {object}
   */
  getCrewUpdates(area, uuid, target) {
    const roster = this[area].value;
    const quantity = roster.reduce((acc, u) => acc + (u === uuid), 0);
    target = Math.max(0, typeof target === "number" ? target : parseDelta(target, quantity));
    const diff = target - quantity;
    const updates = {};
    if ( diff > 0 ) updates[`system.${area}.value`] = roster.concat(Array.fromRange(diff).map(() => uuid));
    else if ( diff < 0 ) {
      let count = quantity;
      const newRoster = [];
      for ( let i = roster.length; i--; ) {
        const u = roster[i];
        if ( (count > target) && (u === uuid) ) count--;
        else newRoster.push(u);
      }
      updates[`system.${area}.value`] = newRoster;
    }
    return updates;
  }

  /* -------------------------------------------- */

  /**
   * Get vehicle encumbrance including draft animals.
   * @returns {Promise<{ pct: number, max: number, value: number }>}
   */
  async getEncumbrance() {
    const encumbrance = foundry.utils.deepClone(this.attributes.encumbrance);
    if ( Number.isFinite(encumbrance.max) || !this.draft.value.length ) return encumbrance; // Encumbrance already calculated.
    const { baseUnits, draftMultiplier } = CONFIG.DND5E.encumbrance;
    const unitSystem = game.settings.get("dnd5e", "metricWeightUnits") ? "metric" : "imperial";
    const units = baseUnits.default[unitSystem];
    encumbrance.max = (await Promise.all(this.draft.value.map(fromUuid))).reduce((n, actor) => {
      const capacity = actor.system.attributes?.encumbrance?.max || 0;
      return n + (capacity * draftMultiplier);
    }, 0);
    const { weight } = this.traits;
    if ( weight.value ) {
      encumbrance.max = Math.max(0, encumbrance.max - convertWeight(weight.value, weight.units, units));
    }
    if ( encumbrance.max ) encumbrance.pct = Math.clamp((encumbrance.value * 100) / encumbrance.max, 0, 100);
    return encumbrance;
  }

  /* -------------------------------------------- */

  /** @override */
  async recoverCombatUses(periods, results) {
    const { actions } = this.attributes;
    if ( !actions.stations && actions.max && (periods.includes("encounter") || periods.includes("turnEnd")) ) {
      results.actor["system.attributes.actions.spent"] = 0;
    }
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Whether the given activity should prompt for auto-consumption of a crew action.
   * @param {Activity} activity  The activity.
   * @returns {boolean|void}
   */
  static canConsumeCrewAction(activity) {
    const { actor } = activity;
    return actor?.system.attributes?.actions?.stations === false;
  }
}

/* -------------------------------------------- */

/**
 * Produce the schema field for a simple trait.
 * @param {object} schemaOptions  Options passed to the outer schema.
 * @returns {PassengerData}
 */
function makePassengerData(schemaOptions={}) {
  return new SchemaField$n({
    name: new StringField$t({required: true}),
    quantity: new NumberField$j({
      required: true, nullable: false, integer: true, initial: 0, min: 0
    })
  }, schemaOptions);
}

/**
 * @import {
 *   AbilityConfiguration, ActivityActivationTypeConfiguration, ActivityConsumptionTargetConfiguration,
 *   ActivityTypeConfiguration, ActorSizeConfiguration, AdvancementTypeConfiguration,
 *   AreaTargetDefinition, CalendarHUDConfiguration, CharacterFlagConfiguration, ConditionConfiguration,
 *   CraftingConfiguration, CreatureTypeConfiguration, CurrencyConfiguration, DamageTypeConfiguration,
 *   EncumbranceConfiguration, FacilityConfiguration, HabitatConfiguration5e,
 *   IndividualTargetDefinition, ItemPropertyConfiguration, LimitedUsePeriodConfiguration,
 *   MapLocationMarkerStyle, MovementTypeConfiguration, MovementUnitConfiguration,
 *   RestTypeConfiguration, RequestCallback5e, RuleTypeConfiguration, SkillConfiguration,
 *   SpellcastingFocusConfiguration, SpellcastingPreparationState5e, SpellSchoolConfiguration,
 *   SpellScrollValues, StatusEffectConfig5e, SubtypeTypeConfiguration, TimeUnitConfiguration,
 *   ToolConfiguration, TraitConfiguration, TransformationConfiguration, TravelPaceConfiguration,
 *   TravelUnitConfiguration, TreasureConfiguration5e, UnitConfiguration, WeaponMasterConfiguration
 * } from "./_types.mjs";
 * @import { TravelPace5e } from "./data/actor/fields/_types.mjs";
 * @import {
 *   MultiLevelSpellcasting, SingleLevelSpellcastingData, SlotSpellcastingData, SpellcastingModelData,
 *   SpellcastingTable5e, SpellcastingTableSingle5e
 * } from "./data/spellcasting/_types.mjs";
 */

// Namespace Configuration Values
const DND5E = {};

// ASCII Artwork
DND5E.ASCII = `_______________________________
______      ______ _____ _____
|  _  \\___  |  _  \\  ___|  ___|
| | | ( _ ) | | | |___ \\| |__
| | | / _ \\/\\ | | |   \\ \\  __|
| |/ / (_>  < |/ //\\__/ / |___
|___/ \\___/\\/___/ \\____/\\____/
_______________________________`;

/**
 * The set of Ability Scores used within the system.
 * @enum {AbilityConfiguration}
 */
DND5E.abilities = {
  str: {
    label: "DND5E.AbilityStr",
    abbreviation: "DND5E.AbilityStrAbbr",
    type: "physical",
    fullKey: "strength",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.nUPv6C66Ur64BIUH",
    icon: "systems/dnd5e/icons/svg/abilities/strength.svg"
  },
  dex: {
    label: "DND5E.AbilityDex",
    abbreviation: "DND5E.AbilityDexAbbr",
    type: "physical",
    fullKey: "dexterity",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.ER8CKDUWLsFXuARJ",
    icon: "systems/dnd5e/icons/svg/abilities/dexterity.svg"
  },
  con: {
    label: "DND5E.AbilityCon",
    abbreviation: "DND5E.AbilityConAbbr",
    type: "physical",
    fullKey: "constitution",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.MpA4jnwD17Q0RPg7",
    icon: "systems/dnd5e/icons/svg/abilities/constitution.svg"
  },
  int: {
    label: "DND5E.AbilityInt",
    abbreviation: "DND5E.AbilityIntAbbr",
    type: "mental",
    fullKey: "intelligence",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.WzWWcTIppki35YvF",
    icon: "systems/dnd5e/icons/svg/abilities/intelligence.svg",
    defaults: { vehicle: 0 }
  },
  wis: {
    label: "DND5E.AbilityWis",
    abbreviation: "DND5E.AbilityWisAbbr",
    type: "mental",
    fullKey: "wisdom",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.v3IPyTtqvXqN934s",
    icon: "systems/dnd5e/icons/svg/abilities/wisdom.svg",
    defaults: { vehicle: 0 }
  },
  cha: {
    label: "DND5E.AbilityCha",
    abbreviation: "DND5E.AbilityChaAbbr",
    type: "mental",
    fullKey: "charisma",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.9FyghudYFV5QJOuG",
    icon: "systems/dnd5e/icons/svg/abilities/charisma.svg",
    defaults: { vehicle: 0 }
  },
  hon: {
    label: "DND5E.AbilityHon",
    abbreviation: "DND5E.AbilityHonAbbr",
    type: "mental",
    fullKey: "honor",
    defaults: { npc: "cha", vehicle: 0 },
    improvement: false
  },
  san: {
    label: "DND5E.AbilitySan",
    abbreviation: "DND5E.AbilitySanAbbr",
    type: "mental",
    fullKey: "sanity",
    defaults: { npc: "wis", vehicle: 0 },
    improvement: false
  }
};
preLocalize("abilities", { keys: ["label", "abbreviation"] });

/**
 * Configure which ability score is used as the default modifier for initiative rolls,
 * when calculating hit points per level and hit dice, and as the default modifier for
 * saving throws to maintain concentration.
 * @enum {string}
 */
DND5E.defaultAbilities = {
  meleeAttack: "str",
  rangedAttack: "dex",
  initiative: "dex",
  hitPoints: "con",
  concentration: "con"
};

/* -------------------------------------------- */

/**
 * The set of skill which can be trained with their default ability scores.
 * @enum {SkillConfiguration}
 */
DND5E.skills = {
  acr: {
    label: "DND5E.SkillAcr",
    ability: "dex",
    fullKey: "acrobatics",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.AvvBLEHNl7kuwPkN",
    icon: "icons/equipment/feet/shoes-simple-leaf-green.webp"
  },
  ani: {
    label: "DND5E.SkillAni",
    ability: "wis",
    fullKey: "animalHandling",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.xb3MCjUvopOU4viE",
    icon: "icons/environment/creatures/horse-brown.webp"
  },
  arc: {
    label: "DND5E.SkillArc",
    ability: "int",
    fullKey: "arcana",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.h3bYSPge8IOqne1N",
    icon: "icons/sundries/books/book-embossed-jewel-silver-green.webp"
  },
  ath: {
    label: "DND5E.SkillAth",
    ability: "str",
    fullKey: "athletics",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.rIR7ttYDUpH3tMzv",
    icon: "icons/magic/control/buff-strength-muscle-damage-orange.webp"
  },
  dec: {
    label: "DND5E.SkillDec",
    ability: "cha",
    fullKey: "deception",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.mqVZ2fz0L7a9VeKJ",
    icon: "icons/magic/control/mouth-smile-deception-purple.webp"
  },
  his: {
    label: "DND5E.SkillHis",
    ability: "int",
    fullKey: "history",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.kRBZbdWMGW9K3wdY",
    icon: "icons/sundries/books/book-embossed-bound-brown.webp"
  },
  ins: {
    label: "DND5E.SkillIns",
    ability: "wis",
    fullKey: "insight",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.8R5SMbAGbECNgO8z",
    icon: "icons/magic/perception/orb-crystal-ball-scrying-blue.webp"
  },
  itm: {
    label: "DND5E.SkillItm",
    ability: "cha",
    fullKey: "intimidation",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.4VHHI2gJ1jEsppfg",
    icon: "icons/skills/social/intimidation-impressing.webp"
  },
  inv: {
    label: "DND5E.SkillInv",
    ability: "int",
    fullKey: "investigation",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.Y7nmbQAruWOs7WRM",
    icon: "icons/tools/scribal/magnifying-glass.webp"
  },
  med: {
    label: "DND5E.SkillMed",
    ability: "wis",
    fullKey: "medicine",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.GeYmM7BVfSCAga4o",
    icon: "icons/tools/cooking/mortar-herbs-yellow.webp"
  },
  nat: {
    label: "DND5E.SkillNat",
    ability: "int",
    fullKey: "nature",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.ueMx3uF2PQlcye31",
    icon: "icons/magic/nature/plant-sprout-snow-green.webp"
  },
  prc: {
    label: "DND5E.SkillPrc",
    ability: "wis",
    fullKey: "perception",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.zjEeHCUqfuprfzhY",
    icon: "icons/magic/perception/eye-ringed-green.webp",
    pace: {
      advantage: new Set(["slow"]),
      disadvantage: new Set(["fast"])
    }
  },
  prf: {
    label: "DND5E.SkillPrf",
    ability: "cha",
    fullKey: "performance",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.hYT7Z06yDNBcMtGe",
    icon: "icons/tools/instruments/lute-gold-brown.webp"
  },
  per: {
    label: "DND5E.SkillPer",
    ability: "cha",
    fullKey: "persuasion",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.4R5H8iIsdFQTsj3X",
    icon: "icons/skills/social/diplomacy-handshake.webp"
  },
  rel: {
    label: "DND5E.SkillRel",
    ability: "int",
    fullKey: "religion",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.CXVzERHdP4qLhJXM",
    icon: "icons/magic/holy/saint-glass-portrait-halo.webp"
  },
  slt: {
    label: "DND5E.SkillSlt",
    ability: "dex",
    fullKey: "sleightOfHand",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.yg6SRpGNVz9nDW0A",
    icon: "icons/sundries/gaming/playing-cards.webp"
  },
  ste: {
    label: "DND5E.SkillSte",
    ability: "dex",
    fullKey: "stealth",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.4MfrpERNiQXmvgCI",
    icon: "icons/magic/perception/shadow-stealth-eyes-purple.webp",
    pace: {
      disadvantage: new Set(["normal", "fast"])
    }
  },
  sur: {
    label: "DND5E.SkillSur",
    ability: "wis",
    fullKey: "survival",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.t3EzDU5b9BVAIEVi",
    icon: "icons/magic/fire/flame-burning-campfire-yellow-blue.webp",
    pace: {
      advantage: new Set(["slow"]),
      disadvantage: new Set(["fast"])
    }
  }
};
preLocalize("skills", { key: "label", sort: true });

/* -------------------------------------------- */

/**
 * Base passive score and the amount by which the passive skill scores are modified when that skill has
 * advantage or disadvantage.
 * @type {{ base: number, modifier: number }}
 */
DND5E.skillPassive = {
  base: 10,
  modifier: 5
};

/* -------------------------------------------- */

/**
 * 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 {string}
 */
DND5E.attunementTypes = {
  required: "DND5E.AttunementRequired",
  optional: "DND5E.AttunementOptional"
};
preLocalize("attunementTypes");

/* -------------------------------------------- */
/*  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");

/* -------------------------------------------- */

/**
 * General weapon categories.
 * @enum {string}
 */
DND5E.weaponProficiencies = {
  sim: "DND5E.WeaponSimpleProficiency",
  mar: "DND5E.WeaponMartialProficiency"
};
preLocalize("weaponProficiencies");

/* -------------------------------------------- */

/**
 * Weapon masteries.
 * @enum {WeaponMasterConfiguration}
 */
DND5E.weaponMasteries = {
  cleave: {
    label: "DND5E.WEAPON.Mastery.Cleave",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.ULDpodOdTxTTiNEx"
  },
  graze: {
    label: "DND5E.WEAPON.Mastery.Graze",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.PPnaXKPsQvAZp0J4"
  },
  nick: {
    label: "DND5E.WEAPON.Mastery.Nick",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.l0uao3UVco5ptQso"
  },
  push: {
    label: "DND5E.WEAPON.Mastery.Push",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.BPD7ScnLyuPwl145"
  },
  sap: {
    label: "DND5E.WEAPON.Mastery.Sap",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.fPkZQ7TkKCCA3nTc"
  },
  slow: {
    label: "DND5E.WEAPON.Mastery.Slow",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.OQQ7hAp6OAxX1rXY"
  },
  topple: {
    label: "DND5E.WEAPON.Mastery.Topple",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.IMnpuysdrSalmZJg"
  },
  vex: {
    label: "DND5E.WEAPON.Mastery.Vex",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.hg3adn9O1O5Z2QxL"
  }
};
preLocalize("weaponMasteries", { key: "label", sort: true });

/* -------------------------------------------- */

/**
 * A mapping between `DND5E.weaponTypes` and `DND5E.weaponProficiencies` that
 * is used to determine if character has proficiency when adding an item.
 * @enum {(boolean|string)}
 */
DND5E.weaponProficienciesMap = {
  simpleM: "sim",
  simpleR: "sim",
  martialM: "mar",
  martialR: "mar"
};

/* -------------------------------------------- */

/**
 * A mapping between `DND5E.weaponTypes` and `DND5E.attackClassifications`. Unlisted types are assumed to be
 * of the "weapon" classification.
 * @enum {string}
 */
DND5E.weaponClassificationMap = {};

/* -------------------------------------------- */

/**
 * A mapping between `DND5E.weaponTypes` and `DND5E.attackTypes`.
 * @enum {string}
 */
DND5E.weaponTypeMap = {
  simpleM: "melee",
  simpleR: "ranged",
  martialM: "melee",
  martialR: "ranged",
  siege: "ranged"
};

/* -------------------------------------------- */

/**
 * The basic weapon types in 5e. This enables specific weapon proficiencies or
 * starting equipment provided by classes and backgrounds.
 * @enum {string}
 */
DND5E.weaponIds = {
  battleaxe: "Compendium.dnd5e.equipment24.Item.phbwepBattleaxe0",
  blowgun: "Compendium.dnd5e.equipment24.Item.phbwepBlowgun000",
  club: "Compendium.dnd5e.equipment24.Item.phbwepClub000000",
  dagger: "Compendium.dnd5e.equipment24.Item.phbwepDagger0000",
  dart: "Compendium.dnd5e.equipment24.Item.phbwepDart000000",
  flail: "Compendium.dnd5e.equipment24.Item.phbwepFlail00000",
  glaive: "Compendium.dnd5e.equipment24.Item.phbwepGlaive0000",
  greataxe: "Compendium.dnd5e.equipment24.Item.phbwepGreataxe00",
  greatclub: "Compendium.dnd5e.equipment24.Item.phbwepGreatclub0",
  greatsword: "Compendium.dnd5e.equipment24.Item.phbwepGreatsword",
  halberd: "Compendium.dnd5e.equipment24.Item.phbwepHalberd000",
  handaxe: "Compendium.dnd5e.equipment24.Item.phbwepHandaxe000",
  handcrossbow: "Compendium.dnd5e.equipment24.Item.phbwepHandCrossb",
  heavycrossbow: "Compendium.dnd5e.equipment24.Item.phbwepHeavyCross",
  javelin: "Compendium.dnd5e.equipment24.Item.phbwepJavelin000",
  lance: "Compendium.dnd5e.equipment24.Item.phbwepLance00000",
  lightcrossbow: "Compendium.dnd5e.equipment24.Item.phbwepLightCross",
  lighthammer: "Compendium.dnd5e.equipment24.Item.phbwepLightHamme",
  longbow: "Compendium.dnd5e.equipment24.Item.phbwepLongbow000",
  longsword: "Compendium.dnd5e.equipment24.Item.phbwepLongsword0",
  mace: "Compendium.dnd5e.equipment24.Item.phbwepMace000000",
  maul: "Compendium.dnd5e.equipment24.Item.phbwepMaul000000",
  morningstar: "Compendium.dnd5e.equipment24.Item.phbwepMorningsta",
  musket: "Compendium.dnd5e.equipment24.Item.phbwepMusket0000",
  pike: "Compendium.dnd5e.equipment24.Item.phbwepPike000000",
  pistol: "Compendium.dnd5e.equipment24.Item.phbwepPistol0000",
  quarterstaff: "Compendium.dnd5e.equipment24.Item.phbwepQuartersta",
  rapier: "Compendium.dnd5e.equipment24.Item.phbwepRapier0000",
  scimitar: "Compendium.dnd5e.equipment24.Item.phbwepScimitar00",
  shortsword: "Compendium.dnd5e.equipment24.Item.phbwepShortsword",
  sickle: "Compendium.dnd5e.equipment24.Item.phbwepSickle0000",
  spear: "Compendium.dnd5e.equipment24.Item.phbwepSpear00000",
  shortbow: "Compendium.dnd5e.equipment24.Item.phbwepShortbow00",
  sling: "Compendium.dnd5e.equipment24.Item.phbwepSling00000",
  trident: "Compendium.dnd5e.equipment24.Item.phbwepTrident000",
  warpick: "Compendium.dnd5e.equipment24.Item.phbwepWarPick000",
  warhammer: "Compendium.dnd5e.equipment24.Item.phbwepWarhammer0",
  whip: "Compendium.dnd5e.equipment24.Item.phbwepWhip000000"
};

/* -------------------------------------------- */

/**
 * The basic ammunition types.
 * @enum {string}
 */
DND5E.ammoIds = {
  arrow: "Compendium.dnd5e.equipment24.Item.phbamoArrows0000",
  blowgunNeedle: "Compendium.dnd5e.equipment24.Item.phbamoNeedles000",
  crossbowBolt: "Compendium.dnd5e.equipment24.Item.phbamoBolts00000",
  firearmBullet: "Compendium.dnd5e.equipment24.Item.phbamoBulletsFir",
  slingBullet: "Compendium.dnd5e.equipment24.Item.phbamoBulletsSli"
};

/* -------------------------------------------- */
/*  Bastion Facilities                          */
/* -------------------------------------------- */

/**
 * Configuration data for bastion facilities.
 * @type {FacilityConfiguration}
 */
DND5E.facilities = {
  advancement: {
    basic: { 5: 2 },
    special: { 5: 2, 9: 4, 13: 5, 17: 6 }
  },
  orders: {
    build: {
      label: "DND5E.FACILITY.Orders.build.inf",
      icon: "systems/dnd5e/icons/svg/facilities/build.svg"
    },
    change: {
      label: "DND5E.FACILITY.Orders.change.inf",
      icon: "systems/dnd5e/icons/svg/facilities/change.svg",
      duration: 21
    },
    craft: {
      label: "DND5E.FACILITY.Orders.craft.inf",
      icon: "systems/dnd5e/icons/svg/facilities/craft.svg"
    },
    empower: {
      label: "DND5E.FACILITY.Orders.empower.inf",
      icon: "systems/dnd5e/icons/svg/facilities/empower.svg"
    },
    enlarge: {
      label: "DND5E.FACILITY.Orders.enlarge.inf",
      icon: "systems/dnd5e/icons/svg/facilities/enlarge.svg",
      basic: true
    },
    harvest: {
      label: "DND5E.FACILITY.Orders.harvest.inf",
      icon: "systems/dnd5e/icons/svg/facilities/harvest.svg"
    },
    maintain: {
      label: "DND5E.FACILITY.Orders.maintain.inf",
      icon: "systems/dnd5e/icons/svg/facilities/maintain.svg"
    },
    recruit: {
      label: "DND5E.FACILITY.Orders.recruit.inf",
      icon: "systems/dnd5e/icons/svg/facilities/recruit.svg"
    },
    repair: {
      label: "DND5E.FACILITY.Orders.repair.inf",
      icon: "systems/dnd5e/icons/svg/facilities/repair.svg",
      hidden: true
    },
    research: {
      label: "DND5E.FACILITY.Orders.research.inf",
      icon: "systems/dnd5e/icons/svg/facilities/research.svg"
    },
    trade: {
      label: "DND5E.FACILITY.Orders.trade.inf",
      icon: "systems/dnd5e/icons/svg/facilities/trade.svg"
    }
  },
  sizes: {
    cramped: {
      label: "DND5E.FACILITY.Sizes.cramped",
      days: 20,
      squares: 4,
      value: 500
    },
    roomy: {
      label: "DND5E.FACILITY.Sizes.roomy",
      days: 45,
      squares: 16,
      value: 1_000
    },
    vast: {
      label: "DND5E.FACILITY.Sizes.vast",
      days: 125,
      squares: 36,
      value: 3_000
    }
  },
  types: {
    basic: {
      label: "DND5E.FACILITY.Types.Basic.Label.one",
      subtypes: {
        bedroom: "DND5E.FACILITY.Types.Basic.Bedroom",
        diningRoom: "DND5E.FACILITY.Types.Basic.DiningRoom",
        parlor: "DND5E.FACILITY.Types.Basic.Parlor",
        courtyard: "DND5E.FACILITY.Types.Basic.Courtyard",
        kitchen: "DND5E.FACILITY.Types.Basic.Kitchen",
        storage: "DND5E.FACILITY.Types.Basic.Storage"
      }
    },
    special: {
      label: "DND5E.FACILITY.Types.Special.Label.one",
      subtypes: {
        arcaneStudy: "DND5E.FACILITY.Types.Special.ArcaneStudy",
        armory: "DND5E.FACILITY.Types.Special.Armory",
        barrack: "DND5E.FACILITY.Types.Special.Barrack",
        garden: "DND5E.FACILITY.Types.Special.Garden",
        library: "DND5E.FACILITY.Types.Special.Library",
        sanctuary: "DND5E.FACILITY.Types.Special.Sanctuary",
        smithy: "DND5E.FACILITY.Types.Special.Smithy",
        storehouse: "DND5E.FACILITY.Types.Special.Storehouse",
        workshop: "DND5E.FACILITY.Types.Special.Workshop",
        gamingHall: "DND5E.FACILITY.Types.Special.GamingHall",
        greenhouse: "DND5E.FACILITY.Types.Special.Greenhouse",
        laboratory: "DND5E.FACILITY.Types.Special.Laboratory",
        sacristy: "DND5E.FACILITY.Types.Special.Sacristy",
        scriptorium: "DND5E.FACILITY.Types.Special.Scriptorium",
        stable: "DND5E.FACILITY.Types.Special.Stable",
        teleportationCircle: "DND5E.FACILITY.Types.Special.TeleportationCircle",
        theater: "DND5E.FACILITY.Types.Special.Theater",
        trainingArea: "DND5E.FACILITY.Types.Special.TrainingArea",
        trophyRoom: "DND5E.FACILITY.Types.Special.TrophyRoom",
        archive: "DND5E.FACILITY.Types.Special.Archive",
        meditationChamber: "DND5E.FACILITY.Types.Special.MeditationChamber",
        menagerie: "DND5E.FACILITY.Types.Special.Menagerie",
        observatory: "DND5E.FACILITY.Types.Special.Observatory",
        pub: "DND5E.FACILITY.Types.Special.Pub",
        reliquary: "DND5E.FACILITY.Types.Special.Reliquary",
        demiplane: "DND5E.FACILITY.Types.Special.Demiplane",
        guildhall: "DND5E.FACILITY.Types.Special.Guildhall",
        sanctum: "DND5E.FACILITY.Types.Special.Sanctum",
        warRoom: "DND5E.FACILITY.Types.Special.WarRoom"
      }
    }
  }
};
preLocalize("facilities.orders", { key: "label", sort: true });
preLocalize("facilities.sizes", { key: "label", sort: true });
preLocalize("facilities.types", { key: "label", sort: true });
preLocalize("facilities.types.basic.subtypes", { sort: true });
preLocalize("facilities.types.special.subtypes", { sort: true });

/* -------------------------------------------- */
/*  Tool Details                                */
/* -------------------------------------------- */

/**
 * 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 });

/**
 * Configuration data for tools.
 * @enum {ToolConfiguration}
 */
DND5E.tools = {
  alchemist: {
    ability: "int",
    id: "Compendium.dnd5e.equipment24.Item.phbtulAlchemists"
  },
  bagpipes: {
    ability: "cha",
    id: "Compendium.dnd5e.equipment24.Item.phbmusBagpipes00"
  },
  brewer: {
    ability: "int",
    id: "Compendium.dnd5e.equipment24.Item.phbtulBrewersSup"
  },
  calligrapher: {
    ability: "dex",
    id: "Compendium.dnd5e.equipment24.Item.phbtulCalligraph"
  },
  card: {
    ability: "wis",
    id: "Compendium.dnd5e.equipment24.Item.phbgstPlayingcar"
  },
  carpenter: {
    ability: "str",
    id: "Compendium.dnd5e.equipment24.Item.phbtulCarpenters"
  },
  cartographer: {
    ability: "wis",
    id: "Compendium.dnd5e.equipment24.Item.phbtulCartograph"
  },
  chess: {
    ability: "wis",
    id: "Compendium.dnd5e.equipment24.Item.phbgstDragonches"
  },
  cobbler: {
    ability: "dex",
    id: "Compendium.dnd5e.equipment24.Item.phbtulCobblersTo"
  },
  cook: {
    ability: "wis",
    id: "Compendium.dnd5e.equipment24.Item.phbtulCooksUtens"
  },
  dice: {
    ability: "wis",
    id: "Compendium.dnd5e.equipment24.Item.phbgstDice000000"
  },
  disg: {
    ability: "cha",
    id: "Compendium.dnd5e.equipment24.Item.phbtulDisguiseKi"
  },
  drum: {
    ability: "cha",
    id: "Compendium.dnd5e.equipment24.Item.phbmusDrum000000"
  },
  dulcimer: {
    ability: "cha",
    id: "Compendium.dnd5e.equipment24.Item.phbmusDulcimer00"
  },
  flute: {
    ability: "cha",
    id: "Compendium.dnd5e.equipment24.Item.phbmusFlute00000"
  },
  forg: {
    ability: "dex",
    id: "Compendium.dnd5e.equipment24.Item.phbtulForgeryKit"
  },
  glassblower: {
    ability: "int",
    id: "Compendium.dnd5e.equipment24.Item.phbtulGlassblowe"
  },
  herb: {
    ability: "int",
    id: "Compendium.dnd5e.equipment24.Item.phbtulHerbalismK"
  },
  horn: {
    ability: "cha",
    id: "Compendium.dnd5e.equipment24.Item.phbmusHorn000000"
  },
  jeweler: {
    ability: "int",
    id: "Compendium.dnd5e.equipment24.Item.phbtulJewelersTo"
  },
  leatherworker: {
    ability: "dex",
    id: "Compendium.dnd5e.equipment24.Item.phbtulLeatherwor"
  },
  lute: {
    ability: "cha",
    id: "Compendium.dnd5e.equipment24.Item.phbmusLute000000"
  },
  lyre: {
    ability: "cha",
    id: "Compendium.dnd5e.equipment24.Item.phbmusLyre000000"
  },
  mason: {
    ability: "str",
    id: "Compendium.dnd5e.equipment24.Item.phbtulMasonsTool"
  },
  navg: {
    ability: "wis",
    id: "Compendium.dnd5e.equipment24.Item.phbtulNavigators"
  },
  painter: {
    ability: "wis",
    id: "Compendium.dnd5e.equipment24.Item.phbtulPaintersSu"
  },
  panflute: {
    ability: "cha",
    id: "Compendium.dnd5e.equipment24.Item.phbmusPanflute00"
  },
  pois: {
    ability: "int",
    id: "Compendium.dnd5e.equipment24.Item.phbtulPoisonersK"
  },
  potter: {
    ability: "int",
    id: "Compendium.dnd5e.equipment24.Item.phbtulPottersToo"
  },
  shawm: {
    ability: "cha",
    id: "Compendium.dnd5e.equipment24.Item.phbmusShawm00000"
  },
  smith: {
    ability: "str",
    id: "Compendium.dnd5e.equipment24.Item.phbtulSmithsTool"
  },
  thief: {
    ability: "dex",
    id: "Compendium.dnd5e.equipment24.Item.phbtulThievesToo"
  },
  tinker: {
    ability: "dex",
    id: "Compendium.dnd5e.equipment24.Item.phbtulTinkersToo"
  },
  viol: {
    ability: "cha",
    id: "Compendium.dnd5e.equipment24.Item.phbmusViol000000"
  },
  weaver: {
    ability: "dex",
    id: "Compendium.dnd5e.equipment24.Item.phbtulWeaversToo"
  },
  woodcarver: {
    ability: "dex",
    id: "Compendium.dnd5e.equipment24.Item.phbtulWoodcarver"
  }
};

/**
 * The basic tool types in 5e. This enables specific tool proficiencies or
 * starting equipment provided by classes and backgrounds.
 * @enum {string}
 */
DND5E.toolIds = new Proxy(DND5E.tools, {
  get(target, prop) {
    return target[prop]?.id ?? target[prop];
  }
});

/* -------------------------------------------- */
/*  Time                                        */
/* -------------------------------------------- */

/**
 * Configuration for time units available to the system.
 * @enum {TimeUnitConfiguration}
 */
DND5E.timeUnits = {
  turn: {
    label: "DND5E.UNITS.TIME.Turn.Label",
    counted: "DND5E.UNITS.TIME.Turn.Counted",
    conversion: .1,
    combat: true
  },
  round: {
    label: "DND5E.UNITS.TIME.Round.Label",
    counted: "DND5E.UNITS.TIME.Round.Counted",
    conversion: .1,
    combat: true
  },
  second: {
    label: "DND5E.UNITS.TIME.Second.Label",
    conversion: 1 / 60,
    option: false,
    timeComponent: "second"
  },
  minute: {
    label: "DND5E.UNITS.TIME.Minute.Label",
    conversion: 1,
    timeComponent: "minute"
  },
  hour: {
    label: "DND5E.UNITS.TIME.Hour.Label",
    conversion: 60,
    timeComponent: "hour"
  },
  day: {
    label: "DND5E.UNITS.TIME.Day.Label",
    conversion: 1_440,
    timeComponent: "day"
  },
  week: {
    label: "DND5E.UNITS.TIME.Week.Label",
    conversion: 10_080,
    option: false
  },
  month: {
    label: "DND5E.UNITS.TIME.Month.Label",
    conversion: 43_200
  },
  year: {
    label: "DND5E.UNITS.TIME.Year.Label",
    conversion: 525_600,
    timeComponent: "year"
  }
};
preLocalize("timeUnits", { key: "label" });

/* -------------------------------------------- */

/**
 * Time periods that accept a numeric value.
 * @enum {string}
 */
DND5E.scalarTimePeriods = new Proxy(DND5E.timeUnits, {
  get(target, prop) {
    return target[prop]?.label;
  },
  has(target, key) {
    return target[key] && target[key].option !== false;
  },
  ownKeys(target) {
    return Object.keys(target).filter(k => target[k]?.option !== false);
  }
});

/* -------------------------------------------- */

/**
 * 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");

/* -------------------------------------------- */

/**
 * Ways in which to activate an item that cannot be labeled with a cost.
 * @enum {string}
 */
DND5E.staticAbilityActivationTypes = {
  none: "DND5E.NoneActionLabel",
  special: DND5E.timePeriods.spec
};

/**
 * Various ways in which an item or ability can be activated.
 * @enum {string}
 */
DND5E.abilityActivationTypes = {
  ...DND5E.staticAbilityActivationTypes,
  action: "DND5E.Action",
  bonus: "DND5E.BonusAction",
  reaction: "DND5E.Reaction",
  minute: DND5E.timePeriods.minute,
  hour: DND5E.timePeriods.hour,
  day: DND5E.timePeriods.day,
  legendary: "DND5E.LegendaryAction.Label",
  mythic: "DND5E.MythicActionLabel",
  lair: "DND5E.LAIR.Action.Label",
  crew: "DND5E.VEHICLE.Activation.Crew.label"
};
preLocalize("abilityActivationTypes");

/* -------------------------------------------- */

/**
 * Configuration data for activation types on activities.
 * @enum {ActivityActivationTypeConfiguration}
 */
DND5E.activityActivationTypes = {
  action: {
    label: "DND5E.ACTIVATION.Type.Action.Label",
    header: "DND5E.ACTIVATION.Type.Action.Header",
    group: "DND5E.ACTIVATION.Category.Standard"
  },
  bonus: {
    label: "DND5E.ACTIVATION.Type.BonusAction.Label",
    header: "DND5E.ACTIVATION.Type.BonusAction.Header",
    group: "DND5E.ACTIVATION.Category.Standard"
  },
  reaction: {
    label: "DND5E.ACTIVATION.Type.Reaction.Label",
    header: "DND5E.ACTIVATION.Type.Reaction.Header",
    group: "DND5E.ACTIVATION.Category.Standard"
  },
  minute: {
    label: "DND5E.ACTIVATION.Type.Minute.Label",
    header: "DND5E.ACTIVATION.Type.Minute.Header",
    group: "DND5E.ACTIVATION.Category.Time",
    scalar: true
  },
  hour: {
    label: "DND5E.ACTIVATION.Type.Hour.Label",
    header: "DND5E.ACTIVATION.Type.Hour.Header",
    group: "DND5E.ACTIVATION.Category.Time",
    scalar: true
  },
  day: {
    label: "DND5E.ACTIVATION.Type.Day.Label",
    header: "DND5E.ACTIVATION.Type.Day.Header",
    group: "DND5E.ACTIVATION.Category.Time",
    scalar: true
  },
  longRest: {
    label: "DND5E.ACTIVATION.Type.LongRest.Label",
    group: "DND5E.ACTIVATION.Category.Rest",
    passive: true
  },
  shortRest: {
    label: "DND5E.ACTIVATION.Type.ShortRest.Label",
    group: "DND5E.ACTIVATION.Category.Rest",
    passive: true
  },
  encounter: {
    label: "DND5E.ACTIVATION.Type.Encounter.Label",
    group: "DND5E.ACTIVATION.Category.Combat",
    passive: true
  },
  turnStart: {
    label: "DND5E.ACTIVATION.Type.TurnStart.Label",
    group: "DND5E.ACTIVATION.Category.Combat",
    passive: true
  },
  turnEnd: {
    label: "DND5E.ACTIVATION.Type.TurnEnd.Label",
    group: "DND5E.ACTIVATION.Category.Combat",
    passive: true
  },
  legendary: {
    counted: "DND5E.ACTIVATION.Type.Legendary.Counted",
    consume: {
      property: "resources.legact"
    },
    label: "DND5E.ACTIVATION.Type.Legendary.Label",
    header: "DND5E.ACTIVATION.Type.Legendary.Header",
    group: "DND5E.ACTIVATION.Category.Monster",
    scalar: true
  },
  mythic: {
    counted: "DND5E.ACTIVATION.Type.Mythic.Counted",
    consume: {
      property: "resources.legact"
    },
    label: "DND5E.ACTIVATION.Type.Mythic.Label",
    header: "DND5E.ACTIVATION.Type.Mythic.Header",
    group: "DND5E.ACTIVATION.Category.Monster",
    scalar: true
  },
  lair: {
    label: "DND5E.ACTIVATION.Type.Lair.Label",
    header: "DND5E.ACTIVATION.Type.Lair.Header",
    group: "DND5E.ACTIVATION.Category.Monster"
  },
  crew: {
    counted: "DND5E.ACTIVATION.Type.Crew.Counted",
    consume: {
      canConsume: VehicleData.canConsumeCrewAction,
      property: "attributes.actions"
    },
    label: "DND5E.ACTIVATION.Type.Crew.Label",
    header: "DND5E.ACTIVATION.Type.Crew.Header",
    group: "DND5E.ACTIVATION.Category.Vehicle",
    scalar: true
  },
  special: {
    label: "DND5E.Special",
    passive: true
  }
};
preLocalize("activityActivationTypes", { key: "label" });

/* -------------------------------------------- */

/**
 * 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 });

/* -------------------------------------------- */

/**
 * Configuration information for different consumption targets.
 * @enum {ActivityConsumptionTargetConfiguration}
 */
DND5E.activityConsumptionTypes = {
  activityUses: {
    label: "DND5E.CONSUMPTION.Type.ActivityUses.Label",
    consume: ConsumptionTargetData.consumeActivityUses,
    consumptionLabels: ConsumptionTargetData.consumptionLabelsActivityUses
  },
  itemUses: {
    label: "DND5E.CONSUMPTION.Type.ItemUses.Label",
    consume: ConsumptionTargetData.consumeItemUses,
    consumptionLabels: ConsumptionTargetData.consumptionLabelsItemUses,
    nonEmbeddedHint: "DND5E.CONSUMPTION.Type.ItemUses.NonEmbeddedHint",
    targetRequiresEmbedded: true,
    validTargets: ConsumptionTargetData.validItemUsesTargets
  },
  material: {
    label: "DND5E.CONSUMPTION.Type.Material.Label",
    consume: ConsumptionTargetData.consumeMaterial,
    consumptionLabels: ConsumptionTargetData.consumptionLabelsMaterial,
    nonEmbeddedHint: "DND5E.CONSUMPTION.Type.Material.NonEmbeddedHint",
    targetRequiresEmbedded: true,
    validTargets: ConsumptionTargetData.validMaterialTargets
  },
  hitDice: {
    label: "DND5E.CONSUMPTION.Type.HitDice.Label",
    consume: ConsumptionTargetData.consumeHitDice,
    consumptionLabels: ConsumptionTargetData.consumptionLabelsHitDice,
    validTargets: ConsumptionTargetData.validHitDiceTargets
  },
  spellSlots: {
    label: "DND5E.CONSUMPTION.Type.SpellSlots.Label",
    consume: ConsumptionTargetData.consumeSpellSlots,
    consumptionLabels: ConsumptionTargetData.consumptionLabelsSpellSlots,
    scalingModes: [{ value: "level", label: "DND5E.CONSUMPTION.Scaling.SlotLevel" }],
    validTargets: ConsumptionTargetData.validSpellSlotsTargets
  },
  attribute: {
    label: "DND5E.CONSUMPTION.Type.Attribute.Label",
    consume: ConsumptionTargetData.consumeAttribute,
    consumptionLabels: ConsumptionTargetData.consumptionLabelsAttribute,
    nonEmbeddedHint: "DND5E.CONSUMPTION.Type.Attribute.NonEmbeddedHint",
    targetRequiresEmbedded: true,
    validTargets: ConsumptionTargetData.validAttributeTargets
  }
};
preLocalize("activityConsumptionTypes", { key: "label" });

/* -------------------------------------------- */

/**
 * Creature sizes ordered from smallest to largest.
 * @enum {ActorSizeConfiguration}
 */
DND5E.actorSizes = {
  tiny: {
    label: "DND5E.SizeTiny",
    abbreviation: "DND5E.SizeTinyAbbr",
    hitDie: 4,
    token: 0.5,
    capacityMultiplier: 0.5,
    numerical: 0
  },
  sm: {
    label: "DND5E.SizeSmall",
    abbreviation: "DND5E.SizeSmallAbbr",
    hitDie: 6,
    dynamicTokenScale: 0.8,
    numerical: 1
  },
  med: {
    label: "DND5E.SizeMedium",
    abbreviation: "DND5E.SizeMediumAbbr",
    hitDie: 8,
    numerical: 2
  },
  lg: {
    label: "DND5E.SizeLarge",
    abbreviation: "DND5E.SizeLargeAbbr",
    hitDie: 10,
    token: 2,
    capacityMultiplier: 2,
    numerical: 3
  },
  huge: {
    label: "DND5E.SizeHuge",
    abbreviation: "DND5E.SizeHugeAbbr",
    hitDie: 12,
    token: 3,
    capacityMultiplier: 4,
    numerical: 4
  },
  grg: {
    label: "DND5E.SizeGargantuan",
    abbreviation: "DND5E.SizeGargantuanAbbr",
    hitDie: 20,
    token: 4,
    capacityMultiplier: 8,
    numerical: 5
  }
};
preLocalize("actorSizes", { keys: ["label", "abbreviation"] });

/* -------------------------------------------- */
/*  Canvas                                      */
/* -------------------------------------------- */

/**
 * 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
};

/* -------------------------------------------- */

/**
 * Colors used when a dynamic token ring effects.
 * @enum {number}
 */
DND5E.tokenRingColors = {
  damage: 0xFF0000,
  defeated: 0x000000,
  healing: 0x00FF00,
  temp: 0x33AAFF
};

/* -------------------------------------------- */

/**
 * Colors used to denote movement speed on ruler segments & grid highlighting
 * @enum {number}
 */
DND5E.tokenRulerColors = {
  normal: 0x33BC4E,
  double: 0xF1D836,
  triple: 0xE72124
};

/* -------------------------------------------- */

/**
 * Settings used to render map location markers on the canvas.
 * @enum {MapLocationMarkerStyle}
 */
DND5E.mapLocationMarker = {
  default: {
    icon: MapLocationControlIcon,
    backgroundColor: 0xFBF8F5,
    borderColor: 0x000000,
    borderHoverColor: 0xFF5500,
    fontFamily: "Roboto Slab",
    shadowColor: 0x000000,
    textColor: 0x000000
  }
};

/* -------------------------------------------- */

/**
 * Default types of creatures.
 * @enum {CreatureTypeConfiguration}
 */
DND5E.creatureTypes = {
  aberration: {
    label: "DND5E.CreatureAberration",
    plural: "DND5E.CreatureAberrationPl",
    icon: "icons/creatures/tentacles/tentacle-eyes-yellow-pink.webp",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.yy50qVC1JhPHt4LC",
    detectAlignment: true
  },
  beast: {
    label: "DND5E.CreatureBeast",
    plural: "DND5E.CreatureBeastPl",
    icon: "icons/creatures/claws/claw-bear-paw-swipe-red.webp",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.6bTHn7pZek9YX2tv"
  },
  celestial: {
    label: "DND5E.CreatureCelestial",
    plural: "DND5E.CreatureCelestialPl",
    icon: "icons/creatures/abilities/wings-birdlike-blue.webp",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.T5CJwxjhBbi6oqaM",
    detectAlignment: true
  },
  construct: {
    label: "DND5E.CreatureConstruct",
    plural: "DND5E.CreatureConstructPl",
    icon: "icons/creatures/magical/construct-stone-earth-gray.webp",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.jQGAJZBZTqDFod8d"
  },
  dragon: {
    label: "DND5E.CreatureDragon",
    plural: "DND5E.CreatureDragonPl",
    icon: "icons/creatures/abilities/dragon-fire-breath-orange.webp",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.k2IRXZwGk9W0PM2S"
  },
  elemental: {
    label: "DND5E.CreatureElemental",
    plural: "DND5E.CreatureElementalPl",
    icon: "icons/creatures/magical/spirit-fire-orange.webp",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.7z1LXGGkXpHuzkFh",
    detectAlignment: true
  },
  fey: {
    label: "DND5E.CreatureFey",
    plural: "DND5E.CreatureFeyPl",
    icon: "icons/creatures/magical/fae-fairy-winged-glowing-green.webp",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.OFsRUt3pWljgm8VC",
    detectAlignment: true
  },
  fiend: {
    label: "DND5E.CreatureFiend",
    plural: "DND5E.CreatureFiendPl",
    icon: "icons/magic/death/skull-horned-goat-pentagram-red.webp",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.ElHKBJeiJPC7gj6k",
    detectAlignment: true
  },
  giant: {
    label: "DND5E.CreatureGiant",
    plural: "DND5E.CreatureGiantPl",
    icon: "icons/creatures/magical/humanoid-giant-forest-blue.webp",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.AOXn3Mv5vPZwo0Uf"
  },
  humanoid: {
    label: "DND5E.CreatureHumanoid",
    plural: "DND5E.CreatureHumanoidPl",
    icon: "icons/environment/people/group.webp",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.iFzQs4AenN8ALRvw"
  },
  monstrosity: {
    label: "DND5E.CreatureMonstrosity",
    plural: "DND5E.CreatureMonstrosityPl",
    icon: "icons/creatures/abilities/mouth-teeth-rows-red.webp",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.TX0yPEFTn79AMZ8P"
  },
  ooze: {
    label: "DND5E.CreatureOoze",
    plural: "DND5E.CreatureOozePl",
    icon: "icons/creatures/slimes/slime-movement-pseudopods-green.webp",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.cgzIC1ecG03D97Fg"
  },
  plant: {
    label: "DND5E.CreaturePlant",
    plural: "DND5E.CreaturePlantPl",
    icon: "icons/magic/nature/tree-animated-strike.webp",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.1oT7t6tHE4kZuSN1"
  },
  undead: {
    label: "DND5E.CreatureUndead",
    plural: "DND5E.CreatureUndeadPl",
    icon: "icons/magic/death/skull-horned-worn-fire-blue.webp",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.D2BdqS1GeD5rcZ6q",
    detectAlignment: true
  }
};
preLocalize("creatureTypes", { keys: ["label", "plural"], sort: true });

/* -------------------------------------------- */

/**
 * Classification types for item action types.
 * @enum {string}
 */
DND5E.itemActionTypes = {
  mwak: "DND5E.ActionMWAK",
  rwak: "DND5E.ActionRWAK",
  msak: "DND5E.ActionMSAK",
  rsak: "DND5E.ActionRSAK",
  abil: "DND5E.ActionAbil",
  save: "DND5E.ActionSave",
  ench: "DND5E.ActionEnch",
  summ: "DND5E.ActionSumm",
  heal: "DND5E.ActionHeal",
  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 {LimitedUsePeriodConfiguration}
 */
DND5E.limitedUsePeriods = {
  lr: {
    label: "DND5E.USES.Recovery.Period.LongRest.Label",
    abbreviation: "DND5E.USES.Recovery.Period.LongRest.Abbreviation"
  },
  sr: {
    label: "DND5E.USES.Recovery.Period.ShortRest.Label",
    abbreviation: "DND5E.USES.Recovery.Period.ShortRest.Abbreviation"
  },
  day: {
    label: "DND5E.USES.Recovery.Period.Day.Label",
    abbreviation: "DND5E.USES.Recovery.Period.Day.Label"
  },
  dawn: {
    label: "DND5E.USES.Recovery.Period.Dawn.Label",
    abbreviation: "DND5E.USES.Recovery.Period.Dawn.Label",
    formula: true
  },
  dusk: {
    label: "DND5E.USES.Recovery.Period.Dusk.Label",
    abbreviation: "DND5E.USES.Recovery.Period.Dusk.Label",
    formula: true
  },
  initiative: {
    label: "DND5E.USES.Recovery.Period.Initiative.Label",
    abbreviation: "DND5E.USES.Recovery.Period.Initiative.Label",
    type: "special"
  },
  turnStart: {
    label: "DND5E.USES.Recovery.Period.TurnStart.Label",
    abbreviation: "DND5E.USES.Recovery.Period.TurnStart.Abbreviation",
    type: "combat"
  },
  turnEnd: {
    label: "DND5E.USES.Recovery.Period.TurnEnd.Label",
    abbreviation: "DND5E.USES.Recovery.Period.TurnEnd.Abbreviation",
    type: "combat"
  },
  turn: {
    label: "DND5E.USES.Recovery.Period.Turn.Label",
    abbreviation: "DND5E.USES.Recovery.Period.Turn.Label",
    type: "combat"
  }
};
preLocalize("limitedUsePeriods", { keys: ["label", "abbreviation"] });

Object.defineProperty(DND5E.limitedUsePeriods, "recoveryOptions", {
  get() {
    return [
      ...Object.entries(CONFIG.DND5E.limitedUsePeriods)
        .filter(([, config]) => !config.deprecated)
        .map(([value, { label, type }]) => ({
          value, label, group: game.i18n.localize(`DND5E.USES.Recovery.${type?.capitalize() ?? "Time"}`)
        })),
      { value: "recharge", label: game.i18n.localize("DND5E.USES.Recovery.Recharge.Label") }
    ];
  }
});

/* -------------------------------------------- */

/**
 * Periods at which enchantments can be re-bound to new items.
 * @enum {{ label: string }}
 */
DND5E.enchantmentPeriods = {
  sr: {
    label: "DND5E.ENCHANTMENT.Period.ShortRest"
  },
  lr: {
    label: "DND5E.ENCHANTMENT.Period.LongRest"
  },
  atwill: {
    label: "DND5E.ENCHANTMENT.Period.AtWill"
  }
};
preLocalize("enchantmentPeriods", { key: "label" });

/* -------------------------------------------- */
/*  Armor                                       */
/* -------------------------------------------- */

/**
 * 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");

/* -------------------------------------------- */

/**
 * The set of Armor Proficiencies which a character may have.
 * @enum {string}
 */
DND5E.armorProficiencies = {
  lgt: "DND5E.ArmorLightProficiency",
  med: "DND5E.ArmorMediumProficiency",
  hvy: "DND5E.ArmorHeavyProficiency",
  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: "Compendium.dnd5e.equipment24.Item.phbarmBreastplat",
  chainmail: "Compendium.dnd5e.equipment24.Item.phbarmChainMail0",
  chainshirt: "Compendium.dnd5e.equipment24.Item.phbarmChainShirt",
  halfplate: "Compendium.dnd5e.equipment24.Item.phbarmHalfPlateA",
  hide: "Compendium.dnd5e.equipment24.Item.phbarmHideArmor0",
  leather: "Compendium.dnd5e.equipment24.Item.phbarmLeatherArm",
  padded: "Compendium.dnd5e.equipment24.Item.phbarmPaddedArmo",
  plate: "Compendium.dnd5e.equipment24.Item.phbarmPlateArmor",
  ringmail: "Compendium.dnd5e.equipment24.Item.phbarmRingMail00",
  scalemail: "Compendium.dnd5e.equipment24.Item.phbarmScaleMail0",
  splint: "Compendium.dnd5e.equipment24.Item.phbarmSplintArmo",
  studded: "Compendium.dnd5e.equipment24.Item.phbarmStuddedLea"
};

/* -------------------------------------------- */

/**
 * The basic shield in 5e.
 * @enum {string}
 */
DND5E.shieldIds = {
  shield: "Compendium.dnd5e.equipment24.Item.phbarmShield0000"
};

/* -------------------------------------------- */

/**
 * 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"
  },
  unarmoredBard: {
    label: "DND5E.ArmorClassUnarmoredBard",
    formula: "10 + @abilities.dex.mod + @abilities.cha.mod"
  },
  custom: {
    label: "DND5E.ArmorClassCustom"
  }
};
preLocalize("armorClasses", { key: "label" });

/* -------------------------------------------- */
/*  Other Equipment Types                       */
/* -------------------------------------------- */

/**
 * Equipment types that aren't armor.
 * @enum {string}
 */
DND5E.miscEquipmentTypes = {
  clothing: "DND5E.EQUIPMENT.Type.Clothing.Label",
  ring: "DND5E.EQUIPMENT.Type.Ring.Label",
  rod: "DND5E.EQUIPMENT.Type.Rod.Label",
  trinket: "DND5E.EQUIPMENT.Type.Trinket.Label",
  vehicle: "DND5E.EQUIPMENT.Type.Vehicle.Label",
  wand: "DND5E.EQUIPMENT.Type.Wand.Label",
  wondrous: "DND5E.EQUIPMENT.Type.Wondrous.Label"
};
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.VEHICLE.Type.Air.label",
  land: "DND5E.VEHICLE.Type.Land.label",
  space: "DND5E.VEHICLE.Type.Space.label",
  water: "DND5E.VEHICLE.Type.Water.label"
};
preLocalize("vehicleTypes", { sort: true });

/* -------------------------------------------- */

/**
 * Enumerate the valid consumable types which are recognized by the system.
 * @enum {SubtypeTypeConfiguration}
 */
DND5E.consumableTypes = {
  ammo: {
    label: "DND5E.CONSUMABLE.Type.Ammunition.Label",
    subtypes: {
      arrow: "DND5E.CONSUMABLE.Type.Ammunition.Arrow",
      crossbowBolt: "DND5E.CONSUMABLE.Type.Ammunition.Bolt",
      energyCell: "DND5E.CONSUMABLE.Type.Ammunition.EnergyCell",
      firearmBullet: "DND5E.CONSUMABLE.Type.Ammunition.BulletFirearm",
      slingBullet: "DND5E.CONSUMABLE.Type.Ammunition.BulletSling",
      blowgunNeedle: "DND5E.CONSUMABLE.Type.Ammunition.Needle"
    }
  },
  potion: {
    label: "DND5E.CONSUMABLE.Type.Potion.Label"
  },
  poison: {
    label: "DND5E.CONSUMABLE.Type.Poison.Label",
    subtypes: {
      contact: "DND5E.CONSUMABLE.Type.Poison.Contact",
      ingested: "DND5E.CONSUMABLE.Type.Poison.Ingested",
      inhaled: "DND5E.CONSUMABLE.Type.Poison.Inhaled",
      injury: "DND5E.CONSUMABLE.Type.Poison.Injury"
    }
  },
  food: {
    label: "DND5E.CONSUMABLE.Type.Food.Label"
  },
  scroll: {
    label: "DND5E.CONSUMABLE.Type.Scroll.Label"
  },
  wand: {
    label: "DND5E.CONSUMABLE.Type.Wand.Label"
  },
  rod: {
    label: "DND5E.CONSUMABLE.Type.Rod.Label"
  },
  trinket: {
    label: "DND5E.CONSUMABLE.Type.Trinket.Label"
  },
  wondrous: {
    label: "DND5E.CONSUMABLE.Type.Wondrous.Label"
  }
};
preLocalize("consumableTypes", { key: "label", sort: true });
preLocalize("consumableTypes.ammo.subtypes", { sort: true });
preLocalize("consumableTypes.poison.subtypes", { sort: true });

/* -------------------------------------------- */

/**
 * Types of containers.
 * @enum {string}
 */
DND5E.containerTypes = {
  backpack: "H8YCd689ezlD26aT",
  barrel: "7Yqbqg5EtVW16wfT",
  basket: "Wv7HzD6dv1P0q78N",
  boltcase: "eJtPBiZtr2pp6ynt",
  bottle: "HZp69hhyNZUUCipF",
  bucket: "mQVYcHmMSoCUnBnM",
  case: "5mIeX824uMklU3xq",
  chest: "2YbuclKfhDL0bU4u",
  flask: "lHS63sC6bypENNlR",
  jug: "0ZBWwjFz3nIAXMLW",
  pot: "M8xM8BLK4tpUayEE",
  pitcher: "nXWdGtzi8DXDLLsL",
  pouch: "9bWTRRDym06PzSAf",
  quiver: "4MtQKPn9qMWCFjDA",
  sack: "CNdDj8dsXVpRVpXt",
  saddlebags: "TmfaFUSZJAotndn9",
  tankard: "uw6fINSmZ2j2o57A",
  vial: "meJEfX3gZgtMX4x2"
};

/* -------------------------------------------- */

/**
 * Type of spellcasting foci.
 * @enum {SpellcastingFocusConfiguration}
 */
DND5E.focusTypes = {
  arcane: {
    label: "DND5E.Focus.Arcane",
    itemIds: {
      crystal: "Compendium.dnd5e.equipment24.Item.phbafcCrystal000",
      orb: "Compendium.dnd5e.equipment24.Item.phbafcOrb0000000",
      rod: "Compendium.dnd5e.equipment24.Item.phbafcRod0000000",
      staff: "Compendium.dnd5e.equipment24.Item.phbafcStaffalsoa",
      wand: "Compendium.dnd5e.equipment24.Item.phbafcWand000000"
    }
  },
  druidic: {
    label: "DND5E.Focus.Druidic",
    itemIds: {
      mistletoe: "Compendium.dnd5e.equipment24.Item.phbdfcSprigofmis",
      woodenstaff: "Compendium.dnd5e.equipment24.Item.phbdfcWoodenstaf",
      yewwand: "Compendium.dnd5e.equipment24.Item.phbdfcYewwand000"
    }
  },
  holy: {
    label: "DND5E.Focus.Holy",
    itemIds: {
      amulet: "Compendium.dnd5e.equipment24.Item.phbhsyAmuletworn",
      emblem: "Compendium.dnd5e.equipment24.Item.phbhsyEmblemborn",
      reliquary: "Compendium.dnd5e.equipment24.Item.phbhsyReliquaryh"
    }
  }
};
preLocalize("focusTypes", { key: "label" });

/* -------------------------------------------- */

/**
 * Types of "features" items.
 * @enum {SubtypeTypeConfiguration}
 */
DND5E.featureTypes = {
  background: {
    label: "DND5E.Feature.Background"
  },
  class: {
    label: "DND5E.Feature.Class.Label",
    subtypes: {
      arcaneShot: "DND5E.Feature.Class.ArcaneShot",
      artificerInfusion: "DND5E.Feature.Class.ArtificerPlan",
      channelDivinity: "DND5E.Feature.Class.ChannelDivinity",
      defensiveTactic: "DND5E.Feature.Class.DefensiveTactic",
      eldritchInvocation: "DND5E.Feature.Class.EldritchInvocation",
      elementalDiscipline: "DND5E.Feature.Class.ElementalDiscipline",
      fightingStyle: "DND5E.Feature.Class.FightingStyle",
      huntersPrey: "DND5E.Feature.Class.HuntersPrey",
      ki: "DND5E.Feature.Class.Ki",
      maneuver: "DND5E.Feature.Class.Maneuver",
      metamagic: "DND5E.Feature.Class.Metamagic",
      multiattack: "DND5E.Feature.Class.Multiattack",
      pact: "DND5E.Feature.Class.PactBoon",
      psionicPower: "DND5E.Feature.Class.PsionicPower",
      rune: "DND5E.Feature.Class.Rune",
      superiorHuntersDefense: "DND5E.Feature.Class.SuperiorHuntersDefense"
    }
  },
  monster: {
    label: "DND5E.Feature.Monster"
  },
  race: {
    label: "DND5E.Feature.Species"
  },
  enchantment: {
    label: "DND5E.ENCHANTMENT.Label",
    subtypes: {
      artificerInfusion: "DND5E.Feature.Class.ArtificerPlan",
      rune: "DND5E.Feature.Class.Rune"
    }
  },
  feat: {
    label: "DND5E.Feature.Feat.Label",
    subtypes: {
      general: "DND5E.Feature.Feat.General",
      origin: "DND5E.Feature.Feat.Origin",
      fightingStyle: "DND5E.Feature.Feat.FightingStyle",
      epicBoon: "DND5E.Feature.Feat.EpicBoon"
    }
  },
  supernaturalGift: {
    label: "DND5E.Feature.SupernaturalGift.Label",
    subtypes: {
      blessing: "DND5E.Feature.SupernaturalGift.Blessing",
      charm: "DND5E.Feature.SupernaturalGift.Charm",
      epicBoon: "DND5E.Feature.SupernaturalGift.EpicBoon"
    }
  },
  vehicle: {
    label: "DND5E.Feature.Vehicle.Label"
  }
};
preLocalize("featureTypes", { key: "label" });
preLocalize("featureTypes.class.subtypes", { sort: true });
preLocalize("featureTypes.enchantment.subtypes", { sort: true });
preLocalize("featureTypes.feat.subtypes", { sort: true });
preLocalize("featureTypes.supernaturalGift.subtypes", { sort: true });

/* -------------------------------------------- */

/**
 * The various properties of all item types.
 * @enum {ItemPropertyConfiguration}
 */
DND5E.itemProperties = {
  ada: {
    label: "DND5E.ITEM.Property.Adamantine",
    isPhysical: true
  },
  amm: {
    label: "DND5E.ITEM.Property.Ammunition"
  },
  concentration: {
    label: "DND5E.ITEM.Property.Concentration",
    abbreviation: "DND5E.ConcentrationAbbr",
    icon: "systems/dnd5e/icons/svg/statuses/concentrating.svg",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.ow58p27ctAnr4VPH",
    isTag: true
  },
  fin: {
    label: "DND5E.ITEM.Property.Finesse"
  },
  fir: {
    label: "DND5E.ITEM.Property.Firearm"
  },
  foc: {
    label: "DND5E.ITEM.Property.Focus"
  },
  hvy: {
    label: "DND5E.ITEM.Property.Heavy"
  },
  lgt: {
    label: "DND5E.ITEM.Property.Light"
  },
  lod: {
    label: "DND5E.ITEM.Property.Loading"
  },
  material: {
    label: "DND5E.ITEM.Property.Material",
    abbreviation: "DND5E.ComponentMaterialAbbr",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.AeH5eDS4YeM9RETC"
  },
  mgc: {
    label: "DND5E.ITEM.Property.Magical",
    icon: "systems/dnd5e/icons/svg/properties/magical.svg",
    isPhysical: true
  },
  rch: {
    label: "DND5E.ITEM.Property.Reach"
  },
  rel: {
    label: "DND5E.ITEM.Property.Reload"
  },
  ret: {
    label: "DND5E.ITEM.Property.Returning"
  },
  ritual: {
    label: "DND5E.ITEM.Property.Ritual",
    abbreviation: "DND5E.RitualAbbr",
    icon: "systems/dnd5e/icons/svg/items/spell.svg",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.FjWqT5iyJ89kohdA",
    isTag: true
  },
  sidekick: {
    label: "DND5E.ITEM.Property.Sidekick"
  },
  sil: {
    label: "DND5E.ITEM.Property.Silvered",
    isPhysical: true
  },
  somatic: {
    label: "DND5E.ITEM.Property.Somatic",
    abbreviation: "DND5E.ComponentSomaticAbbr",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.qwUNgUNilEmZkSC9"
  },
  spc: {
    label: "DND5E.ITEM.Property.Special"
  },
  stealthDisadvantage: {
    label: "DND5E.ITEM.Property.StealthDisadvantage"
  },
  thr: {
    label: "DND5E.ITEM.Property.Thrown"
  },
  trait: {
    label: "DND5E.ITEM.Property.Trait"
  },
  two: {
    label: "DND5E.ITEM.Property.TwoHanded"
  },
  ver: {
    label: "DND5E.ITEM.Property.Versatile"
  },
  vocal: {
    label: "DND5E.ITEM.Property.Verbal",
    abbreviation: "DND5E.ComponentVerbalAbbr",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.6UXTNWMCQ0nSlwwx"
  },
  weightlessContents: {
    label: "DND5E.ITEM.Property.WeightlessContents"
  }
};
preLocalize("itemProperties", { keys: ["label", "abbreviation"], sort: true });

/* -------------------------------------------- */

/**
 * The various properties of an item per item type.
 * @enum {object}
 */
DND5E.validProperties = {
  class: new Set([
    "sidekick"
  ]),
  consumable: new Set([
    "mgc"
  ]),
  container: new Set([
    "mgc",
    "weightlessContents"
  ]),
  equipment: new Set([
    "ada",
    "foc",
    "mgc",
    "stealthDisadvantage"
  ]),
  feat: new Set([
    "mgc",
    "trait"
  ]),
  loot: new Set([
    "mgc"
  ]),
  weapon: new Set([
    "ada",
    "amm",
    "fin",
    "fir",
    "foc",
    "hvy",
    "lgt",
    "lod",
    "mgc",
    "rch",
    "rel",
    "ret",
    "sil",
    "spc",
    "thr",
    "two",
    "ver"
  ]),
  spell: new Set([
    "vocal",
    "somatic",
    "material",
    "concentration",
    "ritual"
  ]),
  tool: new Set([
    "foc",
    "mgc"
  ])
};

/* -------------------------------------------- */

/**
 * Types of "loot" items.
 * @enum {{ label: string }}
 */
DND5E.lootTypes = {
  art: {
    label: "DND5E.Loot.Art"
  },
  gear: {
    label: "DND5E.Loot.Gear"
  },
  gem: {
    label: "DND5E.Loot.Gem"
  },
  junk: {
    label: "DND5E.Loot.Junk"
  },
  material: {
    label: "DND5E.Loot.Material"
  },
  resource: {
    label: "DND5E.Loot.Resource"
  },
  trade: {
    label: "DND5E.Loot.Trade"
  },
  treasure: {
    label: "DND5E.Loot.Treasure"
  }
};
preLocalize("lootTypes", { key: "label" });

/* -------------------------------------------- */

/**
 * 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,
    icon: "systems/dnd5e/icons/currency/platinum.webp"
  },
  gp: {
    label: "DND5E.CurrencyGP",
    abbreviation: "DND5E.CurrencyAbbrGP",
    conversion: 1,
    icon: "systems/dnd5e/icons/currency/gold.webp"
  },
  ep: {
    label: "DND5E.CurrencyEP",
    abbreviation: "DND5E.CurrencyAbbrEP",
    conversion: 2,
    icon: "systems/dnd5e/icons/currency/electrum.webp"
  },
  sp: {
    label: "DND5E.CurrencySP",
    abbreviation: "DND5E.CurrencyAbbrSP",
    conversion: 10,
    icon: "systems/dnd5e/icons/currency/silver.webp"
  },
  cp: {
    label: "DND5E.CurrencyCP",
    abbreviation: "DND5E.CurrencyAbbrCP",
    conversion: 100,
    icon: "systems/dnd5e/icons/currency/copper.webp"
  }
};
preLocalize("currencies", { keys: ["label", "abbreviation"] });

/* -------------------------------------------- */

/**
 * Configuration data for crafting costs.
 * @type {CraftingConfiguration}
 */
DND5E.crafting = {
  consumable: {
    days: .5,
    gold: .5
  },
  exceptions: {
    "potion-of-healing": {
      days: 1,
      gold: 25
    }
  },
  magic: {
    common: {
      days: 5,
      gold: 50
    },
    uncommon: {
      days: 10,
      gold: 200
    },
    rare: {
      days: 50,
      gold: 2_000
    },
    veryRare: {
      days: 125,
      gold: 20_000
    },
    legendary: {
      days: 250,
      gold: 100_000
    }
  },
  mundane: {
    days: .1,
    gold: .5
  },
  scrolls: {
    0: {
      days: 1,
      gold: 15
    },
    1: {
      days: 1,
      gold: 25
    },
    2: {
      days: 3,
      gold: 100
    },
    3: {
      days: 5,
      gold: 150
    },
    4: {
      days: 10,
      gold: 1_000
    },
    5: {
      days: 25,
      gold: 1_500
    },
    6: {
      days: 40,
      gold: 10_000
    },
    7: {
      days: 50,
      gold: 12_500
    },
    8: {
      days: 60,
      gold: 15_000
    },
    9: {
      days: 120,
      gold: 50_000
    }
  }
};

/* -------------------------------------------- */
/*  Damage                                      */
/* -------------------------------------------- */

/**
 * Standard dice spread available for things like damage.
 * @type {number[]}
 */
DND5E.dieSteps = [4, 6, 8, 10, 12, 20, 100];

/* -------------------------------------------- */

/**
 * Methods by which damage scales relative to the overall scaling increase.
 * @enum {{ label: string, labelCantrip: string }}
 */
DND5E.damageScalingModes = {
  whole: {
    label: "DND5E.DAMAGE.Scaling.Whole",
    labelCantrip: "DND5E.DAMAGE.Scaling.WholeCantrip"
  },
  half: {
    label: "DND5E.DAMAGE.Scaling.Half",
    labelCantrip: "DND5E.DAMAGE.Scaling.HalfCantrip"
  }
};
preLocalize("damageScalingModes", { keys: ["label", "labelCantrip"] });

/* -------------------------------------------- */

/**
 * Types of damage the can be caused by abilities.
 * @enum {DamageTypeConfiguration}
 */
DND5E.damageTypes = {
  acid: {
    label: "DND5E.DamageAcid",
    icon: "systems/dnd5e/icons/svg/damage/acid.svg",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.IQhbKRPe1vCPdh8v",
    color: new Color(0x839D50)
  },
  bludgeoning: {
    label: "DND5E.DamageBludgeoning",
    icon: "systems/dnd5e/icons/svg/damage/bludgeoning.svg",
    isPhysical: true,
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.39LFrlef94JIYO8m",
    color: new Color(0x0000A0)
  },
  cold: {
    label: "DND5E.DamageCold",
    icon: "systems/dnd5e/icons/svg/damage/cold.svg",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.4xsFUooHDEdfhw6g",
    color: new Color(0xADD8E6)
  },
  fire: {
    label: "DND5E.DamageFire",
    icon: "systems/dnd5e/icons/svg/damage/fire.svg",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.f1S66aQJi4PmOng6",
    color: new Color(0xFF4500)
  },
  force: {
    label: "DND5E.DamageForce",
    icon: "systems/dnd5e/icons/svg/damage/force.svg",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.eFTWzngD8dKWQuUR",
    color: new Color(0x800080)
  },
  lightning: {
    label: "DND5E.DamageLightning",
    icon: "systems/dnd5e/icons/svg/damage/lightning.svg",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.9SaxFJ9bM3SutaMC",
    color: new Color(0x1E90FF)
  },
  necrotic: {
    label: "DND5E.DamageNecrotic",
    icon: "systems/dnd5e/icons/svg/damage/necrotic.svg",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.klOVUV5G1U7iaKoG",
    color: new Color(0x006400)
  },
  piercing: {
    label: "DND5E.DamagePiercing",
    icon: "systems/dnd5e/icons/svg/damage/piercing.svg",
    isPhysical: true,
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.95agSnEGTdAmKhyC",
    color: new Color(0xC0C0C0)
  },
  poison: {
    label: "DND5E.DamagePoison",
    icon: "systems/dnd5e/icons/svg/damage/poison.svg",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.k5wOYXdWPzcWwds1",
    color: new Color(0x8A2BE2)
  },
  psychic: {
    label: "DND5E.DamagePsychic",
    icon: "systems/dnd5e/icons/svg/damage/psychic.svg",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.YIKbDv4zYqbE5teJ",
    color: new Color(0xFF1493)
  },
  radiant: {
    label: "DND5E.DamageRadiant",
    icon: "systems/dnd5e/icons/svg/damage/radiant.svg",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.5tcK9buXWDOw8yHH",
    color: new Color(0xFFD700)
  },
  slashing: {
    label: "DND5E.DamageSlashing",
    icon: "systems/dnd5e/icons/svg/damage/slashing.svg",
    isPhysical: true,
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.sz2XKQ5lgsdPEJOa",
    color: new Color(0x8B0000)
  },
  thunder: {
    label: "DND5E.DamageThunder",
    icon: "systems/dnd5e/icons/svg/damage/thunder.svg",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.iqsmMHk7FSpiNkQy",
    color: new Color(0x708090)
  }
};
preLocalize("damageTypes", { keys: ["label"], sort: true });

/* -------------------------------------------- */

/**
 * Display aggregated damage in chat cards.
 * @type {boolean}
 */
DND5E.aggregateDamageDisplay = true;

/* -------------------------------------------- */

/**
 * Different types of healing that can be applied using abilities.
 * @enum {DamageTypeConfiguration}
 */
DND5E.healingTypes = {
  healing: {
    label: "DND5E.Healing",
    icon: "systems/dnd5e/icons/svg/damage/healing.svg",
    color: new Color(0x46C252)
  },
  temphp: {
    label: "DND5E.HealingTemp",
    icon: "systems/dnd5e/icons/svg/damage/temphp.svg",
    color: new Color(0x4B66DE)
  }
};
preLocalize("healingTypes", { keys: ["label"] });

/* -------------------------------------------- */
/*  Movement                                    */
/* -------------------------------------------- */

/**
 * Types of terrain that can cause difficult terrain.
 * @enum {{ label: string }}
 */
DND5E.difficultTerrainTypes = {
  ice: {
    label: "DND5E.REGIONBEHAVIORS.DIFFICULTTERRAIN.Type.Ice"
  },
  liquid: {
    label: "DND5E.REGIONBEHAVIORS.DIFFICULTTERRAIN.Type.Liquid"
  },
  plants: {
    label: "DND5E.REGIONBEHAVIORS.DIFFICULTTERRAIN.Type.Plants"
  },
  rocks: {
    label: "DND5E.REGIONBEHAVIORS.DIFFICULTTERRAIN.Type.Rocks"
  },
  mud: {
    label: "DND5E.REGIONBEHAVIORS.DIFFICULTTERRAIN.Type.Mud"
  },
  sand: {
    label: "DND5E.REGIONBEHAVIORS.DIFFICULTTERRAIN.Type.Sand"
  },
  slope: {
    label: "DND5E.REGIONBEHAVIORS.DIFFICULTTERRAIN.Type.Slope"
  },
  snow: {
    label: "DND5E.REGIONBEHAVIORS.DIFFICULTTERRAIN.Type.Snow"
  },
  web: {
    label: "DND5E.REGIONBEHAVIORS.DIFFICULTTERRAIN.Type.Webs"
  }
};
preLocalize("difficultTerrainTypes", { key: "label", sort: true });

/* -------------------------------------------- */

/**
 * Types of movement supported by creature actors in the system.
 * @enum {MovementTypeConfiguration}
 */
DND5E.movementTypes = {
  walk: {
    label: "DND5E.MOVEMENT.Type.Speed"
  },
  burrow: {
    label: "DND5E.MOVEMENT.Type.Burrow"
  },
  climb: {
    label: "DND5E.MOVEMENT.Type.Climb",
    walkFallback: true
  },
  fly: {
    label: "DND5E.MOVEMENT.Type.Fly",
    travel: "air"
  },
  swim: {
    label: "DND5E.MOVEMENT.Type.Swim",
    travel: "water",
    walkFallback: true
  }
};
preLocalize("movementTypes", { key: "label" });
patchConfig("movementTypes", "label", { since: "DnD5e 5.1", until: "DnD5e 5.3" });

/* -------------------------------------------- */

/**
 * Default number of hours per day traveled by specific actor types.
 * @enum {number}
 */
DND5E.travelTimes = {
  group: 8,
  vehicle: 24
};

/* -------------------------------------------- */

/**
 * Types of movement supported by creature actors in the system.
 * @enum {Omit<MovementTypeConfiguration, "travel">}
 */
DND5E.travelTypes = {
  land: {
    label: "DND5E.TRAVEL.Type.Land"
  },
  water: {
    label: "DND5E.TRAVEL.Type.Water"
  },
  air: {
    label: "DND5E.TRAVEL.Type.Air"
  }
};
preLocalize("travelTypes", { key: "label" });

/* -------------------------------------------- */

/**
 * Available travel paces.
 * @type {Readonly<Record<string, TravelPaceConfiguration>>}
 */
DND5E.travelPace = Object.freeze({
  slow: {
    label: "DND5E.TRAVEL.Pace.Slow",
    standard: 18,
    multiplier: 2 / 3,
    round: "down"
  },
  normal: {
    label: "DND5E.TRAVEL.Pace.Normal",
    standard: 24,
    multiplier: 1,
    round: "down"
  },
  fast: {
    label: "DND5E.TRAVEL.Pace.Fast",
    standard: 30,
    multiplier: 4 / 3,
    round: "down"
  }
});
preLocalize("travelPace", { key: "label" });

/* -------------------------------------------- */
/*  Measurement                                 */
/* -------------------------------------------- */

/**
 * Default units used for imperial & metric settings.
 * @enum {{ imperial: string, metric: string }}
 */
DND5E.defaultUnits = {
  length: {
    imperial: "ft",
    metric: "m"
  },
  travel: {
    imperial: "mph",
    metric: "kph"
  },
  volume: {
    imperial: "cubicFoot",
    metric: "liter"
  },
  weight: {
    imperial: "lb",
    metric: "kg"
  }
};

/* -------------------------------------------- */

/**
 * The valid units of measure for movement distances in the game system.
 * @enum {MovementUnitConfiguration}
 */
DND5E.movementUnits = {
  ft: {
    label: "DND5E.UNITS.DISTANCE.Foot.Label",
    abbreviation: "DND5E.UNITS.DISTANCE.Foot.Abbreviation",
    conversion: 1,
    formattingUnit: "foot",
    type: "imperial",
    travelResolution: "round"
  },
  mi: {
    label: "DND5E.UNITS.DISTANCE.Mile.Label",
    abbreviation: "DND5E.UNITS.DISTANCE.Mile.Abbreviation",
    conversion: 5_280,
    formattingUnit: "mile",
    type: "imperial",
    travelResolution: "day"
  },
  m: {
    label: "DND5E.UNITS.DISTANCE.Meter.Label",
    abbreviation: "DND5E.UNITS.DISTANCE.Meter.Abbreviation",
    conversion: 10 / 3, // D&D uses a simplified 5ft -> 1.5m conversion.
    formattingUnit: "meter",
    type: "metric",
    travelResolution: "round"
  },
  km: {
    label: "DND5E.UNITS.DISTANCE.Kilometer.Label",
    abbreviation: "DND5E.UNITS.DISTANCE.Kilometer.Abbreviation",
    conversion: 10_000 / 3, // Matching simplified conversion
    formattingUnit: "kilometer",
    type: "metric",
    travelResolution: "day"
  }
};
preLocalize("movementUnits", { keys: ["label", "abbreviation"] });

/* -------------------------------------------- */

/**
 * The valid units for measuring travel speed. When being formatted, the formatting unit will be combined with
 * `-per-hour` or `-per-day` to result in the final unit passed to `Intl.NumberFormat`.
 * @enum {TravelUnitConfiguration}
 */
DND5E.travelUnits = {
  mph: {
    label: "DND5E.UNITS.TRAVEL.Mile.Label",
    abbreviationDay: "DND5E.UNITS.TRAVEL.Mile.AbbreviationDay",
    abbreviationHour: "DND5E.UNITS.TRAVEL.Mile.AbbreviationHour",
    formattingUnit: "mile",
    conversion: 1,
    type: "imperial"
  },
  kph: {
    label: "DND5E.UNITS.TRAVEL.Kilometer.Label",
    abbreviationDay: "DND5E.UNITS.TRAVEL.Kilometer.AbbreviationDay",
    abbreviationHour: "DND5E.UNITS.TRAVEL.Kilometer.AbbreviationHour",
    formattingUnit: "kilometer",
    conversion: 0.6,
    type: "metric"
  }
};
preLocalize("travelUnits", { keys: ["label", "abbreviationDay", "abbreviationHour"] });

/* -------------------------------------------- */

/**
 * 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 = {
  ...Object.fromEntries(Object.entries(DND5E.movementUnits).map(([k, { label }]) => [k, label])),
  ...DND5E.rangeTypes
};
preLocalize("distanceUnits");

/* -------------------------------------------- */

/**
 * The valid units for measurement of volume.
 * @enum {UnitConfiguration}
 */
DND5E.volumeUnits = {
  cubicFoot: {
    label: "DND5E.UNITS.VOLUME.CubicFoot.Label",
    abbreviation: "DND5E.UNITS.Volume.CubicFoot.Abbreviation",
    counted: "DND5E.UNITS.Volume.CubicFoot.Counted",
    conversion: 1,
    type: "imperial"
  },
  liter: {
    label: "DND5E.UNITS.VOLUME.Liter.Label",
    abbreviation: "DND5E.UNITS.Volume.Liter.Abbreviation",
    conversion: 1 / 28.317,
    type: "metric"
  }
};
preLocalize("volumeUnits", { keys: ["label", "abbreviation"] });

/* -------------------------------------------- */

/**
 * The valid units for measurement of weight.
 * @enum {UnitConfiguration}
 */
DND5E.weightUnits = {
  lb: {
    label: "DND5E.UNITS.WEIGHT.Pound.Label",
    abbreviation: "DND5E.UNITS.WEIGHT.Pound.Abbreviation",
    conversion: 1,
    formattingUnit: "pound",
    type: "imperial"
  },
  tn: {
    label: "DND5E.UNITS.WEIGHT.Ton.Label",
    abbreviation: "DND5E.UNITS.WEIGHT.Ton.Abbreviation",
    counted: "DND5E.UNITS.WEIGHT.Ton.Counted",
    conversion: 2000,
    type: "imperial"
  },
  kg: {
    label: "DND5E.UNITS.WEIGHT.Kilogram.Label",
    abbreviation: "DND5E.UNITS.WEIGHT.Kilogram.Abbreviation",
    conversion: 2.5,
    formattingUnit: "kilogram",
    type: "metric"
  },
  Mg: {
    label: "DND5E.UNITS.WEIGHT.Megagram.Label",
    abbreviation: "DND5E.UNITS.WEIGHT.Megagram.Abbreviation",
    counted: "DND5E.UNITS.WEIGHT.Megagram.Counted",
    conversion: 2500,
    type: "metric"
  }
};
preLocalize("weightUnits", { keys: ["label", "abbreviation"] });

/* -------------------------------------------- */

/**
 * Configure aspects of encumbrance calculation so that it could be configured by modules.
 * @type {EncumbranceConfiguration}
 */
DND5E.encumbrance = {
  currencyPerWeight: {
    imperial: 50,
    metric: 110
  },
  draftMultiplier: 5,
  effects: {
    encumbered: {
      name: "EFFECT.DND5E.StatusEncumbered",
      img: "systems/dnd5e/icons/svg/statuses/encumbered.svg"
    },
    heavilyEncumbered: {
      name: "EFFECT.DND5E.StatusHeavilyEncumbered",
      img: "systems/dnd5e/icons/svg/statuses/heavily-encumbered.svg"
    },
    exceedingCarryingCapacity: {
      name: "EFFECT.DND5E.StatusExceedingCarryingCapacity",
      img: "systems/dnd5e/icons/svg/statuses/exceeding-carrying-capacity.svg"
    }
  },
  threshold: {
    encumbered: {
      imperial: 5,
      metric: 2.5
    },
    heavilyEncumbered: {
      imperial: 10,
      metric: 5
    },
    maximum: {
      imperial: 15,
      metric: 7.5
    }
  },
  speedReduction: {
    encumbered: {
      ft: 10,
      m: 3
    },
    heavilyEncumbered: {
      ft: 20,
      m: 6
    },
    exceedingCarryingCapacity: {
      ft: 5,
      m: 1.5
    }
  },
  baseUnits: {
    default: {
      imperial: "lb",
      metric: "kg"
    }
  }
};
preLocalize("encumbrance.effects", { key: "name" });

/* -------------------------------------------- */
/*  Targeting                                   */
/* -------------------------------------------- */

/**
 * Targeting types that apply to one or more distinct targets.
 * @enum {IndividualTargetDefinition}
 */
DND5E.individualTargetTypes = {
  self: {
    label: "DND5E.TARGET.Type.Self.Label",
    scalar: false
  },
  ally: {
    label: "DND5E.TARGET.Type.Ally.Label",
    counted: "DND5E.TARGET.Type.Ally.Counted"
  },
  enemy: {
    label: "DND5E.TARGET.Type.Enemy.Label",
    counted: "DND5E.TARGET.Type.Enemy.Counted"
  },
  creature: {
    label: "DND5E.TARGET.Type.Creature.Label",
    counted: "DND5E.TARGET.Type.Creature.Counted"
  },
  object: {
    label: "DND5E.TARGET.Type.Object.Label",
    counted: "DND5E.TARGET.Type.Object.Counted"
  },
  space: {
    label: "DND5E.TARGET.Type.Space.Label",
    counted: "DND5E.TARGET.Type.Space.Counted"
  },
  creatureOrObject: {
    label: "DND5E.TARGET.Type.CreatureOrObject.Label",
    counted: "DND5E.TARGET.Type.CreatureOrObject.Counted"
  },
  any: {
    label: "DND5E.TARGET.Type.Any.Label",
    counted: "DND5E.TARGET.Type.Target.Counted"
  },
  willing: {
    label: "DND5E.TARGET.Type.WillingCreature.Label",
    counted: "DND5E.TARGET.Type.WillingCreature.Counted"
  }
};
preLocalize("individualTargetTypes", { key: "label" });

/* -------------------------------------------- */

/**
 * Targeting types that cover an area.
 * @enum {AreaTargetDefinition}
 */
DND5E.areaTargetTypes = {
  circle: {
    label: "DND5E.TARGET.Type.Circle.Label",
    counted: "DND5E.TARGET.Type.Circle.Counted",
    template: "circle",
    sizes: ["radius"]
  },
  cone: {
    label: "DND5E.TARGET.Type.Cone.Label",
    counted: "DND5E.TARGET.Type.Cone.Counted",
    template: "cone",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.DqqAOr5JnX71OCOw",
    sizes: ["length"],
    standard: true
  },
  cube: {
    label: "DND5E.TARGET.Type.Cube.Label",
    counted: "DND5E.TARGET.Type.Cube.Counted",
    template: "rect",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.dRfDIwuaHmUQ06uA",
    sizes: ["width"],
    standard: true
  },
  cylinder: {
    label: "DND5E.TARGET.Type.Cylinder.Label",
    counted: "DND5E.TARGET.Type.Cylinder.Counted",
    template: "circle",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.jZFp4R7tXsIqkiG3",
    sizes: ["radius", "height"],
    standard: true
  },
  line: {
    label: "DND5E.TARGET.Type.Line.Label",
    counted: "DND5E.TARGET.Type.Line.Counted",
    template: "ray",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.6DOoBgg7okm9gBc6",
    sizes: ["length", "width"],
    standard: true
  },
  radius: {
    label: "DND5E.TARGET.Type.Emanation.Label",
    counted: "DND5E.TARGET.Type.Emanation.Counted",
    template: "circle",
    standard: true
  },
  sphere: {
    label: "DND5E.TARGET.Type.Sphere.Label",
    counted: "DND5E.TARGET.Type.Sphere.Counted",
    template: "circle",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.npdEWb2egUPnB5Fa",
    sizes: ["radius"],
    standard: true
  },
  square: {
    label: "DND5E.TARGET.Type.Square.Label",
    counted: "DND5E.TARGET.Type.Square.Counted",
    template: "rect",
    sizes: ["width"]
  },
  wall: {
    label: "DND5E.TARGET.Type.Wall.Label",
    counted: "DND5E.TARGET.Type.Wall.Counted",
    template: "ray",
    sizes: ["length", "thickness", "height"]
  }
};
preLocalize("areaTargetTypes", { key: "label", sort: true });

Object.defineProperty(DND5E, "areaTargetOptions", {
  get() {
    const { primary, secondary } = Object.entries(this.areaTargetTypes).reduce((obj, [value, data]) => {
      const entry = { value, label: data.label };
      if ( data.standard ) obj.primary.push(entry);
      else obj.secondary.push(entry);
      return obj;
    }, { primary: [], secondary: [] });
    return [{ value: "", label: "" }, ...primary, { rule: true }, ...secondary];
  }
});

/* -------------------------------------------- */

/**
 * The types of single or area targets which can be applied to abilities.
 * @enum {string}
 */
DND5E.targetTypes = {
  ...Object.fromEntries(Object.entries(DND5E.individualTargetTypes).map(([k, v]) => [k, v.label])),
  ...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"];

/* -------------------------------------------- */

/**
 * Types of rests.
 * @enum {RestTypeConfiguration}
 */
DND5E.restTypes = {
  short: {
    duration: {
      normal: 60,
      gritty: 480,
      epic: 1
    },
    label: "DND5E.REST.Short.Label",
    icon: "fa-solid fa-utensils",
    activationPeriods: ["shortRest"],
    recoverPeriods: ["sr"],
    recoverSpellSlotTypes: new Set(["pact"])
  },
  long: {
    duration: {
      normal: 480,
      gritty: 10_080,
      epic: 60
    },
    exhaustionDelta: -1,
    label: "DND5E.REST.Long.Label",
    icon: "fa-solid fa-campground",
    activationPeriods: ["longRest"],
    recoverHitDice: true,
    recoverHitPoints: true,
    recoverPeriods: ["lr", "sr"],
    recoverSpellSlotTypes: new Set(["spell", "pact"]),
    recoverTemp: true,
    recoverTempMax: true
  }
};
preLocalize("restTypes", { key: "label" });

/* -------------------------------------------- */

/**
 * 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 });

/* -------------------------------------------- */
/*  Attacks                                     */
/* -------------------------------------------- */

/**
 * Classifications of attacks based on what is performing them.
 * @enum {{ label: string }}
 */
DND5E.attackClassifications = {
  weapon: {
    label: "DND5E.ATTACK.Classification.Weapon"
  },
  spell: {
    label: "DND5E.ATTACK.Classification.Spell"
  },
  unarmed: {
    label: "DND5E.ATTACK.Classification.Unarmed"
  }
};
preLocalize("attackClassifications", { key: "label" });

/* -------------------------------------------- */

/**
 * Attack modes available for weapons.
 * @enum {string}
 */
DND5E.attackModes = Object.seal({
  oneHanded: {
    label: "DND5E.ATTACK.Mode.OneHanded"
  },
  twoHanded: {
    label: "DND5E.ATTACK.Mode.TwoHanded"
  },
  offhand: {
    label: "DND5E.ATTACK.Mode.Offhand"
  },
  ranged: {
    label: "DND5E.ATTACK.Mode.Ranged"
  },
  thrown: {
    label: "DND5E.ATTACK.Mode.Thrown"
  },
  "thrown-offhand": {
    label: "DND5E.ATTACK.Mode.ThrownOffhand"
  }
});
preLocalize("attackModes", { key: "label" });

/* -------------------------------------------- */

/**
 * Types of attacks based on range.
 * @enum {{ label: string }}
 */
DND5E.attackTypes = Object.seal({
  melee: {
    label: "DND5E.ATTACK.Type.Melee"
  },
  ranged: {
    label: "DND5E.ATTACK.Type.Ranged"
  }
});
preLocalize("attackTypes", { key: "label" });

/* -------------------------------------------- */
/*  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 {SpellcastingTable5e}
 */
const SPELL_SLOT_TABLE = 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]
];

/* -------------------------------------------- */

/**
 * Define the pact slot & level progression by pact caster level.
 * @type {SpellcastingTableSingle5e}
 */
const pactCastingProgression = DND5E.pactCastingProgression = {
  1: { slots: 1, level: 1 },
  2: { slots: 2, level: 1 },
  3: { slots: 2, level: 2 },
  5: { slots: 2, level: 3 },
  7: { slots: 2, level: 4 },
  9: { slots: 2, level: 5 },
  11: { slots: 3, level: 5 },
  17: { slots: 4, level: 5 }
};

/* -------------------------------------------- */

/**
 * @typedef {Partial<
 *   SpellcastingModelData & SlotSpellcastingData & SingleLevelSpellcastingData & MultiLevelSpellcasting
 * >} SpellcastingMethod5e
 * @property {SpellcastingTable5e|SpellcastingTableSingle5e} [table]
 */

/**
 * Available spellcasting methods.
 * @type {Record<string, SpellcastingMethod5e>}
 */
DND5E.spellcasting = {
  atwill: {
    label: "DND5E.SPELLCASTING.METHODS.AtWill.label",
    order: -30
  },
  innate: {
    label: "DND5E.SPELLCASTING.METHODS.Innate.label",
    order: -20
  },
  ritual: {
    label: "DND5E.SPELLCASTING.METHODS.Ritual.label",
    order: -10
  },
  pact: {
    label: "DND5E.SPELLCASTING.METHODS.Pact.label",
    type: "single",
    cantrips: true,
    prepares: true,
    order: 10,
    img: "icons/magic/unholy/silhouette-robe-evil-power.webp",
    table: pactCastingProgression,
    progression: {
      pact: {
        label: "DND5E.SPELLCASTING.METHODS.Pact.Full.label",
        divisor: 1
      }
    }
  },
  spell: {
    label: "DND5E.SPELLCASTING.METHODS.Spell.label",
    type: "multi",
    cantrips: true,
    prepares: true,
    order: 20,
    img: "systems/dnd5e/icons/spell-tiers/{id}.webp",
    table: SPELL_SLOT_TABLE,
    progression: {
      full: {
        label: "DND5E.SPELLCASTING.METHODS.Spell.Full.label",
        divisor: 1
      },
      half: {
        label: "DND5E.SPELLCASTING.METHODS.Spell.Half.label",
        divisor: 2,
        roundUp: true
      },
      third: {
        label: "DND5E.SPELLCASTING.METHODS.Spell.Third.label",
        divisor: 3
      },
      artificer: {
        label: "DND5E.SPELLCASTING.METHODS.Spell.Artificer.label",
        divisor: 2,
        roundUp: true
      }
    }
  }
};
preLocalize("spellcasting", { key: "label" });
preLocalize("spellcasting.spell.progression", { key: "label" });
preLocalize("spellcasting.pact.progression", { key: "label" });

/* -------------------------------------------- */

/**
 * Spell preparation states.
 * @type {Record<string, SpellcastingPreparationState5e>}
 */
DND5E.spellPreparationStates = {
  unprepared: {
    label: "DND5E.SPELLCASTING.STATES.Unprepared",
    value: 0
  },
  prepared: {
    label: "DND5E.SPELLCASTING.STATES.Prepared",
    value: 1
  },
  always: {
    label: "DND5E.SPELLCASTING.STATES.AlwaysPrepared",
    value: 2
  }
};
preLocalize("spellPreparationStates", { key: "label" });

/* -------------------------------------------- */

/**
 * Spell lists that will be registered by the system during init.
 * @type {string[]}
 */
DND5E.SPELL_LISTS = Object.freeze([
  "Compendium.dnd5e.content24.JournalEntry.phbSpells0000000.JournalEntryPage.wwia6Wwo4BgE9GSI",
  "Compendium.dnd5e.content24.JournalEntry.phbSpells0000000.JournalEntryPage.SkHptN2PTzFGDaEj",
  "Compendium.dnd5e.content24.JournalEntry.phbSpells0000000.JournalEntryPage.LhvuDQEyrCdg5EfU",
  "Compendium.dnd5e.content24.JournalEntry.phbSpells0000000.JournalEntryPage.8yD9Jgp404hfZ9ie",
  "Compendium.dnd5e.content24.JournalEntry.phbSpells0000000.JournalEntryPage.5HnIk6HsrSxkvkz5",
  "Compendium.dnd5e.content24.JournalEntry.phbSpells0000000.JournalEntryPage.VfZ5mH2ZuyFq82Ga",
  "Compendium.dnd5e.content24.JournalEntry.phbSpells0000000.JournalEntryPage.sSzagq8GvYXpfmfs",
  "Compendium.dnd5e.content24.JournalEntry.phbSpells0000000.JournalEntryPage.6AnqLUowgdsqMFvz",
  "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.spellsLife000000",
  "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.spellsLandArid00",
  "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.spellsLandPolar0",
  "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.spellsLandTemper",
  "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.spellsLandTropic",
  "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.spellsDevotion00",
  "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.spellsDraconic00",
  "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.spellsFiend00000"
]);

/* -------------------------------------------- */

/**
 * @deprecated since 5.1
 * @ignore
 */
DND5E.spellPreparationModes = new Proxy(DND5E.spellcasting, {
  get(target, prop, receiver) {
    foundry.utils.logCompatibilityWarning("CONFIG.DND5E.spellPreparationModes is deprecated, use CONFIG.DND5E.spellcasting"
      + " instead.", { since: "DnD5e 5.1", until: "DnD5e 5.4" });
    if ( (prop === "prepared") || (prop === "always") ) prop = "spell";
    return Reflect.get(target, prop, receiver);
  },

  set(target, prop, value, receiver) {
    foundry.utils.logCompatibilityWarning("CONFIG.DND5E.spellPreparationModes is deprecated, use CONFIG.DND5E.spellcasting"
      + " instead.", { since: "DnD5e 5.1", until: "DnD5e 5.4" });
    if ( (prop === "prepared") || (prop === "always") ) prop = "spell";
    return Reflect.set(target, prop, value, receiver);
  }
});

/* -------------------------------------------- */

/**
 * @deprecated since 5.1
 * @ignore
 */
DND5E.spellcastingTypes = new Proxy(DND5E.spellcasting, {
  get(target, prop, receiver) {
    foundry.utils.logCompatibilityWarning("CONFIG.DND5E.spellcastingTypes is deprecated, use CONFIG.DND5E.spellcasting"
      + " instead.", { since: "DnD5e 5.1", until: "DnD5e 5.4" });
    if ( prop === "leveled" ) prop = "spell";
    return Reflect.get(target, prop, receiver);
  },

  set(target, prop, value, receiver) {
    foundry.utils.logCompatibilityWarning("CONFIG.DND5E.spellcastingTypes is deprecated, use CONFIG.DND5E.spellcasting"
      + " instead.", { since: "DnD5e 5.1", until: "DnD5e 5.4" });
    if ( prop === "leveled" ) prop = "spell";
    if ( !("type" in value) ) value.type = "single";
    if ( !("table" in value) ) value.table = DND5E.pactCastingProgression;
    if ( !("progression" in value) ) value.progression = { [prop]: { label: value.label } };
    return Reflect.set(target, prop, value, receiver);
  }
});

/* -------------------------------------------- */

/**
 * @ignore
 */
DND5E.spellProgression = new Proxy({}, {
  set() {
    foundry.utils.logCompatibilityWarning("CONFIG.DND5E.spellProgression is read-only. Spell progressions must be set "
      + "on CONFIG.DND5E.spellcasting instead.", { since: "DnD5e 5.1", until: "DnD5e 5.4" });
    return true;
  }
});


/* -------------------------------------------- */

/**
 * 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 });

/* -------------------------------------------- */

/**
 * Schools to which a spell can belong.
 * @enum {SpellSchoolConfiguration}
 */
DND5E.spellSchools = {
  abj: {
    label: "DND5E.SchoolAbj",
    icon: "systems/dnd5e/icons/svg/schools/abjuration.svg",
    fullKey: "abjuration",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.849AYEWw9FHD6JNz"
  },
  con: {
    label: "DND5E.SchoolCon",
    icon: "systems/dnd5e/icons/svg/schools/conjuration.svg",
    fullKey: "conjuration",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.TWyKMhZJZGqQ6uls"
  },
  div: {
    label: "DND5E.SchoolDiv",
    icon: "systems/dnd5e/icons/svg/schools/divination.svg",
    fullKey: "divination",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.HoD2MwzmVbMqj9se"
  },
  enc: {
    label: "DND5E.SchoolEnc",
    icon: "systems/dnd5e/icons/svg/schools/enchantment.svg",
    fullKey: "enchantment",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.SehPXk24ySBVOwCZ"
  },
  evo: {
    label: "DND5E.SchoolEvo",
    icon: "systems/dnd5e/icons/svg/schools/evocation.svg",
    fullKey: "evocation",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.kGp1RNuxL2SELLRC"
  },
  ill: {
    label: "DND5E.SchoolIll",
    icon: "systems/dnd5e/icons/svg/schools/illusion.svg",
    fullKey: "illusion",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.smEk7kvVyslFozrB"
  },
  nec: {
    label: "DND5E.SchoolNec",
    icon: "systems/dnd5e/icons/svg/schools/necromancy.svg",
    fullKey: "necromancy",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.W0eyiV1FBmngb6Qh"
  },
  trs: {
    label: "DND5E.SchoolTrs",
    icon: "systems/dnd5e/icons/svg/schools/transmutation.svg",
    fullKey: "transmutation",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.IYWewSailtmv6qEb"
  }
};
preLocalize("spellSchools", { key: "label", sort: true });

/* -------------------------------------------- */

/**
 * Types of spell lists.
 * @enum {string}
 */
DND5E.spellListTypes = {
  class: "TYPES.Item.class",
  subclass: "TYPES.Item.subclass",
  background: "TYPES.Item.background",
  race: "TYPES.Item.race",
  other: "JOURNALENTRYPAGE.DND5E.SpellList.Type.Other"
};
preLocalize("spellListTypes");

/* -------------------------------------------- */

/**
 * Spell scroll item ID within the `DND5E.sourcePacks` compendium or a full UUID for each spell level.
 * @enum {string}
 */
DND5E.spellScrollIds = {
  0: "Compendium.dnd5e.equipment24.Item.dmgSpellScrollCa",
  1: "Compendium.dnd5e.equipment24.Item.dmgSpellScroll1s",
  2: "Compendium.dnd5e.equipment24.Item.dmgSpellScroll2n",
  3: "Compendium.dnd5e.equipment24.Item.dmgSpellScroll3r",
  4: "Compendium.dnd5e.equipment24.Item.dmgSpellScroll4t",
  5: "Compendium.dnd5e.equipment24.Item.dmgSpellScroll5t",
  6: "Compendium.dnd5e.equipment24.Item.dmgSpellScroll6t",
  7: "Compendium.dnd5e.equipment24.Item.dmgSpellScroll7t",
  8: "Compendium.dnd5e.equipment24.Item.dmgSpellScroll8t",
  9: "Compendium.dnd5e.equipment24.Item.dmgSpellScroll9t"
};

/* -------------------------------------------- */

/**
 * Spell scroll save DCs and attack bonus values based on spell level. If matching level isn't found,
 * then the nearest level lower than it will be selected.
 * @enum {SpellScrollValues}
 */
DND5E.spellScrollValues = {
  0: { dc: 13, bonus: 5 },
  3: { dc: 15, bonus: 7 },
  5: { dc: 17, bonus: 9 },
  7: { dc: 18, bonus: 10 },
  9: { dc: 19, bonus: 11 }
};

/* -------------------------------------------- */

/**
 * Compendium packs used for localized items.
 * @enum {string}
 */
DND5E.sourcePacks = {
  BACKGROUNDS: "dnd5e.backgrounds",
  CLASSES: "dnd5e.classes",
  ITEMS: "dnd5e.items",
  RACES: "dnd5e.races"
};

/* -------------------------------------------- */

/**
 * Settings that configuration how actors are changed when transformation is applied.
 * @typedef {TransformationConfiguration}
 */
DND5E.transformation = {
  effects: {
    all: {
      label: "DND5E.TRANSFORM.Setting.Effects.All.Label",
      hint: "DND5E.TRANSFORM.Setting.Effects.All.Hint",
      disables: ["effects.*"]
    },
    origin: {
      label: "DND5E.TRANSFORM.Setting.Effects.Origin.Label",
      hint: "DND5E.TRANSFORM.Setting.Effects.Origin.Hint",
      default: true
    },
    otherOrigin: {
      label: "DND5E.TRANSFORM.Setting.Effects.OtherOrigin.Label",
      hint: "DND5E.TRANSFORM.Setting.Effects.OtherOrigin.Hint",
      default: true
    },
    background: {
      label: "DND5E.TRANSFORM.Setting.Effects.Background.Label",
      default: true
    },
    class: {
      label: "DND5E.TRANSFORM.Setting.Effects.Class.Label",
      default: true
    },
    feat: {
      label: "DND5E.TRANSFORM.Setting.Effects.Feature.Label",
      default: true
    },
    equipment: {
      label: "DND5E.TRANSFORM.Setting.Effects.Equipment.Label",
      default: true
    },
    spell: {
      label: "DND5E.TRANSFORM.Setting.Effects.Spell.Label",
      default: true
    }
  },
  keep: {
    physical: {
      label: "DND5E.TRANSFORM.Setting.Keep.Physical.Label",
      hint: "DND5E.TRANSFORM.Setting.Keep.Physical.Hint"
    },
    mental: {
      label: "DND5E.TRANSFORM.Setting.Keep.Mental.Label",
      hint: "DND5E.TRANSFORM.Setting.Keep.Mental.Hint"
    },
    saves: {
      label: "DND5E.TRANSFORM.Setting.Keep.Saves.Label",
      disables: ["merge.saves"]
    },
    skills: {
      label: "DND5E.TRANSFORM.Setting.Keep.Skills.Label",
      disables: ["merge.skills"]
    },
    gearProf: {
      label: "DND5E.TRANSFORM.Setting.Keep.GearProficiency.Label"
    },
    languages: {
      label: "DND5E.TRANSFORM.Setting.Keep.Languages.Label"
    },
    class: {
      label: "DND5E.TRANSFORM.Setting.Keep.Proficiency.Label"
    },
    feats: {
      label: "DND5E.TRANSFORM.Setting.Keep.Features.Label"
    },
    items: {
      label: "DND5E.TRANSFORM.Setting.Keep.Equipment.Label"
    },
    spells: {
      label: "DND5E.TRANSFORM.Setting.Keep.Spells.Label"
    },
    bio: {
      label: "DND5E.TRANSFORM.Setting.Keep.Biography.Label"
    },
    type: {
      label: "DND5E.TRANSFORM.Setting.Keep.CreatureType.Label"
    },
    hp: {
      label: "DND5E.TRANSFORM.Setting.Keep.Health.Label"
    },
    tempHP: {
      label: "DND5E.TRANSFORM.Setting.Keep.TempHP.Label"
    },
    resistances: {
      label: "DND5E.TRANSFORM.Setting.Keep.Resistances.Label"
    },
    vision: {
      label: "DND5E.TRANSFORM.Setting.Keep.Vision.Label",
      default: true
    },
    self: {
      label: "DND5E.TRANSFORM.Setting.Keep.Self.Label",
      hint: "DND5E.TRANSFORM.Setting.Keep.Self.Hint",
      disables: ["keep.*", "merge.*", "minimumAC", "tempFormula"]
    }
  },
  merge: {
    saves: {
      label: "DND5E.TRANSFORM.Setting.Merge.Saves.Label",
      disables: ["keep.saves"]
    },
    skills: {
      label: "DND5E.TRANSFORM.Setting.Merge.Skills.Label",
      disables: ["keep.skills"]
    }
  },
  other: {},
  presets: {
    wildshape: {
      icon: '<i class="fas fa-paw" inert></i>',
      label: "DND5E.TRANSFORM.Preset.WildShape.Label",
      settings: {
        effects: new Set(["otherOrigin", "origin", "feat", "spell", "class", "background"]),
        keep: new Set(["bio", "class", "feats", "hp", "languages", "mental", "tempHP", "type"]),
        merge: new Set(["saves", "skills"]),
        minimumAC: "(13 + @abilities.wis.mod) * sign(@subclasses.moon.levels)",
        spellLists: new Set(["subclass:moon"]),
        tempFormula: "max(@classes.druid.levels, @subclasses.moon.levels * 3)"
      }
    },
    polymorph: {
      icon: '<i class="fas fa-pastafarianism" inert></i>',
      label: "DND5E.TRANSFORM.Preset.Polymorph.Label",
      settings: {
        effects: new Set(["otherOrigin", "origin", "spell"]),
        keep: new Set(["hp", "type"]),
        tempFormula: "@source.attributes.hp.max"
      }
    },
    polymorphSelf: {
      icon: '<i class="fas fa-eye" inert></i>',
      label: "DND5E.TRANSFORM.Preset.Appearance.Label",
      settings: {
        effects: new Set(["all"]),
        keep: new Set(["self"])
      }
    }
  }
};
preLocalize("transformation.effects", { keys: ["label", "hint"] });
preLocalize("transformation.keep", { keys: ["label", "hint"] });
preLocalize("transformation.merge", { keys: ["label", "hint"] });
preLocalize("transformation.other", { keys: ["label", "hint"], sort: true });
preLocalize("transformation.presets", { key: "label", sort: true });

/* -------------------------------------------- */

/**
 * Skill, ability, and tool proficiency levels.
 * The key for each level represents its proficiency multiplier.
 * @enum {string}
 */
DND5E.proficiencyLevels = {
  0: "DND5E.NotProficient",
  1: "DND5E.Proficient",
  0.5: "DND5E.HalfProficient",
  2: "DND5E.Expertise"
};
preLocalize("proficiencyLevels");

/* -------------------------------------------- */

/**
 * Weapon and armor item proficiency levels.
 * @enum {string}
 */
DND5E.weaponAndArmorProficiencyLevels = {
  0: "DND5E.NotProficient",
  1: "DND5E.Proficient"
};
preLocalize("weaponAndArmorProficiencyLevels");

/* -------------------------------------------- */

/**
 * The amount of cover provided by an object. In cases where multiple pieces
 * of cover are in play, we take the highest value.
 * @enum {string}
 */
DND5E.cover = {
  0: "DND5E.None",
  .5: "DND5E.CoverHalf",
  .75: "DND5E.CoverThreeQuarters",
  1: "DND5E.CoverTotal"
};
preLocalize("cover");

/* -------------------------------------------- */

/**
 * A selection of actor attributes that can be tracked on token resource bars.
 * @type {string[]}
 * @deprecated since v10
 */
DND5E.trackableAttributes = [
  "attributes.ac.value", "attributes.init.bonus", "attributes.movement", "attributes.senses",
  "attributes.spell.attack", "attributes.spell.dc", "attributes.spell.level", "details.cr",
  "details.xp.value", "skills.*.passive", "abilities.*.value"
];

/* -------------------------------------------- */

/**
 * A selection of actor and item attributes that are valid targets for item resource consumption.
 * @type {string[]}
 */
DND5E.consumableResources = [
  // Configured during init.
];

/* -------------------------------------------- */

/**
 * Conditions that can affect an actor.
 * @enum {ConditionConfiguration}
 */
DND5E.conditionTypes = {
  bleeding: {
    name: "EFFECT.DND5E.StatusBleeding",
    img: "systems/dnd5e/icons/svg/statuses/bleeding.svg",
    pseudo: true
  },
  blinded: {
    name: "DND5E.ConBlinded",
    img: "systems/dnd5e/icons/svg/statuses/blinded.svg",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.uDogReMO6QtH6NDw",
    special: "BLIND"
  },
  burning: {
    name: "EFFECT.DND5E.StatusBurning",
    img: "systems/dnd5e/icons/svg/statuses/burning.svg",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.mPBGM1vguT5IPzxT",
    pseudo: true
  },
  charmed: {
    name: "DND5E.ConCharmed",
    img: "systems/dnd5e/icons/svg/statuses/charmed.svg",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.vLAsIUa0FhZNsyLk"
  },
  cursed: {
    name: "EFFECT.DND5E.StatusCursed",
    img: "systems/dnd5e/icons/svg/statuses/cursed.svg",
    pseudo: true
  },
  dehydration: {
    name: "EFFECT.DND5E.StatusDehydration",
    img: "systems/dnd5e/icons/svg/statuses/dehydration.svg",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.FZFvLNOX0lHaHZ1k",
    pseudo: true
  },
  deafened: {
    name: "DND5E.ConDeafened",
    img: "systems/dnd5e/icons/svg/statuses/deafened.svg",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.qlRw66tJhk0zLnwq"
  },
  diseased: {
    name: "DND5E.ConDiseased",
    img: "systems/dnd5e/icons/svg/statuses/diseased.svg",
    pseudo: true,
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.oNQWvyRZkTOJ8PBq"
  },
  exhaustion: {
    name: "DND5E.ConExhaustion",
    img: "systems/dnd5e/icons/svg/statuses/exhaustion.svg",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.jSQtPgNm0i4f3Qi3",
    levels: 6,
    reduction: { rolls: 2, speed: 5 }
  },
  falling: {
    name: "EFFECT.DND5E.StatusFalling",
    img: "systems/dnd5e/icons/svg/statuses/falling.svg",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.kREHL5pgNUOhay9f",
    pseudo: true
  },
  frightened: {
    name: "DND5E.ConFrightened",
    img: "systems/dnd5e/icons/svg/statuses/frightened.svg",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.93uaingTESo8N1qL"
  },
  grappled: {
    name: "DND5E.ConGrappled",
    img: "systems/dnd5e/icons/svg/statuses/grappled.svg",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.KbQ1k0OIowtZeQgp"
  },
  incapacitated: {
    name: "DND5E.ConIncapacitated",
    img: "systems/dnd5e/icons/svg/statuses/incapacitated.svg",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.4i3G895hy99piand",
    neverBlockMovement: true
  },
  invisible: {
    name: "DND5E.ConInvisible",
    img: "systems/dnd5e/icons/svg/statuses/invisible.svg",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.MQIZ1zRLWRcNOtPN"
  },
  malnutrition: {
    name: "EFFECT.DND5E.StatusMalnutrition",
    img: "systems/dnd5e/icons/svg/statuses/malnutrition.svg",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.earBo4vQPC1ti4g7",
    pseudo: true
  },
  paralyzed: {
    name: "DND5E.ConParalyzed",
    img: "systems/dnd5e/icons/svg/statuses/paralyzed.svg",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.RnxZoTglPnLc6UPb",
    statuses: ["incapacitated"]
  },
  petrified: {
    name: "DND5E.ConPetrified",
    img: "systems/dnd5e/icons/svg/statuses/petrified.svg",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.6vtLuQT9lwZ9N299",
    statuses: ["incapacitated"]
  },
  poisoned: {
    name: "DND5E.ConPoisoned",
    img: "systems/dnd5e/icons/svg/statuses/poisoned.svg",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.HWs8kEojffqwTSJz"
  },
  prone: {
    name: "DND5E.ConProne",
    img: "systems/dnd5e/icons/svg/statuses/prone.svg",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.QxCrRcgMdUd3gfzz"
  },
  restrained: {
    name: "DND5E.ConRestrained",
    img: "systems/dnd5e/icons/svg/statuses/restrained.svg",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.dqLeGdpHtb8FfcxX"
  },
  silenced: {
    name: "EFFECT.DND5E.StatusSilenced",
    img: "systems/dnd5e/icons/svg/statuses/silenced.svg",
    pseudo: true
  },
  stunned: {
    name: "DND5E.ConStunned",
    img: "systems/dnd5e/icons/svg/statuses/stunned.svg",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.EjbXjvyQAMlDyANI",
    statuses: ["incapacitated"]
  },
  suffocation: {
    name: "EFFECT.DND5E.StatusSuffocation",
    img: "systems/dnd5e/icons/svg/statuses/suffocation.svg",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.gAvV8TLyS8UGq00x",
    pseudo: true
  },
  surprised: {
    name: "EFFECT.DND5E.StatusSurprised",
    img: "systems/dnd5e/icons/svg/statuses/surprised.svg",
    pseudo: true
  },
  transformed: {
    name: "EFFECT.DND5E.StatusTransformed",
    img: "systems/dnd5e/icons/svg/statuses/transformed.svg",
    pseudo: true
  },
  unconscious: {
    name: "DND5E.ConUnconscious",
    img: "systems/dnd5e/icons/svg/statuses/unconscious.svg",
    reference: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.fZCRaKEJd4KoQCqH",
    statuses: ["incapacitated"],
    riders: ["prone"]
  }
};
preLocalize("conditionTypes", { key: "name", sort: true });

/* -------------------------------------------- */

/**
 * Various effects of conditions and which conditions apply it. Either keys for the conditions,
 * and with a number appended for a level of exhaustion.
 * @enum {Set<string>}
 */
DND5E.conditionEffects = {
  noMovement: new Set(["exhaustion-5", "grappled", "paralyzed", "petrified", "restrained", "unconscious"]),
  halfMovement: new Set(["exhaustion-2"]),
  crawl: new Set(["prone", "exceedingCarryingCapacity"]),
  petrification: new Set(["petrified"]),
  halfHealth: new Set(["exhaustion-4"]),
  dehydrated: new Set(["dehydration"]),
  malnourished: new Set(["malnutrition"]),
  abilityCheckDisadvantage: new Set(["poisoned", "exhaustion-1"]),
  abilitySaveDisadvantage: new Set(["exhaustion-3"]),
  attackDisadvantage: new Set(["poisoned", "exhaustion-3"]),
  dexteritySaveDisadvantage: new Set(["restrained"]),
  initiativeAdvantage: new Set(["invisible"]),
  initiativeDisadvantage: new Set(["incapacitated", "surprised"])
};

/* -------------------------------------------- */

/**
 * Extra status effects not specified in `conditionTypes`. If the ID matches a core-provided effect, then this
 * data will be merged into the core data.
 * @enum {StatusEffectConfig5e}
 */
DND5E.statusEffects = {
  burrowing: {
    name: "EFFECT.DND5E.StatusBurrowing",
    img: "systems/dnd5e/icons/svg/statuses/burrowing.svg",
    special: "BURROW"
  },
  concentrating: {
    name: "EFFECT.DND5E.StatusConcentrating",
    img: "systems/dnd5e/icons/svg/statuses/concentrating.svg",
    special: "CONCENTRATING"
  },
  coverHalf: {
    name: "EFFECT.DND5E.StatusHalfCover",
    img: "systems/dnd5e/icons/svg/statuses/cover-half.svg",
    order: 2,
    exclusiveGroup: "cover",
    coverBonus: 2
  },
  coverThreeQuarters: {
    name: "EFFECT.DND5E.StatusThreeQuartersCover",
    img: "systems/dnd5e/icons/svg/statuses/cover-three-quarters.svg",
    order: 3,
    exclusiveGroup: "cover",
    coverBonus: 5
  },
  coverTotal: {
    name: "EFFECT.DND5E.StatusTotalCover",
    img: "systems/dnd5e/icons/svg/statuses/cover-total.svg",
    order: 4,
    exclusiveGroup: "cover"
  },
  dead: {
    name: "EFFECT.DND5E.StatusDead",
    img: "systems/dnd5e/icons/svg/statuses/dead.svg",
    special: "DEFEATED",
    order: 1,
    neverBlockMovement: true
  },
  dodging: {
    name: "EFFECT.DND5E.StatusDodging",
    img: "systems/dnd5e/icons/svg/statuses/dodging.svg"
  },
  ethereal: {
    name: "EFFECT.DND5E.StatusEthereal",
    img: "systems/dnd5e/icons/svg/statuses/ethereal.svg",
    neverBlockMovement: true
  },
  flying: {
    name: "EFFECT.DND5E.StatusFlying",
    img: "systems/dnd5e/icons/svg/statuses/flying.svg",
    special: "FLY"
  },
  hiding: {
    name: "EFFECT.DND5E.StatusHiding",
    img: "systems/dnd5e/icons/svg/statuses/hiding.svg"
  },
  hovering: {
    name: "EFFECT.DND5E.StatusHovering",
    img: "systems/dnd5e/icons/svg/statuses/hovering.svg",
    special: "HOVER"
  },
  marked: {
    name: "EFFECT.DND5E.StatusMarked",
    img: "systems/dnd5e/icons/svg/statuses/marked.svg"
  },
  sleeping: {
    name: "EFFECT.DND5E.StatusSleeping",
    img: "systems/dnd5e/icons/svg/statuses/sleeping.svg",
    statuses: ["incapacitated", "unconscious"]
  },
  stable: {
    name: "EFFECT.DND5E.StatusStable",
    img: "systems/dnd5e/icons/svg/statuses/stable.svg"
  }
};

/* -------------------------------------------- */

/**
 * Status effects that never block token movement. Populated during the setup process.
 * @type {Set<string>}
 */
DND5E.neverBlockStatuses = new Set();

/* -------------------------------------------- */

/**
 * Configuration for the special bloodied status effect.
 * @type {{ name: string, icon: string, threshold: number }}
 */
DND5E.bloodied = {
  name: "EFFECT.DND5E.StatusBloodied",
  img: "systems/dnd5e/icons/svg/statuses/bloodied.svg",
  threshold: .5
};

/* -------------------------------------------- */
/*  Languages                                   */
/* -------------------------------------------- */

/**
 * Languages a character can learn.
 * @enum {object}
 */
DND5E.languages = {
  standard: {
    label: "DND5E.Language.Category.Standard",
    selectable: false,
    children: {
      common: "DND5E.Language.Language.Common",
      draconic: "DND5E.Language.Language.Draconic",
      dwarvish: "DND5E.Language.Language.Dwarvish",
      elvish: "DND5E.Language.Language.Elvish",
      giant: "DND5E.Language.Language.Giant",
      gnomish: "DND5E.Language.Language.Gnomish",
      goblin: "DND5E.Language.Language.Goblin",
      halfling: "DND5E.Language.Language.Halfling",
      orc: "DND5E.Language.Language.Orc",
      sign: "DND5E.Language.Language.CommonSign"
    }
  },
  exotic: {
    label: "DND5E.Language.Category.Rare",
    selectable: false,
    children: {
      aarakocra: "DND5E.Language.Language.Aarakocra",
      abyssal: "DND5E.Language.Language.Abyssal",
      cant: "DND5E.Language.Language.ThievesCant",
      celestial: "DND5E.Language.Language.Celestial",
      deep: "DND5E.Language.Language.DeepSpeech",
      druidic: "DND5E.Language.Language.Druidic",
      gith: "DND5E.Language.Language.Gith",
      gnoll: "DND5E.Language.Language.Gnoll",
      infernal: "DND5E.Language.Language.Infernal",
      primordial: {
        label: "DND5E.Language.Language.Primordial",
        children: {
          aquan: "DND5E.Language.Language.Aquan",
          auran: "DND5E.Language.Language.Auran",
          ignan: "DND5E.Language.Language.Ignan",
          terran: "DND5E.Language.Language.Terran"
        }
      },
      sylvan: "DND5E.Language.Language.Sylvan",
      undercommon: "DND5E.Language.Language.Undercommon"
    }
  }
};
preLocalize("languages", { key: "label" });
preLocalize("languages.standard.children", { key: "label", sort: true });
preLocalize("languages.exotic.children", { key: "label", sort: true });
preLocalize("languages.exotic.children.primordial.children", { sort: true });

/* -------------------------------------------- */

/**
 * Communication types that take ranges such as telepathy.
 * @enum {{ label: string }}
 */
DND5E.communicationTypes = {
  telepathy: {
    label: "DND5E.Language.Communication.Telepathy"
  }
};
preLocalize("communicationTypes", { key: "label" });

/* -------------------------------------------- */

/**
 * NPC habitats.
 * @enum {HabitatConfiguration5e}
 */
DND5E.habitats = {
  any: {
    label: "DND5E.Habitat.Categories.Any"
  },
  arctic: {
    label: "DND5E.Habitat.Categories.Arctic"
  },
  coastal: {
    label: "DND5E.Habitat.Categories.Coastal"
  },
  desert: {
    label: "DND5E.Habitat.Categories.Desert"
  },
  forest: {
    label: "DND5E.Habitat.Categories.Forest"
  },
  grassland: {
    label: "DND5E.Habitat.Categories.Grassland"
  },
  hill: {
    label: "DND5E.Habitat.Categories.Hill"
  },
  mountain: {
    label: "DND5E.Habitat.Categories.Mountain"
  },
  planar: {
    label: "DND5E.Habitat.Categories.Planar",
    subtypes: true
  },
  swamp: {
    label: "DND5E.Habitat.Categories.Swamp"
  },
  underdark: {
    label: "DND5E.Habitat.Categories.Underdark"
  },
  underwater: {
    label: "DND5E.Habitat.Categories.Underwater"
  },
  urban: {
    label: "DND5E.Habitat.Categories.Urban"
  }
};
preLocalize("habitats", { key: "label" });

/* -------------------------------------------- */

/**
 * NPC Treasure
 * @enum {TreasureConfiguration5e}
 */
DND5E.treasure = {
  any: {
    label: "DND5E.Treasure.Categories.Any"
  },
  arcana: {
    label: "DND5E.Treasure.Categories.Arcana"
  },
  armaments: {
    label: "DND5E.Treasure.Categories.Armaments"
  },
  implements: {
    label: "DND5E.Treasure.Categories.Implements"
  },
  individual: {
    label: "DND5E.Treasure.Categories.Individual"
  },
  relics: {
    label: "DND5E.Treasure.Categories.Relics"
  }
};
preLocalize("treasure", { key: "label" });

/* -------------------------------------------- */

/**
 * Maximum allowed character level.
 * @type {number}
 */
DND5E.maxLevel = 20;

/**
 * Maximum ability score value allowed by default.
 * @type {number}
 */
DND5E.maxAbilityScore = 20;

/**
 * XP required to achieve each character level.
 * @type {number[]}
 */
DND5E.CHARACTER_EXP_LEVELS = [
  0, 300, 900, 2700, 6500, 14000, 23000, 34000, 48000, 64000, 85000, 100000,
  120000, 140000, 165000, 195000, 225000, 265000, 305000, 355000
];

/**
 * XP granted for each challenge rating.
 * @type {number[]}
 */
DND5E.CR_EXP_LEVELS = [
  10, 200, 450, 700, 1100, 1800, 2300, 2900, 3900, 5000, 5900, 7200, 8400, 10000, 11500, 13000, 15000, 18000,
  20000, 22000, 25000, 33000, 41000, 50000, 62000, 75000, 90000, 105000, 120000, 135000, 155000
];

/**
 * XP thresholds for encounter difficulty.
 * @type {number[][]}
 */
DND5E.ENCOUNTER_DIFFICULTY = [
  [0, 0, 0],
  [50, 75, 100],
  [100, 150, 200],
  [150, 225, 400],
  [250, 375, 500],
  [500, 750, 1100],
  [600, 1000, 1400],
  [750, 1300, 1700],
  [1000, 1700, 2100],
  [1300, 2000, 2600],
  [1600, 2300, 3100],
  [1900, 2900, 4100],
  [2200, 3700, 4700],
  [2600, 4200, 5400],
  [2900, 4900, 6200],
  [3300, 5400, 7800],
  [3800, 6100, 9800],
  [4500, 7200, 11700],
  [5000, 8700, 14200],
  [5500, 10700, 17200],
  [6400, 13200, 22000]
];

/**
 * Intervals above the maximum XP that result in an epic boon.
 * @type {number}
 */
DND5E.epicBoonInterval = 30000;

/* -------------------------------------------- */

/**
 * Configurable traits on actors.
 * @enum {TraitConfiguration}
 */
DND5E.traits = {
  saves: {
    labels: {
      title: "DND5E.ClassSaves",
      localization: "DND5E.TraitSavesPlural"
    },
    icon: "icons/magic/life/ankh-gold-blue.webp",
    actorKeyPath: "system.abilities",
    configKey: "abilities",
    labelKeyPath: "label"
  },
  skills: {
    labels: {
      title: "DND5E.Skills",
      localization: "DND5E.TraitSkillsPlural"
    },
    icon: "icons/tools/instruments/harp-yellow-teal.webp",
    actorKeyPath: "system.skills",
    labelKeyPath: "label",
    expertise: true,
    dataType: MappingField
  },
  languages: {
    labels: {
      title: "DND5E.Languages",
      localization: "DND5E.TraitLanguagesPlural",
      all: "DND5E.Language.All"
    },
    icon: "icons/skills/social/diplomacy-peace-alliance.webp"
  },
  armor: {
    labels: {
      title: "DND5E.TraitArmorProf",
      localization: "DND5E.TraitArmorPlural"
    },
    icon: "icons/equipment/chest/breastplate-helmet-metal.webp",
    actorKeyPath: "system.traits.armorProf",
    configKey: "armorProficiencies",
    subtypes: { keyPath: "armor.type", ids: ["armorIds", "shieldIds"] }
  },
  weapon: {
    labels: {
      title: "DND5E.TraitWeaponProf",
      localization: "DND5E.TraitWeaponPlural"
    },
    icon: "icons/skills/melee/weapons-crossed-swords-purple.webp",
    actorKeyPath: "system.traits.weaponProf",
    configKey: "weaponProficiencies",
    subtypes: { keyPath: "weaponType", ids: ["weaponIds"] },
    mastery: true
  },
  tool: {
    labels: {
      title: "DND5E.TraitToolProf",
      localization: "DND5E.TraitToolPlural"
    },
    icon: "icons/skills/trades/smithing-anvil-silver-red.webp",
    actorKeyPath: "system.tools",
    configKey: "toolProficiencies",
    subtypes: { keyPath: "toolType", ids: ["tools"] },
    children: { vehicle: "vehicleTypes" },
    sortCategories: true,
    expertise: true,
    dataType: MappingField
  },
  di: {
    labels: {
      title: "DND5E.DamImm",
      localization: "DND5E.TraitDIPlural"
    },
    icon: "systems/dnd5e/icons/svg/trait-damage-immunities.svg",
    configKey: "damageTypes"
  },
  dr: {
    labels: {
      title: "DND5E.DamRes",
      localization: "DND5E.TraitDRPlural"
    },
    icon: "systems/dnd5e/icons/svg/trait-damage-resistances.svg",
    configKey: "damageTypes"
  },
  dv: {
    labels: {
      title: "DND5E.DamVuln",
      localization: "DND5E.TraitDVPlural"
    },
    icon: "systems/dnd5e/icons/svg/trait-damage-vulnerabilities.svg",
    configKey: "damageTypes"
  },
  dm: {
    labels: {
      title: "DND5E.DamMod",
      localization: "DND5E.TraitDMPlural"
    },
    configKey: "damageTypes",
    dataType: Number
  },
  ci: {
    labels: {
      title: "DND5E.ConImm",
      localization: "DND5E.TraitCIPlural"
    },
    icon: "systems/dnd5e/icons/svg/trait-condition-immunities.svg",
    configKey: "conditionTypes",
    labelKeyPath: "name"
  }
};
preLocalize("traits", { keys: ["labels.title", "labels.all"] });

/* -------------------------------------------- */

/**
 * Modes used within a trait advancement.
 * @enum {{ label: string, hint: string }}
 */
DND5E.traitModes = {
  default: {
    label: "DND5E.ADVANCEMENT.Trait.Mode.Default.Label",
    hint: "DND5E.ADVANCEMENT.Trait.Mode.Default.Hint"
  },
  expertise: {
    label: "DND5E.ADVANCEMENT.Trait.Mode.Expertise.Label",
    hint: "DND5E.ADVANCEMENT.Trait.Mode.Expertise.Hint"
  },
  forcedExpertise: {
    label: "DND5E.ADVANCEMENT.Trait.Mode.Force.Label",
    hint: "DND5E.ADVANCEMENT.Trait.Mode.Force.Hint"
  },
  upgrade: {
    label: "DND5E.ADVANCEMENT.Trait.Mode.Upgrade.Label",
    hint: "DND5E.ADVANCEMENT.Trait.Mode.Upgrade.Hint"
  },
  mastery: {
    label: "DND5E.ADVANCEMENT.Trait.Mode.Mastery.Label",
    hint: "DND5E.ADVANCEMENT.Trait.Mode.Mastery.Hint"
  }
};
preLocalize("traitModes", { keys: ["label", "hint"] });

/* -------------------------------------------- */

/**
 * Special character flags.
 * @enum {CharacterFlagConfiguration}
 */
DND5E.characterFlags = {
  diamondSoul: {
    name: "DND5E.FlagsDiamondSoul",
    hint: "DND5E.FlagsDiamondSoulHint",
    section: "DND5E.Feats",
    type: Boolean
  },
  enhancedDualWielding: {
    name: "DND5E.FLAGS.EnhancedDualWielding.Name",
    hint: "DND5E.FLAGS.EnhancedDualWielding.Hint",
    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
  },
  halflingNimbleness: {
    name: "DND5E.FlagsHalflingNimbleness",
    hint: "DND5E.FlagsHalflingNimblenessHint",
    section: "DND5E.RacialTraits",
    type: Boolean
  },
  initiativeAlert: {
    name: "DND5E.FlagsAlert",
    hint: "DND5E.FlagsAlertHint",
    section: "DND5E.Feats",
    type: Boolean
  },
  jackOfAllTrades: {
    name: "DND5E.FlagsJOAT",
    hint: "DND5E.FlagsJOATHint",
    section: "DND5E.Feats",
    type: Boolean
  },
  observantFeat: {
    name: "DND5E.FlagsObservant",
    hint: "DND5E.FlagsObservantHint",
    skills: ["prc", "inv"],
    section: "DND5E.Feats",
    type: Boolean
  },
  tavernBrawlerFeat: {
    name: "DND5E.FlagsTavernBrawler",
    hint: "DND5E.FlagsTavernBrawlerHint",
    section: "DND5E.Feats",
    type: Boolean
  },
  powerfulBuild: {
    name: "DND5E.FlagsPowerfulBuild",
    hint: "DND5E.FlagsPowerfulBuildHint",
    section: "DND5E.RacialTraits",
    type: Boolean
  },
  reliableTalent: {
    name: "DND5E.FlagsReliableTalent",
    hint: "DND5E.FlagsReliableTalentHint",
    section: "DND5E.Feats",
    type: Boolean
  },
  remarkableAthlete: {
    name: "DND5E.FlagsRemarkableAthlete",
    hint: "DND5E.FlagsRemarkableAthleteHint",
    abilities: ["str", "dex", "con"],
    section: "DND5E.Feats",
    type: Boolean
  },
  toolExpertise: {
    name: "DND5E.FlagsToolExpertise",
    hint: "DND5E.FlagsToolExpertiseHint",
    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"] });

/* -------------------------------------------- */

/**
 * Different types of actor structures that groups can represent.
 * @enum {object}
 */
DND5E.groupTypes = {
  party: "DND5E.Group.TypeParty",
  encounter: "DND5E.Group.TypeEncounter"
};
preLocalize("groupTypes");

/* -------------------------------------------- */

/**
 * Configuration information for activity types.
 * @enum {ActivityTypeConfiguration}
 */
DND5E.activityTypes = {
  attack: {
    documentClass: AttackActivity
  },
  cast: {
    documentClass: CastActivity
  },
  check: {
    documentClass: CheckActivity
  },
  damage: {
    documentClass: DamageActivity
  },
  enchant: {
    documentClass: EnchantActivity
  },
  forward: {
    documentClass: ForwardActivity
  },
  heal: {
    documentClass: HealActivity
  },
  order: {
    documentClass: OrderActivity,
    configurable: false
  },
  save: {
    documentClass: SaveActivity
  },
  summon: {
    documentClass: SummonActivity
  },
  transform: {
    documentClass: TransformActivity
  },
  utility: {
    documentClass: UtilityActivity
  }
};

/* -------------------------------------------- */

const _ALL_ITEM_TYPES = ["background", "class", "feat", "race", "subclass"];

/**
 * Advancement types that can be added to items.
 * @enum {AdvancementTypeConfiguration}
 */
DND5E.advancementTypes = {
  AbilityScoreImprovement: {
    documentClass: AbilityScoreImprovementAdvancement,
    validItemTypes: new Set(["background", "class", "race", "feat"])
  },
  HitPoints: {
    documentClass: HitPointsAdvancement,
    validItemTypes: new Set(["class"])
  },
  ItemChoice: {
    documentClass: ItemChoiceAdvancement,
    validItemTypes: new Set(_ALL_ITEM_TYPES)
  },
  ItemGrant: {
    documentClass: ItemGrantAdvancement,
    validItemTypes: new Set(_ALL_ITEM_TYPES)
  },
  ScaleValue: {
    documentClass: ScaleValueAdvancement,
    validItemTypes: new Set(_ALL_ITEM_TYPES)
  },
  Size: {
    documentClass: SizeAdvancement,
    validItemTypes: new Set(["race"])
  },
  Subclass: {
    documentClass: SubclassAdvancement,
    validItemTypes: new Set(["class"])
  },
  Trait: {
    documentClass: TraitAdvancement,
    validItemTypes: new Set(_ALL_ITEM_TYPES)
  }
};

/* -------------------------------------------- */

/**
 * Default artwork configuration for each Document type and sub-type.
 * @enum {Record<string, string>}
 */
DND5E.defaultArtwork = {
  Actor: {
    character: "systems/dnd5e/icons/svg/actors/character.svg",
    encounter: "systems/dnd5e/icons/svg/actors/encounter.svg",
    group: "systems/dnd5e/icons/svg/actors/group.svg",
    npc: "systems/dnd5e/icons/svg/actors/npc.svg",
    vehicle: "systems/dnd5e/icons/svg/actors/vehicle.svg"
  },
  Item: {
    background: "systems/dnd5e/icons/svg/items/background.svg",
    class: "systems/dnd5e/icons/svg/items/class.svg",
    consumable: "systems/dnd5e/icons/svg/items/consumable.svg",
    container: "systems/dnd5e/icons/svg/items/container.svg",
    equipment: "systems/dnd5e/icons/svg/items/equipment.svg",
    facility: "systems/dnd5e/icons/svg/items/facility.svg",
    feat: "systems/dnd5e/icons/svg/items/feature.svg",
    loot: "systems/dnd5e/icons/svg/items/loot.svg",
    race: "systems/dnd5e/icons/svg/items/race.svg",
    spell: "systems/dnd5e/icons/svg/items/spell.svg",
    subclass: "systems/dnd5e/icons/svg/items/subclass.svg",
    tool: "systems/dnd5e/icons/svg/items/tool.svg",
    weapon: "systems/dnd5e/icons/svg/items/weapon.svg"
  }
};

/* -------------------------------------------- */
/*  Calendar                                    */
/* -------------------------------------------- */

/**
 * Configuration information for the calendar UI.
 * @type {CalendarHUDConfiguration}
 */
DND5E.calendar = {
  application: CalendarHUD,
  calendars: [
    {
      value: "gregorian",
      label: "DND5E.CALENDAR.Gregorian",
      config: foundry.data.SIMPLIFIED_GREGORIAN_CALENDAR_CONFIG
    },
    {
      value: "greyhawk",
      label: "DND5E.CALENDAR.Greyhawk.Name",
      config: CALENDAR_OF_GREYHAWK,
      class: CalendarGreyhawk
    },
    {
      value: "harptos",
      label: "DND5E.CALENDAR.Harptos.Name",
      config: CALENDAR_OF_HARPTOS,
      class: CalendarHarptos
    },
    {
      value: "khorvaire",
      label: "DND5E.CALENDAR.Khorvaire.Name",
      config: CALENDAR_OF_KHORVAIRE,
      class: CalendarKhorvaire
    }
  ],
  formatters: [
    {
      value: "monthDay",
      label: "DND5E.CALENDAR.Formatters.MonthDay.Label",
      formatter: "formatMonthDay",
      group: "DND5E.CALENDAR.Formatters.Date"
    },
    {
      value: "monthDayYear",
      label: "DND5E.CALENDAR.Formatters.MonthDayYear.Label",
      formatter: "formatMonthDayYear",
      group: "DND5E.CALENDAR.Formatters.Date"
    },
    {
      value: "approximateDate",
      label: "DND5E.CALENDAR.Formatters.ApproximateDate.Label",
      formatter: "formatApproximateDate",
      group: "DND5E.CALENDAR.Formatters.Date"
    },
    {
      value: "hoursMinutes",
      label: "DND5E.CALENDAR.Formatters.HoursMinutes.Label",
      formatter: "formatHoursMinutes",
      group: "DND5E.CALENDAR.Formatters.Time"
    },
    {
      value: "hoursMinutesSeconds",
      label: "DND5E.CALENDAR.Formatters.HoursMinutesSeconds.Label",
      formatter: "formatHoursMinutesSeconds",
      group: "DND5E.CALENDAR.Formatters.Time"
    },
    {
      value: "approximateTime",
      label: "DND5E.CALENDAR.Formatters.ApproximateTime.Label",
      formatter: "formatApproximateTime",
      group: "DND5E.CALENDAR.Formatters.Time"
    }
  ]
};
preLocalize("calendar.calendars", { keys: ["label", "group"] });
preLocalize("calendar.formatters", { keys: ["label", "group"] });

/* -------------------------------------------- */
/*  Requests                                    */
/* -------------------------------------------- */

/**
 * Handler functions for named request/response operations
 * @type {Record<string, RequestCallback5e>}
 */
DND5E.requests = {
  rest: Actor5e.handleRestRequest,
  skill: Actor5e.handleSkillCheckRequest
};

/* -------------------------------------------- */
/*  Rules                                       */
/* -------------------------------------------- */

/**
 * Types of rules that can be used in rule pages and the &Reference enricher.
 * @enum {RuleTypeConfiguration}
 */
DND5E.ruleTypes = {
  rule: {
    label: "DND5E.Rule.Type.Rule",
    references: "rules"
  },
  ability: {
    label: "DND5E.Ability",
    references: "enrichmentLookup.abilities"
  },
  areaOfEffect: {
    label: "DND5E.AreaOfEffect.Label",
    references: "areaTargetTypes"
  },
  condition: {
    label: "DND5E.Rule.Type.Condition",
    references: "conditionTypes"
  },
  creatureType: {
    label: "DND5E.CreatureType",
    references: "creatureTypes"
  },
  damage: {
    label: "DND5E.DamageType",
    references: "damageTypes"
  },
  skill: {
    label: "DND5E.Skill",
    references: "enrichmentLookup.skills"
  },
  spellComponent: {
    label: "DND5E.SpellComponent",
    references: "itemProperties"
  },
  spellSchool: {
    label: "DND5E.SpellSchool",
    references: "enrichmentLookup.spellSchools"
  },
  spellTag: {
    label: "DND5E.SpellTag",
    references: "itemProperties"
  },
  weaponMastery: {
    label: "DND5E.WEAPON.Mastery.Label",
    references: "weaponMasteries"
  }
};
preLocalize("ruleTypes", { key: "label" });

/* -------------------------------------------- */

/**
 * List of rules that can be referenced from enrichers.
 * @enum {string}
 */
DND5E.rules = {
  inspiration: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.nkEPI89CiQnOaLYh",
  carryingcapacity: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.1PnjDBKbQJIVyc2t",
  push: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.Hni8DjqLzoqsVjb6",
  lift: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.Hni8DjqLzoqsVjb6",
  drag: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.Hni8DjqLzoqsVjb6",
  encumbrance: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.JwqYf9qb6gJAWZKs",
  hiding: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.plHuoNdS0j3umPNS",
  passiveperception: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.988C2hQNyvqkdbND",
  time: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.eihqNjwpZ3HM4IqY",
  speed: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.HhqeIiSj8sE1v1qZ",
  travelpace: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.eFAISahBloR2X8MX",
  forcedmarch: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.uQWQpRKQ1kWhuvjZ",
  difficultterrainpace: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.hFW5BR2yHHwwgurD",
  climbing: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.KxUXbMrUCIAhv4AF",
  swimming: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.KxUXbMrUCIAhv4AF",
  longjump: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.1U0myNrOvIVBUdJV",
  highjump: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.raPwIkqKSv60ELmy",
  falling: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.kREHL5pgNUOhay9f",
  suffocating: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.BIlnr0xYhqt4TGsi",
  vision: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.O6hamUbI9kVASN8b",
  light: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.O6hamUbI9kVASN8b",
  lightlyobscured: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.MAxtfJyvJV7EpzWN",
  heavilyobscured: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.wPFjfRruboxhtL4b",
  brightlight: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.RnMokVPyKGbbL8vi",
  dimlight: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.n1Ocpbyhr6HhgbCG",
  darkness: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.4dfREIDjG5N4fvxd",
  blindsight: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.tdtmXZbUIOZGSnKT",
  darkvision: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.7vQ1hLQ5fS6SSUqF",
  tremorsense: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.r64UrNusMhwJVnxb",
  truesight: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.be7g0q1xBAwb8drv",
  food: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.jayo7XVgGnRCpTW0",
  water: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.iIEI87J7lr2sqtb5",
  resting: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.dpHJXYLigIdEseIb",
  shortrest: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.1s2swI3UsjUUgbt2",
  longrest: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.6cLtjbHn4KV2R7G9",
  surprise: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.YmOt8HderKveA19K",
  initiative: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.RcwElV4GAcVXKWxo",
  bonusaction: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.2fu2CXsDg8gQmGGw",
  reaction: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.2VqLyxMyMxgXe2wC",
  difficultterrain: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.6tqz947qO8vPyxvD",
  beingprone: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.bV8akkBdVUUG21CO",
  droppingprone: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.hwTLpAtSS5OqQsI1",
  standingup: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.hwTLpAtSS5OqQsI1",
  crawling: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.VWG9qe8PUNtS28Pw",
  movingaroundothercreatures: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.9ZWCknaXCOdhyOrX",
  flying: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.0B1fxfmw0a48tPsc",
  size: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.HWHRQVBVG7K0RVVW",
  space: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.WIA5bs3P45PmO3OS",
  squeezing: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.wKtOwagDAiNfVoPS",
  attack: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.f4fZHwBvpbpzRyn4",
  castaspell: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.iIIDUsmSOkL0xNzF",
  dash: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.6l6nBKip4LqB1sCU",
  disengage: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.w1AGsemFERfjqWNx",
  dodge: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.3YJIuyCMmuUrfmuX",
  help: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.5S8i59qskkd9GGcJ",
  hide: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.rqhOsUY4wWa1oHTy",
  ready: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.nI9tN6Oq7fCV7hcA",
  search: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.ySj4gYZ4ADZoia7R",
  useanobject: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.UDlogfdiT2uYEZz4",
  attackrolls: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.W8uJrd1D8NeOuawp",
  unseenattackers: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.5ZJNwEPlsGurecg5",
  unseentargets: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.5ZJNwEPlsGurecg5",
  rangedattacks: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.S9aclVOCbusLE3kC",
  range: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.HjKXuB8ndjcqOds7",
  rangedattacksinclosecombat: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.qEZvxW0NM7ixSQP5",
  meleeattacks: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.GTk6emvzNxl8Oosl",
  reach: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.hgZ5ZN4B3y7tmFlt",
  unarmedstrike: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.xJjJ4lhymAYXAOvO",
  opportunityattacks: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.eNvzQabiTqTtfzis",
  twoweaponfighting: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.FQTS08uH74A6psL2",
  grappling: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.YSLWJcQCP6kzsPql",
  escapingagrapple: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.2TZKy9YbMN3ZY3h8",
  movingagrappledcreature: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.x5bUdhAD7u5Bt2rg",
  shoving: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.hrdqMF8hRXJdNzJx",
  cover: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.W7f7PcRubNUMIq2S",
  halfcover: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.hv0J61IAfofuhy3Q",
  threequarterscover: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.zAMStUjUrPV10dFm",
  totalcover: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.BKUAxXuPEzxiEOeL",
  hitpoints: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.PFbzoMBviI2DD9QP",
  damagerolls: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.hd26AqKrCqtcQBWy",
  criticalhits: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.gFL1VhSEljL1zvje",
  damagetypes: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.jVOgf7DNEhkzYNIe",
  damageresistance: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.v0WE18nT5SJO8Ft7",
  damagevulnerability: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.v0WE18nT5SJO8Ft7",
  healing: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.ICketFqbFslqKiX9",
  instantdeath: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.8BG05mA0mEzwmrHU",
  deathsavingthrows: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.JL8LePEJQYFdNuLL",
  deathsaves: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.JL8LePEJQYFdNuLL",
  stabilizing: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.r1CgZXLcqFop6Dlx",
  knockingacreatureout: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.PjXBMVcEaWuKjder",
  temporaryhitpoints: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.qOKtJt8CB2qRaTNA",
  temphp: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.qOKtJt8CB2qRaTNA",
  mounting: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.MFpyvUIdcBpC9kIE",
  dismounting: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.MFpyvUIdcBpC9kIE",
  controllingamount: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.khmR2xFk1NxoQUgZ",
  underwatercombat: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.6zVOeLyq4iMnrQT4",
  spelllevel: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.A6k5fS0kFqPXTW3v",
  knownspells: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.oezg742GlxmEwT85",
  preparedspells: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.oezg742GlxmEwT85",
  spellslots: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.Su6wbb0O9UN4ZDIH",
  castingatahigherlevel: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.4H9SLM95OCLfFizz",
  upcasting: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.4H9SLM95OCLfFizz",
  castinginarmor: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.z4A8vHSK2pb8YA9X",
  cantrips: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.jZD5mCTnMPJ9jW67",
  rituals: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.FjWqT5iyJ89kohdA",
  castingtime: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.zRVW8Tvyk6BECjZD",
  bonusactioncasting: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.RP1WL9FXI3aknlxZ",
  reactioncasting: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.t62lCfinwU9H7Lji",
  longercastingtimes: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.gOAIRFCyPUx42axn",
  spellrange: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.RBYPyE5z5hAZSbH6",
  components: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.xeHthAF9lxfn2tII",
  verbal: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.6UXTNWMCQ0nSlwwx",
  spellduration: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.9mp0SRsptjvJcq1e",
  instantaneous: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.kdlgZOpRMB6bGCod",
  concentrating: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.ow58p27ctAnr4VPH",
  spelltargets: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.G80AIQr04sxdVpw4",
  areaofeffect: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.wvtCeGHgnUmh0cuj",
  pointoforigin: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.8HxbRceQQUAhyWRt",
  spellsavingthrows: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.8DajfNll90eeKcmB",
  spellattackrolls: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.qAFzmGZKhVvAEUF3",
  combiningmagicaleffects: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.TMIN963hG773yZzO",
  schoolsofmagic: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.TeF6CKMDRpYpsLd4",
  detectingtraps: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.DZ7AhdQ94xggG4bj",
  disablingtraps: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.DZ7AhdQ94xggG4bj",
  curingmadness: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.6Icem7G3CICdNOkM",
  damagethreshold: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.9LJZhqvCburpags3",
  poisontypes: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.I6OMMWUaYCWR9xip",
  contactpoison: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.kXnCEqqGUWRZeZDj",
  ingestedpoison: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.Y0vsJYSWeQcFpJ27",
  inhaledpoison: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.KUyN4eK1xTBzXsjP",
  injurypoison: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.LUL48OUq6SJeMGc7",
  attunement: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.UQ65OwIyGK65eiOK",
  wearingitems: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.iPB8mGKuQx3X0Z2J",
  wieldingitems: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.iPB8mGKuQx3X0Z2J",
  multipleitemsofthesamekind: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.rLJdvz4Mde8GkEYQ",
  paireditems: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.rd9pCH8yFraSGN34",
  commandword: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.HiXixxLYesv6Ff3t",
  consumables: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.UEPAcZFzQ5x196zE",
  itemspells: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.DABoaeeF6w31UCsj",
  charges: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.NLRXcgrpRCfsA5mO",
  spellscroll: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.gi8IKhtOlBVhMJrN",
  creaturetags: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.9jV1fFF163dr68vd",
  telepathy: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.geTidcFIYWuUvD2L",
  legendaryactions: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.C1awOyZh78pq1xmY",
  lairactions: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.07PtjpMxiRIhkBEp",
  regionaleffects: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.uj8W27NKFyzygPUd",
  disease: "Compendium.dnd5e.content24.JournalEntry.phbAppendixDRule.JournalEntryPage.oNQWvyRZkTOJ8PBq",
  d20test: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.nxPH59t3iNtWJxnU",
  advantage: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.lvs9RRDi1UA1Lff8",
  disadvantage: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.fFrHBgqKUMY0Nnco",
  difficultyclass: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.afnB0KZZk2hKtjv4",
  armorclass: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.IL73rq9BlQowdon7",
  abilitycheck: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.XBQqXCoTbvp5Dika",
  savingthrow: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.Vlri6Mp6grn9wt3g",
  challengerating: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.BMoxmXB8pX6bOBus",
  expertise: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.69nu4Sk3V5O15GFf",
  influence: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.4V59Q1dlWjNhpJGo",
  magic: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.iIIDUsmSOkL0xNzF",
  study: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.Nuz0Wx4a4aAPcC34",
  utilize: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.UDlogfdiT2uYEZz4",
  friendly: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.RVcWSqblHIs7SUzn",
  indifferent: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.eYX5eimGuYhHPoj4",
  hostile: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.BNxLbtJofbNGzjsp",
  breakingobjects: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.RXTLVpAwcGm1qtKf",
  hazards: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.5hyEitPd1Kb27fP5",
  bloodied: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.shZaSIlFPpHufPFn",
  jumping: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.aaJOlRhI1H6vAxt9",
  resistance: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.Uk3xhCTvEfx8BN1O",
  stable: "Compendium.dnd5e.content24.JournalEntry.phbAppendixCRule.JournalEntryPage.klXWp4c90n7Kt5LB"
};

/* -------------------------------------------- */
/*  Sources                                     */
/* -------------------------------------------- */

/**
 * List of books available as sources.
 * @enum {string}
 */
DND5E.sourceBooks = {};
preLocalize("sourceBooks", { sort: true });

/* -------------------------------------------- */
/*  Themes                                      */
/* -------------------------------------------- */

/**
 * Themes that can be set for the system or on sheets.
 * @enum {string}
 */
DND5E.themes = {
  light: "SHEETS.DND5E.THEME.Light",
  dark: "SHEETS.DND5E.THEME.Dark"
};
preLocalize("themes");

/* -------------------------------------------- */
/*  Enrichment                                  */
/* -------------------------------------------- */

let _enrichmentLookup;
Object.defineProperty(DND5E, "enrichmentLookup", {
  get() {
    const slugify = value => value?.slugify().replaceAll("-", "");
    if ( !_enrichmentLookup ) {
      _enrichmentLookup = {
        abilities: foundry.utils.deepClone(DND5E.abilities),
        languages: _flattenConfig(DND5E.languages, { labelKey: "label", skipEntry: (k, d) => d.selectable === false }),
        skills: foundry.utils.deepClone(DND5E.skills),
        spellSchools: foundry.utils.deepClone(DND5E.spellSchools),
        tools: foundry.utils.deepClone(DND5E.tools)
      };
      const addFullKeys = key => Object.entries(DND5E[key]).forEach(([k, v]) =>
        _enrichmentLookup[key][slugify(v.fullKey)] = { ...v, key: k }
      );
      addFullKeys("abilities");
      addFullKeys("skills");
      addFullKeys("spellSchools");
    }
    return _enrichmentLookup;
  },
  enumerable: true
});

/* -------------------------------------------- */

/**
 * Create a flattened version of a nested config (such as CONFIG.DND5E.languages) so all leaf entries are at
 * a single level.
 * @param {object} config
 * @param {object} [options={}]
 * @param {string} [options.labelKey]        If provided, simplify all included objects to just the label.
 * @param {Function} [options.skipCategory]  Callback passed the key and data that should return a boolean to skip a
 *                                           category but not its children when creating flattened object.
 * @returns {object}
 */
function _flattenConfig(config, { labelKey, skipEntry }={}) {
  const obj = {};
  for ( const [key, data] of Object.entries(config) ) {
    if ( !skipEntry?.(key, data) ) {
      if ( labelKey && (foundry.utils.getType(data) === "Object") ) obj[key] = data[labelKey];
      else obj[key] = data;
    }
    if ( data.children ) Object.assign(obj, _flattenConfig(data.children, { labelKey, skipEntry }));
  }
  return obj;
}

/* -------------------------------------------- */

/**
 * 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 => {
    if ( foundry.utils.getType(o) !== "Object" ) return;
    Object.defineProperty(o, "toString", {value: toString});
  });
}

const { BooleanField: BooleanField$i, NumberField: NumberField$i } = foundry.data.fields;

/**
 * @import { BastionSettingData } from "./_types.mjs";
 */

/**
 * A data model that represents the Bastion configuration options.
 * @extends {foundry.abstract.DataModel<BastionSettingData>}
 * @mixes BastionSettingData
 */
class BastionSetting extends foundry.abstract.DataModel {
  /** @override */
  static defineSchema() {
    return {
      button: new BooleanField$i({
        required: true, label: "DND5E.Bastion.Button.Label", hint: "DND5E.Bastion.Button.Hint"
      }),
      duration: new NumberField$i({
        required: true, positive: true, integer: true, initial: 7, label: "DND5E.Bastion.Duration.Label"
      }),
      enabled: new BooleanField$i({
        required: true, label: "DND5E.Bastion.Enabled.Label", hint: "DND5E.Bastion.Enabled.Hint"
      })
    };
  }
}

const { BooleanField: BooleanField$h, DataField, NumberField: NumberField$h, StringField: StringField$s } = foundry.data.fields;

/**
 * Base application for configuring system settings.
 */
class BaseSettingsConfig extends Application5e {
  /** @override */
  static DEFAULT_OPTIONS = {
    tag: "form",
    classes: ["standard-form"],
    position: {
      width: 500
    },
    form: {
      closeOnSubmit: true,
      handler: BaseSettingsConfig.#onCommitChanges
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    config: {
      template: "systems/dnd5e/templates/settings/base-config.hbs"
    },
    footer: {
      template: "templates/generic/form-footer.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    context.fields = [];
    context.buttons = [{ type: "submit", icon: "fas fa-save", label: "Save Changes" }];
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Create the field data for a specific setting.
   * @param {string} name  Setting key within the dnd5e namespace.
   * @returns {object}
   */
  createSettingField(name) {
    const setting = game.settings.settings.get(`dnd5e.${name}`);
    if ( !setting ) throw new Error(`Setting \`dnd5e.${name}\` not registered.`);
    const isDataField = setting.type instanceof DataField;
    const Field = { [Boolean]: BooleanField$h, [Number]: NumberField$h, [String]: StringField$s }[setting.type];
    if ( !isDataField && !Field ) {
      throw new Error("Automatic field generation only available for Boolean, Number, or String types");
    }
    const data = {
      name,
      field: isDataField ? setting.type : new Field({ required: true, blank: false }),
      hint: game.i18n.localize(setting.hint),
      label: game.i18n.localize(setting.name),
      value: game.settings.get("dnd5e", name)
    };
    if ( (setting.type === Boolean) || (setting.type instanceof BooleanField$h) ) data.input = createCheckboxInput;
    if ( setting.choices ) data.options = Object.entries(setting.choices)
      .map(([value, label]) => ({ value, label: game.i18n.localize(label) }));
    return data;
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /**
   * Commit settings changes.
   * This method processes the submitted form data, updates the settings, and determines if a reload is required.
   * @this {BaseSettingsConfig}
   * @param {SubmitEvent} event          The submission event.
   * @param {HTMLFormElement} form       The submitted form element.
   * @param {FormDataExtended} formData  The submitted form data.
   * @returns {Promise<void>}            Resolves once the settings are updated, or prompts for a reload if required.
   */
  static async #onCommitChanges(event, form, formData) {
    let requiresClientReload = false;
    let requiresWorldReload = false;
    for ( const [key, value] of Object.entries(foundry.utils.expandObject(formData.object)) ) {
      const setting = game.settings.settings.get(`dnd5e.${key}`);
      const current = game.settings.get("dnd5e", key, { document: true });
      const prior = current?._source?.value ?? current;
      const updated = await game.settings.set("dnd5e", key, value, { document: true });
      if ( prior === (updated?._source?.value ?? updated) ) continue;
      requiresClientReload ||= (setting.scope !== "world") && setting.requiresReload;
      requiresWorldReload ||= (setting.scope === "world") && setting.requiresReload;
    }
    if ( requiresClientReload || requiresWorldReload ) {
      return SettingsConfig.reloadConfirm({ world: requiresWorldReload });
    }
  }
}

/**
 * An application for configuring bastion settings.
 */
class BastionSettingsConfig extends BaseSettingsConfig {
  /** @override */
  static DEFAULT_OPTIONS = {
    window: {
      title: "DND5E.Bastion.Configuration.Label"
    }
  };

  /** @override */
  static PARTS = {
    ...super.PARTS,
    config: {
      template: "systems/dnd5e/templates/settings/bastion-config.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    context.fields = BastionSetting.schema.fields;
    context.source = game.settings.get("dnd5e", "bastionConfiguration");
    return context;
  }
}

const { BooleanField: BooleanField$g, SchemaField: SchemaField$m, StringField: StringField$r } = foundry.data.fields;

/**
 * @import { CalendarConfigSettingData, CalendarPreferencesSettingData } from "./_types.mjs";
 */

/**
 * A data model that represents the GM-specific calendar settings.
 * @extends {foundry.abstract.DataModel<CalendarConfigSettingData>}
 * @mixes CalendarConfigSettingData
 */
class CalendarConfigSetting extends foundry.abstract.DataModel {

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.CALENDAR"];

  /* -------------------------------------------- */

  /** @override */
  static defineSchema() {
    return {
      enabled: new BooleanField$g()
    };
  }
}

/* -------------------------------------------- */

/**
 * A data model that represents the player visible calendar settings.
 * @extends {foundry.abstract.DataModel<CalendarPreferencesSettingData>}
 * @mixes CalendarPreferencesSettingData
 */
class CalendarPreferencesSetting extends foundry.abstract.DataModel {

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.CALENDAR"];

  /* -------------------------------------------- */

  /** @override */
  static defineSchema() {
    return {
      formatters: new SchemaField$m({
        date: new StringField$r({ required: true, initial: "monthDay" }),
        time: new StringField$r({ required: true, initial: "hoursMinutes" })
      }),
      visible: new BooleanField$g({ required: true, initial: true })
    };
  }
}

const { BooleanField: BooleanField$f } = foundry.data.fields;

/**
 * An application for configuring calendar settings.
 */
class CalendarSettingsConfig extends BaseSettingsConfig {
  /** @override */
  static DEFAULT_OPTIONS = {
    window: {
      title: "DND5E.CALENDAR.Configuration.Label"
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    config: {
      template: "systems/dnd5e/templates/settings/base-config.hbs"
    },
    preferences: {
      template: "systems/dnd5e/templates/settings/base-config.hbs"
    },
    footer: {
      template: "templates/generic/form-footer.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _configureRenderParts(options) {
    const parts = super._configureRenderParts(options);
    if ( !game.user.isGM ) delete parts.config;
    return parts;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    switch ( partId ) {
      case "config": return this._prepareConfigContext(context, options);
      case "preferences": return this._preparePreferencesContext(context, options);
    }
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare rendering context for the DM configuration section
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {ApplicationRenderContext}
   * @protected
   */
  async _prepareConfigContext(context, options) {
    const data = game.settings.get("dnd5e", "calendarConfig");
    context.fields = Object.entries(CalendarConfigSetting.schema.fields)
      .filter(([name]) => name !== "buttons")
      .map(([name, field]) => ({
        field,
        input: field instanceof BooleanField$f ? createCheckboxInput : undefined,
        name: `calendarConfig.${name}`,
        value: data[name]
      }));
    context.fields.splice(1, 0, this.createSettingField("calendar"));

    if ( !CONFIG.DND5E.calendar.application ) {
      const enabledField = context.fields.find(f => f.name === "calendarConfig.enabled");
      enabledField.disabled = true;
      enabledField.value = false;
      context.message = {
        level: "warn",
        text: game.i18n.localize("DND5E.CALENDAR.Configuration.UnavailableMessage")
      };
    }

    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare rendering context for the player preferences section
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {ApplicationRenderContext}
   * @protected
   */
  async _preparePreferencesContext(context, options) {
    const data = game.settings.get("dnd5e", "calendarPreferences");
    const fields = CalendarPreferencesSetting.schema.fields;
    context.fields = [
      {
        field: fields.visible,
        input: createCheckboxInput,
        name: "calendarPreferences.visible",
        value: data.visible
      },
      {
        field: fields.formatters.fields.date,
        name: "calendarPreferences.formatters.date",
        options: CONFIG.DND5E.calendar.formatters,
        value: data.formatters.date
      },
      {
        field: fields.formatters.fields.time,
        name: "calendarPreferences.formatters.time",
        options: CONFIG.DND5E.calendar.formatters,
        value: data.formatters.time
      }
    ];
    context.legend = game.i18n.localize("DND5E.CALENDAR.Configuration.Preferences");
    if ( !CONFIG.DND5E.calendar.application ) {
      context.disabled = true;
      if ( !game.user.isGM ) context.message = {
        level: "warn",
        text: game.i18n.localize("DND5E.CALENDAR.Configuration.UnavailableMessage")
      };
    } else if ( !game.settings.get("dnd5e", "calendarConfig")?.enabled ) {
      context.disabled = !game.user.isGM;
      context.message = {
        level: "warn",
        text: game.i18n.localize("DND5E.CALENDAR.Configuration.DisabledMessage")
      };
    }
    return context;
  }
}

/**
 * An application for configuring combat settings.
 */
class CombatSettingsConfig extends BaseSettingsConfig {
  /** @override */
  static DEFAULT_OPTIONS = {
    window: {
      title: "SETTINGS.DND5E.COMBAT.Label"
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    initiative: {
      template: "systems/dnd5e/templates/settings/base-config.hbs"
    },
    criticals: {
      template: "systems/dnd5e/templates/settings/base-config.hbs"
    },
    npcs: {
      template: "systems/dnd5e/templates/settings/base-config.hbs"
    },
    footer: {
      template: "templates/generic/form-footer.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    switch ( partId ) {
      case "initiative":
        context.fields = [
          this.createSettingField("initiativeDexTiebreaker"),
          this.createSettingField("initiativeScore")
        ];
        context.legend = game.i18n.localize("DND5E.Initiative");
        break;
      case "criticals":
        context.fields = [
          this.createSettingField("criticalDamageModifiers"),
          this.createSettingField("criticalDamageMaxDice")
        ];
        context.legend = game.i18n.localize("SETTINGS.DND5E.CRITICAL.Name");
        break;
      case "npcs":
        context.fields = [
          this.createSettingField("autoRecharge"),
          this.createSettingField("autoRollNPCHP")
        ];
        context.legend = game.i18n.localize("SETTINGS.DND5E.NPCS.Name");
        break;
    }
    return context;
  }
}

/**
 * @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}
   */
  get suppressArt() {
    return !game.compendiumArt.enabled;
  }

  set suppressArt(value) {
    game.compendiumArt.enabled = !value;
  }

  /* -------------------------------------------- */

  /**
   * Register any art mapping information included in active modules.
   * @returns {Promise<void>}
   */
  async registerModuleArt() {
    this.map.clear();
    // Load art modules in reverse order so that higher-priority modules overwrite lower-priority ones.
    for ( const { id, mapping, credit } of this.constructor.getArtModules().reverse() ) {
      foundry.utils.logCompatibilityWarning(
        "The dnd5e `ModuleArt` system has been deprecated and replaced with core's `CompendiumArt` system.",
        { since: "DnD5e 5.0", until: "DnD5e 6.0", once: true }
      );
      try {
        const json = await foundry.utils.fetchJsonWithTimeout(mapping);
        await this.#parseArtMapping(id, json, 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 = pack.getUuid(actorId);
        info = foundry.utils.mergeObject(this.map.get(uuid) ?? {}, info, {inplace: false});
        this.map.set(uuid, info);
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Apply module portrait art to a freshly generated compendium index.
   * @param {Collection} index  Index generated by `CompendiumCollection#getIndex`.
   * @returns {Collection}
   */
  apply(index) {
    for ( const entry of index ) {
      entry.img ??= this.map.get(entry.uuid)?.actor ?? this.map.get(entry.uuid.replace("Actor.", ""))?.actor;
    }
    return index;
  }

  /* -------------------------------------------- */

  /**
   * 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;
  }

  /* -------------------------------------------- */

  /**
   * @typedef {object} ModuleArtDescriptor
   * @property {string} id        The module ID.
   * @property {string} label     The module title.
   * @property {string} mapping   The path to the art mapping file.
   * @property {string} [credit]  An optional credit line to attack to the Actor's biography.
   * @property {number} priority  The module's user-configured priority.
   */

  /**
   * Returns all currently configured art modules in priority order.
   * @returns {ModuleArtDescriptor[]}
   */
  static getArtModules() {
    const settings = game.settings.get("dnd5e", "moduleArtConfiguration");
    const unsorted = [];
    const configs = [];

    for ( const module of game.modules ) {
      const flags = module.flags?.[module.id];
      const mapping = this.getModuleArtPath(module);
      if ( !mapping ) continue;
      const config = { id: module.id, label: module.title, credit: flags?.["dnd5e-art-credit"], mapping };
      configs.push(config);
      const priority = settings[module.id]?.priority;
      if ( priority === undefined ) unsorted.push(config);
      else config.priority = priority;
    }

    const maxPriority = Math.max(...configs.map(({ priority }) => priority ?? -Infinity));
    unsorted.forEach((config, i) => config.priority = maxPriority + ((i + 1) * CONST.SORT_INTEGER_DENSITY));
    configs.sort((a, b) => a.priority - b.priority);
    return configs;
  }
}

/**
 * A class responsible for allowing GMs to configure art provided by installed modules.
 */
class ModuleArtSettingsConfig 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.hbs",
      popOut: true,
      width: 600,
      height: "auto"
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  getData(options={}) {
    const context = super.getData(options);
    context.config = [];
    for ( const config of ModuleArt.getArtModules() ) {
      const settings = this.object[config.id] ?? { portraits: true, tokens: true };
      context.config.push({ ...config, ...settings });
    }
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  activateListeners(html) {
    super.activateListeners(html);
    html.find("[data-action]").on("click", this._onAction.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * Handle priority increase or decrease actions.
   * @param {PointerEvent} event  The triggering event.
   * @protected
   */
  _onAction(event) {
    const action = event.currentTarget.dataset.action;
    const item = event.currentTarget.closest("[data-id]");
    const id = item.dataset.id;
    const configs = [];
    for ( const element of this.form.elements ) {
      const [id, key] = element.name.split(".");
      if ( key === "priority" ) configs.push({ id, priority: Number(element.value) });
    }
    const idx = configs.findIndex(config => config.id === id);
    if ( idx < 0 ) return;
    if ( (action === "increase") && (idx === 0) ) return;
    if ( (action === "decrease") && (idx === configs.length - 1) ) return;
    const sortBefore = action === "increase";
    const config = configs[idx];
    const target = configs[sortBefore ? idx - 1 : idx + 1];
    configs.splice(idx, 1);
    const updates = foundry.utils.performIntegerSort(config, {
      target, sortBefore,
      siblings: configs,
      sortKey: "priority"
    });
    updates.forEach(({ target, update }) => this.form.elements[`${target.id}.priority`].value = update.priority);
    if ( action === "increase" ) item.previousElementSibling.insertAdjacentElement("beforebegin", item);
    else item.nextElementSibling.insertAdjacentElement("afterend", item);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _updateObject(event, formData) {
    await game.settings.set("dnd5e", "moduleArtConfiguration", foundry.utils.expandObject(formData));
    return SettingsConfig.reloadConfirm({world: true});
  }
}

/**
 * An application for configuring variant rules settings.
 */
class VariantRulesSettingsConfig extends BaseSettingsConfig {
  /** @override */
  static DEFAULT_OPTIONS = {
    window: {
      title: "SETTINGS.DND5E.VARIANT.Label"
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    general: {
      template: "systems/dnd5e/templates/settings/base-config.hbs"
    },
    encumbrance: {
      template: "systems/dnd5e/templates/settings/base-config.hbs"
    },
    abilities: {
      template: "systems/dnd5e/templates/settings/base-config.hbs"
    },
    footer: {
      template: "templates/generic/form-footer.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    switch ( partId ) {
      case "general":
        context.fields = [
          game.settings.get("dnd5e", "rulesVersion") === "legacy" ? this.createSettingField("allowFeats") : null,
          this.createSettingField("restVariant"),
          this.createSettingField("proficiencyModifier"),
          this.createSettingField("levelingMode")
        ].filter(_ => _);
        context.legend = game.i18n.localize("SETTINGS.DND5E.General");
        break;
      case "encumbrance":
        context.fields = [
          this.createSettingField("encumbrance"),
          this.createSettingField("currencyWeight")
        ];
        context.legend = game.i18n.localize("DND5E.Encumbrance");
        break;
      case "abilities":
        context.fields = [
          this.createSettingField("honorScore"),
          this.createSettingField("sanityScore")
        ];
        context.legend = game.i18n.localize("DND5E.Abilities");
        break;
    }
    return context;
  }
}

/**
 * An application for configuring player visibility settings.
 */
class VisibilitySettingsConfig extends BaseSettingsConfig {
  /** @override */
  static DEFAULT_OPTIONS = {
    window: {
      title: "SETTINGS.DND5E.VISIBILITY.Label"
    }
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    context.fields = [
      this.createSettingField("challengeVisibility"),
      this.createSettingField("attackRollVisibility"),
      this.createSettingField("bloodied"),
      this.createSettingField("concealItemDescriptions")
    ];
    return context;
  }
}

const { ForeignDocumentField: ForeignDocumentField$4 } = foundry.data.fields;

/**
 * @import { PrimaryPartySettingData } from "./_types.mjs";
 */

/**
 * Data model for tracking information on the primary party.
 * @extends {foundry.abstract.DataModel<PrimaryPartySettingData>}
 * @mixes PrimaryPartySettingData
 */
class PrimaryPartySetting extends foundry.abstract.DataModel {
  static defineSchema() {
    return {
      actor: new ForeignDocumentField$4(foundry.documents.BaseActor)
    };
  }
}

/**
 * Replacement reference UUIDs for the legacy rules.
 * @type {Record<string, Record<string, string>>}
 */
const REFERENCES = {
  abilities: {
    str: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.nUPv6C66Ur64BIUH",
    dex: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.ER8CKDUWLsFXuARJ",
    con: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.MpA4jnwD17Q0RPg7",
    int: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.WzWWcTIppki35YvF",
    wis: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.v3IPyTtqvXqN934s",
    cha: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.9FyghudYFV5QJOuG"
  },
  conditionTypes: {
    blinded: "Compendium.dnd5e.rules.JournalEntry.w7eitkpD7QQTB6j0.JournalEntryPage.0b8N4FymGGfbZGpJ",
    charmed: "Compendium.dnd5e.rules.JournalEntry.w7eitkpD7QQTB6j0.JournalEntryPage.zZaEBrKkr66OWJvD",
    deafened: "Compendium.dnd5e.rules.JournalEntry.w7eitkpD7QQTB6j0.JournalEntryPage.6G8JSjhn701cBITY",
    exhaustion: "Compendium.dnd5e.rules.JournalEntry.w7eitkpD7QQTB6j0.JournalEntryPage.cspWveykstnu3Zcv",
    frightened: "Compendium.dnd5e.rules.JournalEntry.w7eitkpD7QQTB6j0.JournalEntryPage.oreoyaFKnvZCrgij",
    grappled: "Compendium.dnd5e.rules.JournalEntry.w7eitkpD7QQTB6j0.JournalEntryPage.gYDAhd02ryUmtwZn",
    incapacitated: "Compendium.dnd5e.rules.JournalEntry.w7eitkpD7QQTB6j0.JournalEntryPage.TpkZgLfxCmSndmpb",
    invisible: "Compendium.dnd5e.rules.JournalEntry.w7eitkpD7QQTB6j0.JournalEntryPage.3UU5GCTVeRDbZy9u",
    paralyzed: "Compendium.dnd5e.rules.JournalEntry.w7eitkpD7QQTB6j0.JournalEntryPage.xnSV5hLJIMaTABXP",
    petrified: "Compendium.dnd5e.rules.JournalEntry.w7eitkpD7QQTB6j0.JournalEntryPage.xaNDaW6NwQTgHSmi",
    poisoned: "Compendium.dnd5e.rules.JournalEntry.w7eitkpD7QQTB6j0.JournalEntryPage.lq3TRI6ZlED8ABMx",
    prone: "Compendium.dnd5e.rules.JournalEntry.w7eitkpD7QQTB6j0.JournalEntryPage.y0TkcdyoZlOTmAFT",
    restrained: "Compendium.dnd5e.rules.JournalEntry.w7eitkpD7QQTB6j0.JournalEntryPage.cSVcyZyNe2iG1fIc",
    stunned: "Compendium.dnd5e.rules.JournalEntry.w7eitkpD7QQTB6j0.JournalEntryPage.ZyZMUwA2rboh4ObS",
    unconscious: "Compendium.dnd5e.rules.JournalEntry.w7eitkpD7QQTB6j0.JournalEntryPage.UWw13ISmMxDzmwbd"
  },
  spellSchools: {
    abj: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.849AYEWw9FHD6JNz",
    con: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.TWyKMhZJZGqQ6uls",
    div: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.HoD2MwzmVbMqj9se",
    enc: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.SehPXk24ySBVOwCZ",
    evo: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.kGp1RNuxL2SELLRC",
    ill: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.smEk7kvVyslFozrB",
    nec: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.W0eyiV1FBmngb6Qh",
    trs: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.IYWewSailtmv6qEb"
  },
  skills: {
    acr: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.AvvBLEHNl7kuwPkN",
    ani: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.xb3MCjUvopOU4viE",
    arc: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.h3bYSPge8IOqne1N",
    ath: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.rIR7ttYDUpH3tMzv",
    dec: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.mqVZ2fz0L7a9VeKJ",
    his: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.kRBZbdWMGW9K3wdY",
    ins: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.8R5SMbAGbECNgO8z",
    itm: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.4VHHI2gJ1jEsppfg",
    inv: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.Y7nmbQAruWOs7WRM",
    med: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.GeYmM7BVfSCAga4o",
    nat: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.ueMx3uF2PQlcye31",
    prc: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.zjEeHCUqfuprfzhY",
    prf: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.hYT7Z06yDNBcMtGe",
    per: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.4R5H8iIsdFQTsj3X",
    rel: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.CXVzERHdP4qLhJXM",
    slt: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.yg6SRpGNVz9nDW0A",
    ste: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.4MfrpERNiQXmvgCI",
    sur: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.t3EzDU5b9BVAIEVi"
  }
};

/* -------------------------------------------- */

/**
 * Replacement rule references.
 * @type {Record<string>}
 */
const RULES = {
  inspiration: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.nkEPI89CiQnOaLYh",
  carryingcapacity: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.1PnjDBKbQJIVyc2t",
  push: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.Hni8DjqLzoqsVjb6",
  lift: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.Hni8DjqLzoqsVjb6",
  drag: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.Hni8DjqLzoqsVjb6",
  encumbrance: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.JwqYf9qb6gJAWZKs",
  hiding: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.plHuoNdS0j3umPNS",
  passiveperception: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.988C2hQNyvqkdbND",
  time: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.eihqNjwpZ3HM4IqY",
  speed: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.HhqeIiSj8sE1v1qZ",
  travelpace: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.eFAISahBloR2X8MX",
  forcedmarch: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.uQWQpRKQ1kWhuvjZ",
  difficultterrainpace: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.hFW5BR2yHHwwgurD",
  climbing: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.KxUXbMrUCIAhv4AF",
  swimming: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.KxUXbMrUCIAhv4AF",
  longjump: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.1U0myNrOvIVBUdJV",
  highjump: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.raPwIkqKSv60ELmy",
  falling: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.kREHL5pgNUOhay9f",
  suffocating: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.BIlnr0xYhqt4TGsi",
  vision: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.O6hamUbI9kVASN8b",
  light: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.O6hamUbI9kVASN8b",
  lightlyobscured: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.MAxtfJyvJV7EpzWN",
  heavilyobscured: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.wPFjfRruboxhtL4b",
  brightlight: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.RnMokVPyKGbbL8vi",
  dimlight: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.n1Ocpbyhr6HhgbCG",
  darkness: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.4dfREIDjG5N4fvxd",
  blindsight: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.sacjsfm9ZXnw4Tqc",
  darkvision: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.ldmA1PbnEGVkmE11",
  tremorsense: "Compendium.dnd5e.rules.JournalEntry.eVtpEGXjA2tamEIJ.JournalEntryPage.8AIlZ95v54mL531X",
  truesight: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.kNa8rJFbtaTM3Rmk",
  food: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.jayo7XVgGnRCpTW0",
  water: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.iIEI87J7lr2sqtb5",
  resting: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.dpHJXYLigIdEseIb",
  shortrest: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.1s2swI3UsjUUgbt2",
  longrest: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.6cLtjbHn4KV2R7G9",
  surprise: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.YmOt8HderKveA19K",
  initiative: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.RcwElV4GAcVXKWxo",
  bonusaction: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.2fu2CXsDg8gQmGGw",
  reaction: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.2VqLyxMyMxgXe2wC",
  difficultterrain: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.6tqz947qO8vPyxvD",
  beingprone: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.bV8akkBdVUUG21CO",
  droppingprone: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.hwTLpAtSS5OqQsI1",
  standingup: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.hwTLpAtSS5OqQsI1",
  crawling: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.VWG9qe8PUNtS28Pw",
  movingaroundothercreatures: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.9ZWCknaXCOdhyOrX",
  flying: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.0B1fxfmw0a48tPsc",
  size: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.HWHRQVBVG7K0RVVW",
  space: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.WIA5bs3P45PmO3OS",
  squeezing: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.wKtOwagDAiNfVoPS",
  attack: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.u4GQCzoBig20yRLj",
  castaspell: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.GLwN36E4WXn3Cp4Z",
  dash: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.Jqn0MEvq6fduYNo6",
  disengage: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.ZOPRfI48NyjoloEF",
  dodge: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.V1BkwK2HQrtEfa4d",
  help: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.KnrD3u2AnQfmtOWj",
  hide: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.BXlHhE4ZoiFwiXLK",
  ready: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.8xJzZVelP2AmQGfU",
  search: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.5cn1ZTLgQq95vfZx",
  useanobject: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.ljqhJx8Qxu2ivo69",
  attackrolls: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.5wkqEqhbBD5kDeE7",
  unseenattackers: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.5ZJNwEPlsGurecg5",
  unseentargets: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.5ZJNwEPlsGurecg5",
  rangedattacks: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.S9aclVOCbusLE3kC",
  range: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.HjKXuB8ndjcqOds7",
  rangedattacksinclosecombat: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.qEZvxW0NM7ixSQP5",
  meleeattacks: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.GTk6emvzNxl8Oosl",
  reach: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.hgZ5ZN4B3y7tmFlt",
  unarmedstrike: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.xJjJ4lhymAYXAOvO",
  opportunityattacks: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.zeU0NyCyP10lkLg3",
  twoweaponfighting: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.FQTS08uH74A6psL2",
  grappling: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.Sl4bniSPSbyrakM2",
  escapingagrapple: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.2TZKy9YbMN3ZY3h8",
  movingagrappledcreature: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.x5bUdhAD7u5Bt2rg",
  shoving: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.hrdqMF8hRXJdNzJx",
  cover: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.W7f7PcRubNUMIq2S",
  halfcover: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.hv0J61IAfofuhy3Q",
  threequarterscover: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.zAMStUjUrPV10dFm",
  totalcover: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.BKUAxXuPEzxiEOeL",
  hitpoints: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.PFbzoMBviI2DD9QP",
  damagerolls: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.hd26AqKrCqtcQBWy",
  criticalhits: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.gFL1VhSEljL1zvje",
  damagetypes: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.jVOgf7DNEhkzYNIe",
  damageresistance: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.v0WE18nT5SJO8Ft7",
  damagevulnerability: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.v0WE18nT5SJO8Ft7",
  healing: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.ICketFqbFslqKiX9",
  instantdeath: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.8BG05mA0mEzwmrHU",
  deathsavingthrows: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.JL8LePEJQYFdNuLL",
  deathsaves: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.JL8LePEJQYFdNuLL",
  stabilizing: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.r1CgZXLcqFop6Dlx",
  knockingacreatureout: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.uEwjgKGuCRTNADYv",
  temporaryhitpoints: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.AW6HpJZHqxfESXaq",
  temphp: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.AW6HpJZHqxfESXaq",
  mounting: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.MFpyvUIdcBpC9kIE",
  dismounting: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.MFpyvUIdcBpC9kIE",
  controllingamount: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.khmR2xFk1NxoQUgZ",
  underwatercombat: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.6zVOeLyq4iMnrQT4",
  spelllevel: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.A6k5fS0kFqPXTW3v",
  knownspells: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.oezg742GlxmEwT85",
  preparedspells: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.oezg742GlxmEwT85",
  spellslots: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.Su6wbb0O9UN4ZDIH",
  castingatahigherlevel: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.4H9SLM95OCLfFizz",
  upcasting: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.4H9SLM95OCLfFizz",
  castinginarmor: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.z4A8vHSK2pb8YA9X",
  cantrips: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.jZD5mCTnMPJ9jW67",
  rituals: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.FjWqT5iyJ89kohdA",
  castingtime: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.zRVW8Tvyk6BECjZD",
  bonusactioncasting: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.RP1WL9FXI3aknlxZ",
  reactioncasting: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.t62lCfinwU9H7Lji",
  longercastingtimes: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.gOAIRFCyPUx42axn",
  spellrange: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.RBYPyE5z5hAZSbH6",
  components: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.xeHthAF9lxfn2tII",
  verbal: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.6UXTNWMCQ0nSlwwx",
  spellduration: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.9mp0SRsptjvJcq1e",
  instantaneous: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.kdlgZOpRMB6bGCod",
  concentrating: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.ow58p27ctAnr4VPH",
  spelltargets: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.G80AIQr04sxdVpw4",
  areaofeffect: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.wvtCeGHgnUmh0cuj",
  pointoforigin: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.8HxbRceQQUAhyWRt",
  spellsavingthrows: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.8DajfNll90eeKcmB",
  spellattackrolls: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.qAFzmGZKhVvAEUF3",
  combiningmagicaleffects: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.TMIN963hG773yZzO",
  schoolsofmagic: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.TeF6CKMDRpYpsLd4",
  detectingtraps: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.DZ7AhdQ94xggG4bj",
  disablingtraps: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.DZ7AhdQ94xggG4bj",
  curingmadness: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.6Icem7G3CICdNOkM",
  damagethreshold: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.9LJZhqvCburpags3",
  poisontypes: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.I6OMMWUaYCWR9xip",
  contactpoison: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.kXnCEqqGUWRZeZDj",
  ingestedpoison: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.Y0vsJYSWeQcFpJ27",
  inhaledpoison: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.KUyN4eK1xTBzXsjP",
  injurypoison: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.LUL48OUq6SJeMGc7",
  attunement: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.UQ65OwIyGK65eiOK",
  wearingitems: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.iPB8mGKuQx3X0Z2J",
  wieldingitems: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.iPB8mGKuQx3X0Z2J",
  multipleitemsofthesamekind: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.rLJdvz4Mde8GkEYQ",
  paireditems: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.rd9pCH8yFraSGN34",
  commandword: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.HiXixxLYesv6Ff3t",
  consumables: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.UEPAcZFzQ5x196zE",
  itemspells: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.DABoaeeF6w31UCsj",
  charges: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.NLRXcgrpRCfsA5mO",
  spellscroll: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.gi8IKhtOlBVhMJrN",
  creaturetags: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.9jV1fFF163dr68vd",
  telepathy: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.geTidcFIYWuUvD2L",
  legendaryactions: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.C1awOyZh78pq1xmY",
  lairactions: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.07PtjpMxiRIhkBEp",
  regionaleffects: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.uj8W27NKFyzygPUd",
  disease: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.oNQWvyRZkTOJ8PBq"
};

/* -------------------------------------------- */

/**
 * Replacement reference base item IDs for the legacy rules.
 * @type {Record<string, Record<string, string>>}
 */
const IDS = {
  ammoIds: {
    arrow: "Compendium.dnd5e.items.Item.3c7JXOzsv55gqJS5",
    blowgunNeedle: "Compendium.dnd5e.items.Item.gBQ8xqTA5f8wP5iu",
    crossbowBolt: "Compendium.dnd5e.items.Item.SItCnYBqhzqBoaWG",
    slingBullet: "Compendium.dnd5e.items.Item.z9SbsMIBZzuhZOqT"
  },
  armorIds: {
    breastplate: "Compendium.dnd5e.items.Item.SK2HATQ4abKUlV8i",
    chainmail: "Compendium.dnd5e.items.Item.rLMflzmxpe8JGTOA",
    chainshirt: "Compendium.dnd5e.items.Item.p2zChy24ZJdVqMSH",
    halfplate: "Compendium.dnd5e.items.Item.vsgmACFYINloIdPm",
    hide: "Compendium.dnd5e.items.Item.n1V07puo0RQxPGuF",
    leather: "Compendium.dnd5e.items.Item.WwdpHLXGX5r8uZu5",
    padded: "Compendium.dnd5e.items.Item.GtKV1b5uqFQqpEni",
    plate: "Compendium.dnd5e.items.Item.OjkIqlW2UpgFcjZa",
    ringmail: "Compendium.dnd5e.items.Item.nsXZejlmgalj4he9",
    scalemail: "Compendium.dnd5e.items.Item.XmnlF5fgIO3tg6TG",
    splint: "Compendium.dnd5e.items.Item.cKpJmsJmU8YaiuqG",
    studded: "Compendium.dnd5e.items.Item.TIV3B1vbrVHIhQAm"
  },
  focusTypes: {
    arcane: {
      crystal: "Compendium.dnd5e.items.Item.uXOT4fYbgPY8DGdd",
      orb: "Compendium.dnd5e.items.Item.tH5Rn0JVRG1zdmPa",
      rod: "Compendium.dnd5e.items.Item.OojyyGfh91iViuMF",
      staff: "Compendium.dnd5e.items.Item.BeKIrNIvNHRPQ4t5",
      wand: "Compendium.dnd5e.items.Item.KA2P6I48iOWlnboO"
    },
    druidic: {
      mistletoe: "Compendium.dnd5e.items.Item.xDK9GQd2iqOGH8Sd",
      totem: "Compendium.dnd5e.items.Item.PGL6aaM0wE5h0VN5",
      woodenstaff: "Compendium.dnd5e.items.Item.FF1ktpb2YSiyv896",
      yewwand: "Compendium.dnd5e.items.Item.t5yP0d7YaKwuKKiH"
    },
    holy: {
      amulet: "Compendium.dnd5e.items.Item.paqlMjggWkBIAeCe",
      emblem: "Compendium.dnd5e.items.Item.laVqttkGMW4B9654",
      reliquary: "Compendium.dnd5e.items.Item.gP1URGq3kVIIFHJ7"
    }
  },
  shieldIds: {
    shield: "Compendium.dnd5e.items.Item.sSs3hSzkKBMNBgTs"
  },
  spellScrollIds: {
    0: "Compendium.dnd5e.items.Item.rQ6sO7HDWzqMhSI3",
    1: "Compendium.dnd5e.items.Item.9GSfMg0VOA2b4uFN",
    2: "Compendium.dnd5e.items.Item.XdDp6CKh9qEvPTuS",
    3: "Compendium.dnd5e.items.Item.hqVKZie7x9w3Kqds",
    4: "Compendium.dnd5e.items.Item.DM7hzgL836ZyUFB1",
    5: "Compendium.dnd5e.items.Item.wa1VF8TXHmkrrR35",
    6: "Compendium.dnd5e.items.Item.tI3rWx4bxefNCexS",
    7: "Compendium.dnd5e.items.Item.mtyw4NS1s7j2EJaD",
    8: "Compendium.dnd5e.items.Item.aOrinPg7yuDZEuWr",
    9: "Compendium.dnd5e.items.Item.O4YbkJkLlnsgUszZ"
  },
  tools: {
    alchemist: "Compendium.dnd5e.items.Item.SztwZhbhZeCqyAes",
    bagpipes: "Compendium.dnd5e.items.Item.yxHi57T5mmVt0oDr",
    brewer: "Compendium.dnd5e.items.Item.Y9S75go1hLMXUD48",
    calligrapher: "Compendium.dnd5e.items.Item.jhjo20QoiD5exf09",
    card: "Compendium.dnd5e.items.Item.YwlHI3BVJapz4a3E",
    carpenter: "Compendium.dnd5e.items.Item.8NS6MSOdXtUqD7Ib",
    cartographer: "Compendium.dnd5e.items.Item.fC0lFK8P4RuhpfaU",
    chess: "Compendium.dnd5e.items.Item.23y8FvWKf9YLcnBL",
    cobbler: "Compendium.dnd5e.items.Item.hM84pZnpCqKfi8XH",
    cook: "Compendium.dnd5e.items.Item.Gflnp29aEv5Lc1ZM",
    dice: "Compendium.dnd5e.items.Item.iBuTM09KD9IoM5L8",
    disg: "Compendium.dnd5e.items.Item.IBhDAr7WkhWPYLVn",
    drum: "Compendium.dnd5e.items.Item.69Dpr25pf4BjkHKb",
    dulcimer: "Compendium.dnd5e.items.Item.NtdDkjmpdIMiX7I2",
    flute: "Compendium.dnd5e.items.Item.eJOrPcAz9EcquyRQ",
    forg: "Compendium.dnd5e.items.Item.cG3m4YlHfbQlLEOx",
    glassblower: "Compendium.dnd5e.items.Item.rTbVrNcwApnuTz5E",
    herb: "Compendium.dnd5e.items.Item.i89okN7GFTWHsvPy",
    horn: "Compendium.dnd5e.items.Item.aa9KuBy4dst7WIW9",
    jeweler: "Compendium.dnd5e.items.Item.YfBwELTgPFHmQdHh",
    leatherworker: "Compendium.dnd5e.items.Item.PUMfwyVUbtyxgYbD",
    lute: "Compendium.dnd5e.items.Item.qBydtUUIkv520DT7",
    lyre: "Compendium.dnd5e.items.Item.EwG1EtmbgR3bM68U",
    mason: "Compendium.dnd5e.items.Item.skUih6tBvcBbORzA",
    navg: "Compendium.dnd5e.items.Item.YHCmjsiXxZ9UdUhU",
    painter: "Compendium.dnd5e.items.Item.ccm5xlWhx74d6lsK",
    panflute: "Compendium.dnd5e.items.Item.G5m5gYIx9VAUWC3J",
    pois: "Compendium.dnd5e.items.Item.il2GNi8C0DvGLL9P",
    potter: "Compendium.dnd5e.items.Item.hJS8yEVkqgJjwfWa",
    shawm: "Compendium.dnd5e.items.Item.G3cqbejJpfB91VhP",
    smith: "Compendium.dnd5e.items.Item.KndVe2insuctjIaj",
    thief: "Compendium.dnd5e.items.Item.woWZ1sO5IUVGzo58",
    tinker: "Compendium.dnd5e.items.Item.0d08g1i5WXnNrCNA",
    viol: "Compendium.dnd5e.items.Item.baoe3U5BfMMMxhCU",
    weaver: "Compendium.dnd5e.items.Item.ap9prThUB2y9lDyj",
    woodcarver: "Compendium.dnd5e.items.Item.xKErqkLo4ASYr5EP"
  },
  weaponIds: {
    battleaxe: "Compendium.dnd5e.items.Item.I0WocDSuNpGJayPb",
    blowgun: "Compendium.dnd5e.items.Item.wNWK6yJMHG9ANqQV",
    club: "Compendium.dnd5e.items.Item.nfIRTECQIG81CvM4",
    dagger: "Compendium.dnd5e.items.Item.0E565kQUBmndJ1a2",
    dart: "Compendium.dnd5e.items.Item.3rCO8MTIdPGSW6IJ",
    flail: "Compendium.dnd5e.items.Item.UrH3sMdnUDckIHJ6",
    glaive: "Compendium.dnd5e.items.Item.rOG1OM2ihgPjOvFW",
    greataxe: "Compendium.dnd5e.items.Item.1Lxk6kmoRhG8qQ0u",
    greatclub: "Compendium.dnd5e.items.Item.QRCsxkCwWNwswL9o",
    greatsword: "Compendium.dnd5e.items.Item.xMkP8BmFzElcsMaR",
    halberd: "Compendium.dnd5e.items.Item.DMejWAc8r8YvDPP1",
    handaxe: "Compendium.dnd5e.items.Item.eO7Fbv5WBk5zvGOc",
    handcrossbow: "Compendium.dnd5e.items.Item.qaSro7kFhxD6INbZ",
    heavycrossbow: "Compendium.dnd5e.items.Item.RmP0mYRn2J7K26rX",
    javelin: "Compendium.dnd5e.items.Item.DWLMnODrnHn8IbAG",
    lance: "Compendium.dnd5e.items.Item.RnuxdHUAIgxccVwj",
    lightcrossbow: "Compendium.dnd5e.items.Item.ddWvQRLmnnIS0eLF",
    lighthammer: "Compendium.dnd5e.items.Item.XVK6TOL4sGItssAE",
    longbow: "Compendium.dnd5e.items.Item.3cymOVja8jXbzrdT",
    longsword: "Compendium.dnd5e.items.Item.10ZP2Bu3vnCuYMIB",
    mace: "Compendium.dnd5e.items.Item.Ajyq6nGwF7FtLhDQ",
    maul: "Compendium.dnd5e.items.Item.DizirD7eqjh8n95A",
    morningstar: "Compendium.dnd5e.items.Item.dX8AxCh9o0A9CkT3",
    net: "Compendium.dnd5e.items.Item.aEiM49V8vWpWw7rU",
    pike: "Compendium.dnd5e.items.Item.tC0kcqZT9HHAO0PD",
    quarterstaff: "Compendium.dnd5e.items.Item.g2dWN7PQiMRYWzyk",
    rapier: "Compendium.dnd5e.items.Item.Tobce1hexTnDk4sV",
    scimitar: "Compendium.dnd5e.items.Item.fbC0Mg1a73wdFbqO",
    shortsword: "Compendium.dnd5e.items.Item.osLzOwQdPtrK3rQH",
    sickle: "Compendium.dnd5e.items.Item.i4NeNZ30ycwPDHMx",
    spear: "Compendium.dnd5e.items.Item.OG4nBBydvmfWYXIk",
    shortbow: "Compendium.dnd5e.items.Item.GJv6WkD7D2J6rP6M",
    sling: "Compendium.dnd5e.items.Item.3gynWO9sN4OLGMWD",
    trident: "Compendium.dnd5e.items.Item.F65ANO66ckP8FDMa",
    warpick: "Compendium.dnd5e.items.Item.2YdfjN1PIIrSHZii",
    warhammer: "Compendium.dnd5e.items.Item.F0Df164Xv1gWcYt0",
    whip: "Compendium.dnd5e.items.Item.QKTyxoO0YDnAsbYe"
  }
};

/* -------------------------------------------- */

/**
 * Spell lists that will be registered when using the legacy rules.
 * @type {string[]}
 */
const SPELL_LISTS = Object.freeze([
  "Compendium.dnd5e.rules.JournalEntry.QvPDSUsAiEn3hD8s.JournalEntryPage.ziBzRlrpBm1KVV0j",
  "Compendium.dnd5e.rules.JournalEntry.QvPDSUsAiEn3hD8s.JournalEntryPage.cuG9d7J9fQH9InYT",
  "Compendium.dnd5e.rules.JournalEntry.QvPDSUsAiEn3hD8s.JournalEntryPage.MWiN7ILEO0Vd3zAZ",
  "Compendium.dnd5e.rules.JournalEntry.QvPDSUsAiEn3hD8s.JournalEntryPage.FhucONA0yRZQjMmb",
  "Compendium.dnd5e.rules.JournalEntry.QvPDSUsAiEn3hD8s.JournalEntryPage.sANq9JMycfSq3A5d",
  "Compendium.dnd5e.rules.JournalEntry.QvPDSUsAiEn3hD8s.JournalEntryPage.PVgly1xB2S2I8GLQ",
  "Compendium.dnd5e.rules.JournalEntry.QvPDSUsAiEn3hD8s.JournalEntryPage.mx4TsSbBIAaAkhQ7",
  "Compendium.dnd5e.rules.JournalEntry.QvPDSUsAiEn3hD8s.JournalEntryPage.k7Rs5EyXeA0SFTXD"
]);

const { StringField: StringField$q } = foundry.data.fields;

/**
 * Register all of the system's keybindings.
 */
function registerSystemKeybindings() {
  game.keybindings.register("dnd5e", "skipDialogNormal", {
    name: "KEYBINDINGS.DND5E.SkipDialogNormal",
    editable: [{ key: "ShiftLeft" }, { key: "ShiftRight" }]
  });

  game.keybindings.register("dnd5e", "skipDialogAdvantage", {
    name: "KEYBINDINGS.DND5E.SkipDialogAdvantage",
    editable: [{ key: "AltLeft" }, { key: "AltRight" }]
  });

  game.keybindings.register("dnd5e", "skipDialogDisadvantage", {
    name: "KEYBINDINGS.DND5E.SkipDialogDisadvantage",
    editable: [{ key: "ControlLeft" }, { key: "ControlRight" }, { key: "OsLeft" }, { key: "OsRight" }]
  });

  game.keybindings.register("dnd5e", "dragCopy", {
    name: "KEYBINDINGS.DND5E.DragCopy",
    editable: [{ key: "ControlLeft" }, { key: "ControlRight" }, { key: "AltLeft" }, { key: "AltRight" }]
  });

  game.keybindings.register("dnd5e", "dragMove", {
    name: "KEYBINDINGS.DND5E.DragMove",
    editable: [{ key: "ShiftLeft" }, { key: "ShiftRight" }, { key: "OsLeft" }, { key: "OsRight" }]
  });
}

/* -------------------------------------------- */

/**
 * 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: ""
  });

  // Polymorph Settings
  game.settings.register("dnd5e", "transformationSettings", {
    scope: "client",
    config: false,
    type: TransformationSetting
  });

  // Rules version
  game.settings.register("dnd5e", "rulesVersion", {
    name: "SETTINGS.DND5E.RULESVERSION.Name",
    hint: "SETTINGS.DND5E.RULESVERSION.Hint",
    scope: "world",
    config: true,
    default: "modern",
    type: String,
    choices: {
      modern: "SETTINGS.DND5E.RULESVERSION.Modern",
      legacy: "SETTINGS.DND5E.RULESVERSION.Legacy"
    },
    requiresReload: true
  });

  // Movement automation
  game.settings.register("dnd5e", "movementAutomation", {
    name: "SETTINGS.DND5E.AUTOMATION.Movement.Name",
    hint: "SETTINGS.DND5E.AUTOMATION.Movement.Hint",
    scope: "world",
    config: true,
    default: "full",
    type: String,
    choices: {
      full: "SETTINGS.DND5E.AUTOMATION.Movement.Full",
      noBlocking: "SETTINGS.DND5E.AUTOMATION.Movement.NoBlocking",
      none: "SETTINGS.DND5E.AUTOMATION.Movement.None"
    }
  });

  // Allow rotating square templates
  game.settings.register("dnd5e", "gridAlignedSquareTemplates", {
    name: "SETTINGS.5eGridAlignedSquareTemplatesN",
    hint: "SETTINGS.5eGridAlignedSquareTemplatesL",
    scope: "world",
    config: true,
    default: true,
    type: Boolean
  });

  // Loyalty
  game.settings.register("dnd5e", "loyaltyScore", {
    name: "SETTINGS.DND5E.LOYALTY.Name",
    hint: "SETTINGS.DND5E.LOYALTY.Hint",
    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
  });

  // Disable Concentration Tracking
  game.settings.register("dnd5e", "disableConcentration", {
    name: "SETTINGS.5eNoConcentrationN",
    hint: "SETTINGS.5eNoConcentrationL",
    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();
    }
  });

  // Collapse Chat Card Trays
  game.settings.register("dnd5e", "autoCollapseChatTrays", {
    name: "SETTINGS.DND5E.COLLAPSETRAYS.Name",
    hint: "SETTINGS.DND5E.COLLAPSETRAYS.Hint",
    scope: "client",
    config: true,
    default: "older",
    type: String,
    choices: {
      manual: "SETTINGS.DND5E.COLLAPSETRAYS.Manual",
      never: "SETTINGS.DND5E.COLLAPSETRAYS.Never",
      older: "SETTINGS.DND5E.COLLAPSETRAYS.Older",
      always: "SETTINGS.DND5E.COLLAPSETRAYS.Always"
    }
  });

  // Allow Rests from Sheet
  game.settings.register("dnd5e", "allowRests", {
    name: "SETTINGS.DND5E.PERMISSIONS.AllowRests.Name",
    hint: "SETTINGS.DND5E.PERMISSIONS.AllowRests.Hint",
    scope: "world",
    config: true,
    default: true,
    type: Boolean
  });

  // Allow Polymorphing
  game.settings.register("dnd5e", "allowPolymorphing", {
    name: "SETTINGS.DND5E.PERMISSIONS.AllowTransformation.Name",
    hint: "SETTINGS.DND5E.PERMISSIONS.AllowTransformation.Hint",
    scope: "world",
    config: true,
    default: false,
    type: Boolean
  });

  // Allow Summoning
  game.settings.register("dnd5e", "allowSummoning", {
    name: "SETTINGS.DND5E.PERMISSIONS.AllowSummoning.Name",
    hint: "SETTINGS.DND5E.PERMISSIONS.AllowSummoning.Hint",
    scope: "world",
    config: true,
    default: false,
    type: Boolean
  });

  // Metric Length Weights
  game.settings.register("dnd5e", "metricLengthUnits", {
    name: "SETTINGS.DND5E.METRIC.LengthUnits.Name",
    hint: "SETTINGS.DND5E.METRIC.LengthUnits.Hint",
    scope: "world",
    config: true,
    type: Boolean,
    default: false
  });

  // Metric Volume Weights
  game.settings.register("dnd5e", "metricVolumeUnits", {
    name: "SETTINGS.DND5E.METRIC.VolumeUnits.Name",
    hint: "SETTINGS.DND5E.METRIC.VolumeUnits.Hint",
    scope: "world",
    config: true,
    type: Boolean,
    default: false
  });

  // Metric Unit Weights
  game.settings.register("dnd5e", "metricWeightUnits", {
    name: "SETTINGS.DND5E.METRIC.WeightUnits.Name",
    hint: "SETTINGS.DND5E.METRIC.WeightUnits.Hint",
    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: ModuleArtSettingsConfig,
    restricted: true
  });

  game.settings.register("dnd5e", "moduleArtConfiguration", {
    name: "Module Art Configuration",
    scope: "world",
    config: false,
    type: Object,
    default: {
      dnd5e: {
        portraits: true,
        tokens: true
      }
    }
  });

  // Compendium Browser source exclusion
  game.settings.registerMenu("dnd5e", "packSourceConfiguration", {
    name: "DND5E.CompendiumBrowser.Sources.Name",
    label: "DND5E.CompendiumBrowser.Sources.Label",
    hint: "DND5E.CompendiumBrowser.Sources.Hint",
    icon: "fas fa-book-open-reader",
    type: CompendiumBrowserSettingsConfig,
    restricted: true
  });

  game.settings.register("dnd5e", "packSourceConfiguration", {
    name: "Pack Source Configuration",
    scope: "world",
    config: false,
    type: Object,
    default: {},
    onChange: () => {
      // Refresh all open Compendium Browser instances when source configuration changes
      foundry.applications.instances.forEach(app => {
        if ( app instanceof CompendiumBrowser ) {
          app.render({ parts: ["results", "filters"], changedTab: true });
        }
      });
    }
  });

  // Bastions
  game.settings.registerMenu("dnd5e", "bastionConfiguration", {
    name: "DND5E.Bastion.Configuration.Name",
    label: "DND5E.Bastion.Configuration.Label",
    hint: "DND5E.Bastion.Configuration.Hint",
    icon: "fas fa-chess-rook",
    type: BastionSettingsConfig,
    restricted: true
  });

  game.settings.register("dnd5e", "bastionConfiguration", {
    name: "Bastion Configuration",
    scope: "world",
    config: false,
    type: BastionSetting,
    default: {
      button: false,
      enabled: false,
      duration: 7
    },
    onChange: () => game.dnd5e.bastion.initializeUI()
  });

  // Calendar Settings
  game.settings.registerMenu("dnd5e", "calendarConfiguration", {
    name: "DND5E.CALENDAR.Configuration.Name",
    label: "DND5E.CALENDAR.Configuration.Label",
    hint: "DND5E.CALENDAR.Configuration.Hint",
    icon: "fas fa-calendar-days",
    type: CalendarSettingsConfig
  });

  game.settings.register("dnd5e", "calendar", {
    name: "DND5E.CALENDAR.FIELDS.calendar.label",
    hint: "DND5E.CALENDAR.FIELDS.calendar.hint",
    scope: "world",
    config: false,
    type: new StringField$q({
      required: true, blank: false, initial: "gregorian", choices: () => Object.fromEntries(
        CONFIG.DND5E.calendar.calendars.map(({ value, label }) => [value, label])
      )
    }),
    requiresReload: true
  });

  game.settings.register("dnd5e", "calendarConfig", {
    name: "Calendar Configuration",
    scope: "world",
    config: false,
    type: CalendarConfigSetting,
    onChange: () => dnd5e.ui.calendar?.onUpdateSettings?.()
  });

  game.settings.register("dnd5e", "calendarPreferences", {
    name: "Calendar Preferences",
    scope: "user",
    config: false,
    type: CalendarPreferencesSetting,
    onChange: () => dnd5e.ui.calendar?.onUpdateSettings?.()
  });

  // Combat Settings
  game.settings.registerMenu("dnd5e", "combatConfiguration", {
    name: "SETTINGS.DND5E.COMBAT.Name",
    label: "SETTINGS.DND5E.COMBAT.Label",
    hint: "SETTINGS.DND5E.COMBAT.Hint",
    icon: "fas fa-explosion",
    type: CombatSettingsConfig,
    restricted: true
  });

  game.settings.register("dnd5e", "autoRecharge", {
    name: "SETTINGS.DND5E.NPCS.AutoRecharge.Name",
    hint: "SETTINGS.DND5E.NPCS.AutoRecharge.Hint",
    scope: "world",
    config: false,
    default: "no",
    type: String,
    choices: {
      no: "SETTINGS.DND5E.NPCS.AutoRecharge.No",
      silent: "SETTINGS.DND5E.NPCS.AutoRecharge.Silent",
      yes: "SETTINGS.DND5E.NPCS.AutoRecharge.Yes"
    }
  });

  game.settings.register("dnd5e", "autoRollNPCHP", {
    name: "SETTINGS.DND5E.NPCS.AutoRollNPCHP.Name",
    hint: "SETTINGS.DND5E.NPCS.AutoRollNPCHP.Hint",
    scope: "world",
    config: false,
    default: "no",
    type: String,
    choices: {
      no: "SETTINGS.DND5E.NPCS.AutoRollNPCHP.No",
      silent: "SETTINGS.DND5E.NPCS.AutoRollNPCHP.Silent",
      yes: "SETTINGS.DND5E.NPCS.AutoRollNPCHP.Yes"
    }
  });

  game.settings.register("dnd5e", "criticalDamageModifiers", {
    name: "SETTINGS.DND5E.CRITICAL.MultiplyModifiers.Name",
    hint: "SETTINGS.DND5E.CRITICAL.MultiplyModifiers.Hint",
    scope: "world",
    config: false,
    type: Boolean,
    default: false
  });

  game.settings.register("dnd5e", "criticalDamageMaxDice", {
    name: "SETTINGS.DND5E.CRITICAL.MaxDice.Name",
    hint: "SETTINGS.DND5E.CRITICAL.MaxDice.Hint",
    scope: "world",
    config: false,
    type: Boolean,
    default: false
  });

  game.settings.register("dnd5e", "initiativeDexTiebreaker", {
    name: "SETTINGS.DND5E.COMBAT.DexTiebreaker.Name",
    hint: "SETTINGS.DND5E.COMBAT.DexTiebreaker.Hint",
    scope: "world",
    config: false,
    default: false,
    type: Boolean
  });

  game.settings.register("dnd5e", "initiativeScore", {
    name: "SETTINGS.DND5E.COMBAT.InitiativeScore.Name",
    hint: "SETTINGS.DND5E.COMBAT.InitiativeScore.Hint",
    scope: "world",
    config: false,
    default: "none",
    type: String,
    choices: {
      none: "SETTINGS.DND5E.COMBAT.InitiativeScore.None",
      npcs: "SETTINGS.DND5E.COMBAT.InitiativeScore.NPCs",
      all: "SETTINGS.DND5E.COMBAT.InitiativeScore.All"
    }
  });

  // Variant Rules
  game.settings.registerMenu("dnd5e", "variantRulesConfiguration", {
    name: "SETTINGS.DND5E.VARIANT.Name",
    label: "SETTINGS.DND5E.VARIANT.Label",
    hint: "SETTINGS.DND5E.VARIANT.Hint",
    icon: "fas fa-list-check",
    type: VariantRulesSettingsConfig,
    restricted: true
  });

  game.settings.register("dnd5e", "allowFeats", {
    name: "SETTINGS.DND5E.VARIANT.AllowFeats.Name",
    hint: "SETTINGS.DND5E.VARIANT.AllowFeats.Hint",
    scope: "world",
    config: false,
    default: true,
    type: Boolean
  });

  game.settings.register("dnd5e", "currencyWeight", {
    name: "SETTINGS.DND5E.VARIANT.CurrencyWeight.Name",
    hint: "SETTINGS.DND5E.VARIANT.CurrencyWeight.Hint",
    scope: "world",
    config: false,
    default: true,
    type: Boolean
  });

  game.settings.register("dnd5e", "encumbrance", {
    name: "SETTINGS.DND5E.VARIANT.Encumbrance.Name",
    hint: "SETTINGS.DND5E.VARIANT.Encumbrance.Hint",
    scope: "world",
    config: false,
    default: "none",
    type: String,
    choices: {
      none: "SETTINGS.DND5E.VARIANT.Encumbrance.None",
      normal: "SETTINGS.DND5E.VARIANT.Encumbrance.Normal",
      variant: "SETTINGS.DND5E.VARIANT.Encumbrance.Variant"
    }
  });

  game.settings.register("dnd5e", "honorScore", {
    name: "SETTINGS.DND5E.VARIANT.HonorScore.Name",
    hint: "SETTINGS.DND5E.VARIANT.HonorScore.Hint",
    scope: "world",
    config: false,
    default: false,
    type: Boolean,
    requiresReload: true
  });

  game.settings.register("dnd5e", "levelingMode", {
    name: "SETTINGS.DND5E.VARIANT.LevelingMode.Name",
    hint: "SETTINGS.DND5E.VARIANT.LevelingMode.Hint",
    scope: "world",
    config: false,
    default: "xpBoons",
    type: String,
    choices: {
      noxp: "SETTINGS.DND5E.VARIANT.LevelingMode.NoXP",
      xp: "SETTINGS.DND5E.VARIANT.LevelingMode.XP",
      xpBoons: "SETTINGS.DND5E.VARIANT.LevelingMode.XPBoons"
    }
  });

  game.settings.register("dnd5e", "proficiencyModifier", {
    name: "SETTINGS.DND5E.VARIANT.ProficiencyModifier.Name",
    hint: "SETTINGS.DND5E.VARIANT.ProficiencyModifier.Hint",
    scope: "world",
    config: false,
    default: "bonus",
    type: String,
    choices: {
      bonus: "SETTINGS.DND5E.VARIANT.ProficiencyModifier.Bonus",
      dice: "SETTINGS.DND5E.VARIANT.ProficiencyModifier.Dice"
    }
  });

  game.settings.register("dnd5e", "restVariant", {
    name: "SETTINGS.DND5E.VARIANT.Rest.Name",
    hint: "SETTINGS.DND5E.VARIANT.Rest.Hint",
    scope: "world",
    config: false,
    default: "normal",
    type: String,
    choices: {
      normal: "SETTINGS.DND5E.VARIANT.Rest.Normal",
      gritty: "SETTINGS.DND5E.VARIANT.Rest.Gritty",
      epic: "SETTINGS.DND5E.VARIANT.Rest.Epic"
    }
  });

  game.settings.register("dnd5e", "sanityScore", {
    name: "SETTINGS.DND5E.VARIANT.SanityScore.Name",
    hint: "SETTINGS.DND5E.VARIANT.SanityScore.Hint",
    scope: "world",
    config: false,
    default: false,
    type: Boolean,
    requiresReload: true
  });

  // Visibility Settings
  game.settings.registerMenu("dnd5e", "visibilityConfiguration", {
    name: "SETTINGS.DND5E.VISIBILITY.Name",
    label: "SETTINGS.DND5E.VISIBILITY.Label",
    hint: "SETTINGS.DND5E.VISIBILITY.Hint",
    icon: "fas fa-eye",
    type: VisibilitySettingsConfig,
    restricted: true
  });

  game.settings.register("dnd5e", "attackRollVisibility", {
    name: "SETTINGS.DND5E.VISIBILITY.Attack.Name",
    hint: "SETTINGS.DND5E.VISIBILITY.Attack.Hint",
    scope: "world",
    config: false,
    default: "none",
    type: String,
    choices: {
      all: "SETTINGS.DND5E.VISIBILITY.Attack.All",
      hideAC: "SETTINGS.DND5E.VISIBILITY.Attack.HideAC",
      none: "SETTINGS.DND5E.VISIBILITY.Attack.None"
    }
  });

  game.settings.register("dnd5e", "bloodied", {
    name: "SETTINGS.DND5E.BLOODIED.Name",
    hint: "SETTINGS.DND5E.BLOODIED.Hint",
    scope: "world",
    config: false,
    default: "player",
    type: String,
    choices: {
      all: "SETTINGS.DND5E.BLOODIED.All",
      player: "SETTINGS.DND5E.BLOODIED.Player",
      none: "SETTINGS.DND5E.BLOODIED.None"
    }
  });

  game.settings.register("dnd5e", "challengeVisibility", {
    name: "SETTINGS.DND5E.VISIBILITY.Challenge.Name",
    hint: "SETTINGS.DND5E.VISIBILITY.Challenge.Hint",
    scope: "world",
    config: false,
    default: "player",
    type: String,
    choices: {
      all: "SETTINGS.DND5E.VISIBILITY.Challenge.All",
      player: "SETTINGS.DND5E.VISIBILITY.Challenge.Player",
      none: "SETTINGS.DND5E.VISIBILITY.Challenge.None"
    }
  });

  game.settings.register("dnd5e", "concealItemDescriptions", {
    name: "SETTINGS.DND5E.VISIBILITY.ItemDescriptions.Name",
    hint: "SETTINGS.DND5E.VISIBILITY.ItemDescriptions.Hint",
    scope: "world",
    config: false,
    default: false,
    type: Boolean
  });

  // Primary Group
  game.settings.register("dnd5e", "primaryParty", {
    name: "Primary Party",
    scope: "world",
    config: false,
    default: null,
    type: PrimaryPartySetting,
    onChange: s => ui.actors.render()
  });

  // Control hints
  game.settings.register("dnd5e", "controlHints", {
    name: "DND5E.Controls.Name",
    hint: "DND5E.Controls.Hint",
    scope: "client",
    config: true,
    type: Boolean,
    default: true
  });

  // NPC sheet default skills
  game.settings.register("dnd5e", "defaultSkills", {
    name: "SETTINGS.DND5E.DEFAULTSKILLS.Name",
    hint: "SETTINGS.DND5E.DEFAULTSKILLS.Hint",
    type: new foundry.data.fields.SetField(
      new foundry.data.fields.StringField({
        choices: () => CONFIG.DND5E.skills
      })
    ),
    default: [],
    config: true
  });
}

/* -------------------------------------------- */

/**
 * Register additional settings after modules have had a chance to initialize to give them a chance to modify choices.
 */
function registerDeferredSettings() {
  game.settings.register("dnd5e", "theme", {
    name: "SETTINGS.DND5E.THEME.Name",
    hint: "SETTINGS.DND5E.THEME.Hint",
    scope: "client",
    config: false,
    default: "",
    type: String,
    choices: {
      "": "SHEETS.DND5E.THEME.Automatic",
      ...CONFIG.DND5E.themes
    },
    onChange: s => setTheme(document.body, s)
  });

  matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
    setTheme(document.body, game.settings.get("dnd5e", "theme"));
  });
  matchMedia("(prefers-contrast: more)").addEventListener("change", () => {
    setTheme(document.body, game.settings.get("dnd5e", "theme"));
  });

  // Hook into core color scheme setting.
  const setting = game.settings.get("core", "uiConfig");
  const settingConfig = game.settings.settings.get("core.uiConfig");
  const { onChange } = settingConfig ?? {};
  if ( onChange ) settingConfig.onChange = (s, ...args) => {
    onChange(s, ...args);
    setTheme(document.body, s.colorScheme);
  };
  setTheme(document.body, setting.colorScheme);
}

/* -------------------------------------------- */

/**
 * Update configuration data when legacy rules are set.
 */
function applyLegacyRules() {
  const DND5E = CONFIG.DND5E;

  // Set half-casters to round down.
  DND5E.spellcasting.spell.progression.half.roundUp = false;

  // Adjust Wild Shape and Polymorph presets.
  for ( const preset of ["polymorph", "wildshape"] ) {
    DND5E.transformation.presets[preset].settings.keep.delete("hp");
    DND5E.transformation.presets[preset].settings.keep.delete("languages");
    DND5E.transformation.presets[preset].settings.keep.delete("type");
    delete DND5E.transformation.presets[preset].settings.tempFormula;
  }

  // Adjust language categories.
  delete DND5E.languages.standard.children.sign;
  DND5E.languages.exotic.children.draconic = DND5E.languages.standard.children.draconic;
  delete DND5E.languages.standard.children.draconic;
  DND5E.languages.cant = DND5E.languages.exotic.children.cant;
  delete DND5E.languages.exotic.children.cant;
  DND5E.languages.druidic = DND5E.languages.exotic.children.druidic;
  delete DND5E.languages.exotic.children.druidic;

  // Stunned stops movement in legacy & surprised doesn't provide initiative disadvantage.
  DND5E.conditionEffects.noMovement.add("stunned");
  DND5E.conditionEffects.initiativeAdvantage.delete("invisible");
  DND5E.conditionEffects.initiativeDisadvantage.delete("incapacitated");
  DND5E.conditionEffects.initiativeDisadvantage.delete("surprised");

  // Incapacitated creatures within 2 size categories still cannot be moved through in legacy
  delete DND5E.conditionTypes.incapacitated.neverBlockMovement;

  // Adjust references.
  Object.assign(DND5E.rules, RULES);
  for ( const [cat, value] of Object.entries(REFERENCES) ) {
    Object.entries(value).forEach(([k, v]) => DND5E[cat][k].reference = v);
  }

  // Adjust base item IDs.
  for ( const [cat, value] of Object.entries(IDS) ) {
    if ( cat === "focusTypes" ) Object.entries(value).forEach(([k, v]) => DND5E[cat][k].itemIds = v);
    else if ( cat === "tools" ) Object.entries(value).forEach(([k, v]) => DND5E[cat][k].id = v);
    else DND5E[cat] = value;
  }

  // Swap spell lists.
  DND5E.SPELL_LISTS = SPELL_LISTS;
}

/* -------------------------------------------- */

/**
 * Set the theme on an element, removing the previous theme class in the process.
 * @param {HTMLElement} element     Body or sheet element on which to set the theme data.
 * @param {string} [theme=""]       Theme key to set.
 * @param {Set<string>} [flags=[]]  Additional theming flags to set.
 */
function setTheme(element, theme="", flags=new Set()) {
  if ( foundry.utils.getType(theme) === "Object" ) theme = theme.applications;
  element.className = element.className.replace(/\bdnd5e-(theme|flag)-[\w-]+\b/g, "");

  // Primary Theme
  if ( !theme && (element === document.body) ) {
    if ( matchMedia("(prefers-color-scheme: dark)").matches ) theme = "dark";
    if ( matchMedia("(prefers-color-scheme: light)").matches ) theme = "light";
  }
  if ( theme ) {
    element.classList.add(`dnd5e-theme-${theme.slugify()}`);
    element.dataset.theme = theme;
  }
  else delete element.dataset.theme;

  // Additional Flags
  if ( (element === document.body) && matchMedia("(prefers-contrast: more)").matches ) flags.add("high-contrast");
  for ( const flag of flags ) element.classList.add(`dnd5e-flag-${flag.slugify()}`);
  element.dataset.themeFlags = Array.from(flags).join(" ");
}

var _module$v = /*#__PURE__*/Object.freeze({
  __proto__: null,
  ActivityChoiceDialog: ActivityChoiceDialog,
  ActivitySheet: ActivitySheet,
  ActivityUsageDialog: ActivityUsageDialog,
  AttackSheet: AttackSheet,
  CastSheet: CastSheet,
  CheckSheet: CheckSheet,
  DamageSheet: DamageSheet,
  EnchantSheet: EnchantSheet,
  EnchantUsageDialog: EnchantUsageDialog,
  ForwardSheet: ForwardSheet,
  HealSheet: HealSheet,
  OrderUsageDialog: OrderUsageDialog,
  SaveSheet: SaveSheet,
  SummonSheet: SummonSheet,
  SummonUsageDialog: SummonUsageDialog,
  TransformSheet: TransformSheet,
  TransformUsageDialog: TransformUsageDialog,
  UtilitySheet: UtilitySheet
});

/**
 * @import { DropEffectValue } from "./_types.mjs";
 */

/**
 * Extension of core's DragDrop class to provide additional information used by the system. Will replace core's
 * version in the global namespace.
 */
class DragDrop5e extends foundry.applications.ux.DragDrop {

  /**
   * Drop effect used for current drag operation.
   * @type {DropEffectValue|null}
   */
  static dropEffect = null;

  /* -------------------------------------------- */

  /**
   * Stored drag event payload.
   * @type {{ data: any, event: DragEvent }|null}
   */
  static #payload = null;

  /* -------------------------------------------- */

  /** @override */
  async _handleDragStart(event) {
    await this.callback(event, "dragstart");
    if ( event.dataTransfer.items.length ) {
      event.stopPropagation();
      let data = event.dataTransfer.getData("application/json") || event.dataTransfer.getData("text/plain");
      try { data = JSON.parse(data); } catch(err) {}
      DragDrop5e.#payload = data ? { event, data } : null;
    } else {
      DragDrop5e.#payload = null;
    }
  }

  /* -------------------------------------------- */

  /** @override */
  async _handleDragEnd(event) {
    await this.callback(event, "dragend");
    DragDrop5e.dropEffect = null;
    DragDrop5e.#payload = null;
  }

  /* -------------------------------------------- */

  /**
   * Get the data payload for the current drag event.
   * @param {DragEvent} event
   * @returns {any}
   */
  static getPayload(event) {
    if ( !DragDrop5e.#payload?.data ) return null;
    return DragDrop5e.#payload.data;
  }
}

/**
 * @import { DropEffectValue } from "../../_types.mjs"
 */

/**
 * Adds drop behavior functionality to all sheets.
 * @param {typeof Application} Base  The base class being mixed.
 * @returns {typeof DragDropApplication}
 */
function DragDropApplicationMixin(Base) {
  return class DragDropApplication extends Base {
    /** @override */
    _onDragOver(event) {
      const data = DragDrop5e.getPayload(event);
      DragDrop5e.dropEffect = event.dataTransfer.dropEffect = (foundry.utils.getType(data) === "Object")
        ? this._dropBehavior(event, data) : "copy";
    }

    /* -------------------------------------------- */

    /**
     * The behavior for the dropped data. When called during the drop event, ensure this is called before awaiting
     * anything or the drop behavior will be lost.
     * @param {DragEvent} event  The drag event.
     * @param {object} [data]    The drag payload.
     * @returns {DropEffectValue}
     */
    _dropBehavior(event, data) {
      data ??= DragDrop5e.getPayload(event);
      const allowed = this._allowedDropBehaviors(event, data);
      let behavior = DragDrop5e.dropEffect ?? event.dataTransfer?.dropEffect;

      if ( event.type === "dragover" ) {
        if ( areKeysPressed(event, "dragMove") ) behavior = "move";
        else if ( areKeysPressed(event, "dragCopy") ) behavior = "copy";
        else behavior = this._defaultDropBehavior(event, data);
      }

      if ( (behavior !== "none") && !allowed.has(behavior) ) return allowed.first() ?? "none";
      return behavior || "copy";
    }

    /* -------------------------------------------- */

    /**
     * Types of allowed drop behaviors based on the origin & target of a drag event.
     * @param {DragEvent} event  The drag event.
     * @param {object} [data]    The drag payload.
     * @returns {Set<DropEffectValue>}
     * @protected
     */
    _allowedDropBehaviors(event, data) {
      return new Set();
    }

    /* -------------------------------------------- */

    /**
     * Determine the default drop behavior for the provided operation.
     * @param {DragEvent} event  The drag event.
     * @param {object} [data]    The drag payload.
     * @returns {DropEffectValue}
     * @protected
     */
    _defaultDropBehavior(event, data) {
      return "copy";
    }
  };
}

/**
 * Adds functionality to a custom HTML element for caching its stylesheet and adopting it into its Shadow DOM, rather
 * than having each stylesheet duplicated per element.
 * @param {typeof HTMLElement} Base  The base class being mixed.
 * @returns {typeof AdoptedStyleSheetElement}
 */
function AdoptedStyleSheetMixin(Base) {
  return class AdoptedStyleSheetElement extends Base {
    /**
     * A map of cached stylesheets per Document root.
     * @type {WeakMap<WeakKey<Document>, CSSStyleSheet>}
     * @protected
     */
    static _stylesheets = new WeakMap();

    /**
     * The CSS content for this element.
     * @type {string}
     */
    static CSS = "";

    /* -------------------------------------------- */

    /** @inheritDoc */
    adoptedCallback() {
      super.adoptedCallback?.();
      const sheet = this._getStyleSheet();
      if ( sheet ) this._adoptStyleSheet(this._getStyleSheet());
    }

    /* -------------------------------------------- */

    /**
     * Retrieves the cached stylesheet, or generates a new one.
     * @returns {CSSStyleSheet}
     * @protected
     */
    _getStyleSheet() {
      let sheet = this.constructor._stylesheets.get(this.ownerDocument);
      if ( !sheet && this.ownerDocument.defaultView ) {
        sheet = new this.ownerDocument.defaultView.CSSStyleSheet();
        sheet.replaceSync(this.constructor.CSS);
        this.constructor._stylesheets.set(this.ownerDocument, sheet);
      }
      return sheet;
    }

    /* -------------------------------------------- */

    /**
     * Adopt the stylesheet into the Shadow DOM.
     * @param {CSSStyleSheet} sheet  The sheet to adopt.
     * @abstract
     */
    _adoptStyleSheet(sheet) {}
  };
}

/**
 * A custom checkbox implementation with more styling options.
 * @mixes AdoptedStyleSheetMixin
 * @extends {AbstractFormInputElement}
 */
class CheckboxElement extends AdoptedStyleSheetMixin(
  foundry.applications.elements.AbstractFormInputElement
) {
  constructor(...args) {
    super(...args);
    this._internals.role = "checkbox";
    this._value = this.getAttribute("value");
    this.#defaultValue = this._value;
    if ( this.constructor.useShadowRoot ) this.#shadowRoot = this.attachShadow({ mode: "closed" });
  }

  /* -------------------------------------------- */

  /** @override */
  static tagName = "dnd5e-checkbox";

  /* -------------------------------------------- */

  /**
   * Should a show root be created for this element?
   */
  static useShadowRoot = true;

  /* -------------------------------------------- */

  /** @override */
  static CSS = `
    :host {
      cursor: var(--cursor-pointer);
      display: inline-block;
      width: var(--checkbox-size, 18px);
      height: var(--checkbox-size, 18px);
      aspect-ratio: 1;
    }

    :host(:disabled) { cursor: var(--cursor-default); }

    :host > div {
      width: 100%;
      height: 100%;
      border-radius: var(--checkbox-border-radius, 3px);
      border: var(--checkbox-border-width, 2px) solid var(--checkbox-border-color, var(--dnd5e-color-gold));
      background: var(--checkbox-empty-color, transparent);
      box-sizing: border-box;
      position: relative;
    }

    :host :is(.checked, .disabled, .indeterminate) {
      display: none;
      height: 100%;
      width: 100%;
      align-items: center;
      justify-content: center;
      position: absolute;
      inset: 0;
    }

    :host([checked]) :is(.checked, .disabled, .indeterminate) {
      background: var(--checkbox-fill-color, var(--dnd5e-color-gold));
    }

    :host([checked]) .checked { display: flex; }
    :host([indeterminate]) .indeterminate { display: flex; }
    :host([indeterminate]) .checked { display: none; }
    :host(:disabled) .disabled { display: flex; }
    :host(:disabled) .checked { display: none; }
    :host(:disabled) .indeterminate { display: none; }
  `;

  /* -------------------------------------------- */

  /**
   * Controller for removing listeners automatically.
   * @type {AbortController}
   */
  _controller;

  /* -------------------------------------------- */

  /**
   * The shadow root that contains the checkbox elements.
   * @type {ShadowRoot}
   */
  #shadowRoot;

  /* -------------------------------------------- */
  /*  Element Properties                          */
  /* -------------------------------------------- */

  /**
   * The default value as originally specified in the HTML that created this object.
   * @type {string}
   */
  get defaultValue() {
    return this.#defaultValue;
  }

  #defaultValue;

  /* -------------------------------------------- */

  /**
   * The indeterminate state of the checkbox.
   * @type {boolean}
   */
  get indeterminate() {
    return this.hasAttribute("indeterminate");
  }

  set indeterminate(indeterminate) {
    this.toggleAttribute("indeterminate", indeterminate);
  }

  /* -------------------------------------------- */

  /**
   * The checked state of the checkbox.
   * @type {boolean}
   */
  get checked() {
    return this.hasAttribute("checked");
  }

  set checked(checked) {
    this.toggleAttribute("checked", checked);
    this._refresh();
  }

  /* -------------------------------------------- */

  /** @override */
  get value() {
    return super.value;
  }

  /**
   * Override AbstractFormInputElement#value setter because we want to emit input/change events when the checked state
   * changes, and not when the value changes.
   * @override
   */
  set value(value) {
    this._setValue(value);
  }

  /** @override */
  _getValue() {
    // Workaround for FormElementExtended only checking the value property and not the checked property.
    if ( typeof this._value === "string" ) return this._value;
    return this.checked;
  }

  /* -------------------------------------------- */
  /*  Element Lifecycle                           */
  /* -------------------------------------------- */

  /** @override */
  connectedCallback() {
    this._adoptStyleSheet(this._getStyleSheet());
    const elements = this._buildElements();
    this.#shadowRoot.replaceChildren(...elements);
    this._refresh();
    this._activateListeners();
    if ( !this.hasAttribute("tabindex") ) this.tabIndex = 0;
  }

  /* -------------------------------------------- */

  /** @override */
  disconnectedCallback() {
    this._controller.abort();
  }

  /* -------------------------------------------- */

  /** @override */
  _adoptStyleSheet(sheet) {
    if ( this.constructor.useShadowRoot ) this.#shadowRoot.adoptedStyleSheets = [sheet];
  }

  /* -------------------------------------------- */

  /** @override */
  _buildElements() {
    const container = document.createElement("div");
    container.innerHTML = `
      <div class="checked">
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"
             style="fill: var(--checkbox-icon-color, #000); width: var(--checkbox-icon-size, 68%);">
          <!--!Font Awesome Free 6.5.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.-->
          <path d="M438.6 105.4c12.5 12.5 12.5 32.8 0 45.3l-256 256c-12.5 12.5-32.8 12.5-45.3 0l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L160 338.7 393.4 105.4c12.5-12.5 32.8-12.5 45.3 0z"/>
        </svg>
      </div>
      <div class="disabled">
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"
             style="fill: var(--checkbox-icon-color, #000); width: var(--checkbox-icon-size, 68%);">
          <!--!Font Awesome Free 6.5.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.-->
          <path d="M144 144v48H304V144c0-44.2-35.8-80-80-80s-80 35.8-80 80zM80 192V144C80 64.5 144.5 0 224 0s144 64.5 144 144v48h16c35.3 0 64 28.7 64 64V448c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V256c0-35.3 28.7-64 64-64H80z"/>
        </svg>
      </div>
      <div class="indeterminate">
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"
             style="fill: var(--checkbox-icon-color, #000); width: var(--checkbox-icon-size, 68%);">
          <!--!Font Awesome Free 6.5.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.-->
          <path d="M432 256c0 17.7-14.3 32-32 32L48 288c-17.7 0-32-14.3-32-32s14.3-32 32-32l352 0c17.7 0 32 14.3 32 32z"/>
        </svg>
      </div>
    `;
    return [container];
  }

  /* -------------------------------------------- */

  /** @override */
  _activateListeners() {
    const { signal } = this._controller = new AbortController();
    this.addEventListener("click", this._onClick.bind(this), { signal });
    this.addEventListener("keydown", event => event.key === " " ? this._onClick(event) : null, { signal });
  }

  /* -------------------------------------------- */

  /** @override */
  _refresh() {
    super._refresh();
    this._internals.ariaChecked = `${this.hasAttribute("checked")}`;
  }

  /* -------------------------------------------- */

  /** @override */
  _onClick(event) {
    event.preventDefault();
    this.checked = !this.checked;
    this.dispatchEvent(new Event("input", { bubbles: true, cancelable: true }));
    this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
  }
}

/**
 * Dialog to select which new advancements should be added to an item.
 */
class AdvancementMigrationDialog extends Dialog5e {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["advancement-migration"],
    actions: {
      complete: AdvancementMigrationDialog.#onComplete
    },
    advancements: [],
    position: {
      width: 500
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Result of the migration dialog.
   * @type {Advancement[]|null}
   */
  result = null;

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle clicking the migrate button.
   * @this {AdvancementMigrationDialog}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static async #onComplete(event, target) {
    const formData = new foundry.applications.ux.FormDataExtended(this.element.querySelector("form"));
    this.result = this.options.advancements.filter(a => formData.object[a.id]);
    this.close();
  }

  /* -------------------------------------------- */
  /*  Factory Methods                             */
  /* -------------------------------------------- */

  /**
   * 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[]>}       Resolves with the advancements that should be added, if any.
   * @throws
   */
  static async createDialog(item, advancements) {
    const advancementContext = advancements.map(a => ({
      id: a.id, icon: a.icon, svg: a.icon?.endsWith(".svg"), title: a.title,
      summary: a.levels.length === 1 ? a.summaryForLevel(a.levels[0]) : ""
    }));
    const { promise, resolve, reject } = Promise.withResolvers();
    const dialog = new this({
      advancements,
      buttons: [
        {
          action: "complete",
          default: true,
          icon: "fa-solid fa-check",
          label: game.i18n.localize("DND5E.ADVANCEMENT.Migration.Action.Confirm")
        }
      ],
      content: await foundry.applications.handlebars.renderTemplate(
        "systems/dnd5e/templates/advancement/advancement-migration-dialog.hbs",
        { item, advancements: advancementContext }
      ),
      rejectClose: false,
      window: {
        title: game.i18n.localize("DND5E.ADVANCEMENT.Migration.Title"),
        subtitle: item.name
      }
    });
    dialog.addEventListener("close", () => dialog.result ? resolve(dialog.result) : reject(null), { once: true });
    dialog.render({ force: true });
    return promise;
  }

}

const { DocumentSheetV2 } = foundry.applications.api;

/**
 * Base document sheet from which all document-based application should be based.
 */
class DocumentSheet5e extends ApplicationV2Mixin(DocumentSheetV2) {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["standard-form"]
  };
}

/**
 * Custom element that handles displaying active effects lists.
 */
class EffectsElement extends (foundry.applications.elements.AdoptableHTMLElement ?? HTMLElement) {
  connectedCallback() {
    if ( this.#app ) return;
    this.#app = foundry.applications.instances.get(this.closest(".application")?.id);

    for ( const control of this.querySelectorAll("[data-action]") ) {
      control.addEventListener("click", event => {
        this._onAction(event.currentTarget, event.currentTarget.dataset.action);
      });
    }

    for ( const source of this.querySelectorAll(".effect-source a") ) {
      source.addEventListener("click", this._onClickEffectSource.bind(this));
    }

    for ( const control of this.querySelectorAll("[data-context-menu]") ) {
      control.addEventListener("click", ContextMenu5e.triggerEvent);
    }

    new ContextMenu5e(this, "[data-effect-id]", [], { onOpen: element => {
      const effect = this.getEffect(element.dataset);
      if ( !effect ) return;
      ui.context.menuItems = this._getContextOptions(effect);
      Hooks.call("dnd5e.getActiveEffectContextOptions", effect, ui.context.menuItems);
    }, jQuery: false });
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The HTML tag named used by this element.
   * @type {string}
   */
  static tagName = "dnd5e-effects";

  /* -------------------------------------------- */

  /**
   * Reference to the application that contains this component.
   * @type {Application}
   */
  #app;

  /**
   * Reference to the application that contains this component.
   * @type {Application}
   * @protected
   */
  get app() { return this.#app; }

  /* -------------------------------------------- */

  /**
   * Document whose effects are represented.
   * @type {Actor5e|Item5e}
   */
  get document() {
    return this.app.document;
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /**
   * 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 for which to prepare sheet data.
   * @param {object} [options={}]
   * @param {Actor5e|Item5e} [options.parent]  Document that owns these active effects.
   * @returns {object}                  Data for rendering.
   */
  static prepareCategories(effects, { parent }={}) {
    // Define effect header categories
    const categories = {
      enchantment: {
        type: "enchantment",
        label: game.i18n.localize("DND5E.ENCHANTMENT.Category.General"),
        effects: [],
        isEnchantment: true
      },
      temporary: {
        type: "temporary",
        label: game.i18n.localize("DND5E.EffectTemporary"),
        effects: []
      },
      enchantmentActive: {
        type: "activeEnchantment",
        label: game.i18n.localize("DND5E.ENCHANTMENT.Category.Active"),
        effects: [],
        isEnchantment: true
      },
      passive: {
        type: "passive",
        label: game.i18n.localize("DND5E.EffectPassive"),
        effects: []
      },
      enchantmentInactive: {
        type: "inactiveEnchantment",
        label: game.i18n.localize("DND5E.ENCHANTMENT.Category.Inactive"),
        effects: [],
        isEnchantment: true
      },
      inactive: {
        type: "inactive",
        label: game.i18n.localize("DND5E.EffectInactive"),
        effects: []
      },
      suppressed: {
        type: "suppressed",
        label: game.i18n.localize("DND5E.EffectUnavailable"),
        effects: [],
        disabled: true,
        info: [game.i18n.localize("DND5E.EffectUnavailableInfo")]
      }
    };

    // Iterate over active effects, classifying them into categories
    for ( const e of effects ) {
      if ( (e.dependentOrigin?.active === false) || ((e.parent.system?.identified === false) && !game.user.isGM) ) {
        continue;
      }
      if ( e.isAppliedEnchantment ) {
        if ( e.disabled ) categories.enchantmentInactive.effects.push(e);
        else categories.enchantmentActive.effects.push(e);
      }
      else if ( e.type === "enchantment" ) categories.enchantment.effects.push(e);
      else 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.enchantment.hidden = !parent?.system.isEnchantment;
    categories.enchantmentActive.hidden = !categories.enchantmentActive.effects.length;
    categories.enchantmentInactive.hidden = !categories.enchantmentInactive.effects.length;
    categories.suppressed.hidden = !categories.suppressed.effects.length;

    for ( const category of Object.values(categories) ) {
      category.localizationPrefix = category.isEnchantment ? "DND5E.ENCHANTMENT.Action." : "DND5E.Effect";
    }

    return categories;
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /**
   * 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
   */
  _getContextOptions(effect) {
    const isConcentrationEffect = (this.document instanceof Actor5e) && this.app._concentration?.effects.has(effect);
    const options = [
      {
        name: "DND5E.ContextMenuActionEdit",
        icon: "<i class='fas fa-edit fa-fw'></i>",
        condition: () => effect.isOwner,
        callback: li => this._onAction(li, "edit")
      },
      {
        name: "DND5E.ContextMenuActionDuplicate",
        icon: "<i class='fas fa-copy fa-fw'></i>",
        condition: () => effect.isOwner,
        callback: li => this._onAction(li, "duplicate")
      },
      {
        name: "DND5E.ContextMenuActionDelete",
        icon: "<i class='fas fa-trash fa-fw'></i>",
        condition: () => effect.isOwner && !isConcentrationEffect,
        callback: li => this._onAction(li, "delete")
      },
      {
        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>",
        group: "state",
        condition: () => effect.isOwner && !isConcentrationEffect,
        callback: li => this._onAction(li, "toggle")
      },
      {
        name: "DND5E.ConcentrationBreak",
        icon: '<dnd5e-icon src="systems/dnd5e/icons/svg/break-concentration.svg"></dnd5e-icon>',
        condition: () => isConcentrationEffect,
        callback: () => this.document.endConcentration(effect),
        group: "state"
      }
    ];

    // Toggle Favorite State
    if ( (this.document instanceof Actor5e) && ("favorites" in this.document.system) ) {
      const uuid = effect.getRelativeUUID(this.document);
      const isFavorited = this.document.system.hasFavorite(uuid);
      options.push({
        name: isFavorited ? "DND5E.FavoriteRemove" : "DND5E.Favorite",
        icon: "<i class='fas fa-bookmark fa-fw'></i>",
        condition: () => effect.isOwner,
        callback: li => this._onAction(li, isFavorited ? "unfavorite" : "favorite"),
        group: "state"
      });
    }

    return options;
  }

  /* -------------------------------------------- */

  /**
   * Handle effects actions.
   * @param {Element} target  Button or context menu entry that triggered this action.
   * @param {string} action   Action being triggered.
   * @returns {Promise}
   * @protected
   */
  async _onAction(target, action) {
    const event = new CustomEvent("effect", {
      bubbles: true,
      cancelable: true,
      detail: action
    });
    if ( target.dispatchEvent(event) === false ) return;

    if ( action === "toggleCondition" ) {
      return this._onToggleCondition(target.closest("[data-condition-id]")?.dataset.conditionId);
    }

    const dataset = target.closest("[data-effect-id]")?.dataset;
    const effect = this.getEffect(dataset);
    if ( (action !== "create") && !effect ) return;

    switch ( action ) {
      case "create":
        return this._onCreate(target);
      case "delete":
        await effect.deleteDialog({}, { render: false });
        return this.#app.render();
      case "duplicate":
        return effect.clone({name: game.i18n.format("DOCUMENT.CopyOf", {name: effect.name})}, {save: true});
      case "edit":
        return effect.sheet.render(true);
      case "favorite":
        return this.document.system.addFavorite({type: "effect", id: effect.getRelativeUUID(this.document)});
      case "toggle":
        return effect.update({disabled: !effect.disabled});
      case "unfavorite":
        return this.document.system.removeFavorite(effect.getRelativeUUID(this.document));
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling a condition.
   * @param {string} conditionId  The condition identifier.
   * @returns {Promise}
   * @protected
   */
  async _onToggleCondition(conditionId) {
    const existing = this.document.effects.get(staticID(`dnd5e${conditionId}`));
    if ( existing ) return existing.delete();
    const effect = await ActiveEffect.implementation.fromStatusEffect(conditionId);
    return ActiveEffect.implementation.create(effect, { parent: this.document, keepId: true });
  }

  /* -------------------------------------------- */

  /**
   * Create a new effect.
   * @param {HTMLElement} target  Button that triggered this action.
   * @returns {Promise<ActiveEffect5e>}
   */
  async _onCreate(target) {
    const li = target.closest("li");
    const isActor = this.document instanceof Actor;
    const isEnchantment = li.dataset.effectType.startsWith("enchantment");
    return this.document.createEmbeddedDocuments("ActiveEffect", [{
      type: isEnchantment ? "enchantment" : "base",
      name: isActor ? game.i18n.localize("DND5E.EffectNew") : this.document.name,
      icon: isActor ? "icons/svg/aura.svg" : this.document.img,
      origin: isEnchantment ? undefined : this.document.uuid,
      "duration.rounds": li.dataset.effectType === "temporary" ? 1 : undefined,
      disabled: ["inactive", "enchantmentInactive"].includes(li.dataset.effectType)
    }]);
  }

  /* -------------------------------------------- */

  /**
   * Handle clicking an effect's source.
   * @param {PointerEvent} event  The triggering event.
   * @protected
   */
  async _onClickEffectSource(event) {
    const { uuid } = event.currentTarget.dataset;
    const doc = await fromUuid(uuid);
    if ( !doc ) return;
    if ( !doc.testUserPermission(game.user, "LIMITED") ) {
      ui.notifications.warn("DND5E.DocumentViewWarn", { localize: true });
      return;
    }
    doc.sheet.render(true);
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Fetch an effect from this document, or any embedded items if this document is an actor.
   * @param {object} data
   * @param {string} data.effectId    ID of the effect to fetch.
   * @param {string} [data.parentId]  ID of the parent item containing the effect.
   * @returns {ActiveEffect5e}
   */
  getEffect({ effectId, parentId }={}) {
    if ( !parentId ) return this.document.effects.get(effectId);
    return this.document.items.get(parentId).effects.get(effectId);
  }
}

/**
 * Base document sheet from which all actor configuration sheets should be based.
 */
class BaseConfigSheet extends DocumentSheet5e {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["config-sheet"],
    sheetConfig: false,
    form: {
      submitOnChange: true
    }
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    context.advantageModeOptions = [
      { value: -1, label: game.i18n.localize("DND5E.Disadvantage") },
      { value: 0, label: game.i18n.localize("DND5E.Normal") },
      { value: 1, label: game.i18n.localize("DND5E.Advantage") }
    ];
    return context;
  }
}

/**
 * Configuration application for an actor's creature type.
 */
class CreatureTypeConfig extends BaseConfigSheet {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["creature-type"],
    keyPath: "details.type",
    position: {
      width: 420
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    config: {
      template: "systems/dnd5e/templates/shared/config/creature-type-config.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  get title() {
    return game.i18n.localize("DND5E.CreatureType");
  }

  /* -------------------------------------------- */

  /**
   * Return a reference to the Actor. Either the NPCs themselves if they are being edited, otherwise the parent Actor
   * if a race Item is being edited.
   * @returns {Actor5e}
   */
  get actor() {
    return this.object.actor ?? this.object;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    const source = this.document.system._source;

    context.data = foundry.utils.getProperty(source, this.options.keyPath) ?? {};
    context.fields = this.document.system.schema.getField(this.options.keyPath).fields;
    context.keyPath = `system.${this.options.keyPath}`;

    context.swarmOptions = [
      { value: "", label: "" },
      ...Object.entries(CONFIG.DND5E.actorSizes).map(([value, { label }]) => ({ value, label })).reverse()
    ];
    context.typeOptions = Object.entries(CONFIG.DND5E.creatureTypes)
      .map(([value, { label }]) => ({ value, label, selected: context.data.value === value }));
    if ( context.fields.custom ) context.custom = {
      enabled: true,
      selected: context.data.value === "custom"
    };
    context.rows = Math.ceil((context.typeOptions.length + (context.custom.enabled ? 1 : 0)) / 2);
    context.preview = Actor5e.formatCreatureType(context.data);

    return context;
  }
}

/**
 * Configuration application for an actor or species's movement & senses.
 */
class MovementSensesConfig extends BaseConfigSheet {
  /** @override */
  static DEFAULT_OPTIONS = {
    type: null,
    keyPath: null,
    position: {
      width: 420
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    config: {
      template: "systems/dnd5e/templates/shared/config/movement-senses-config.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Path to the movement or senses data on the document.
   * @type {string}
   */
  get keyPath() {
    let keyPath = this.options.keyPath ?? `${this.document instanceof Actor ? "attributes." : ""}${this.options.type}`;
    if ( keyPath.startsWith("system.") ) keyPath = keyPath.slice(7);
    return keyPath;
  }

  /* -------------------------------------------- */

  /** @override */
  get title() {
    return game.i18n.localize(this.options.type === "movement" ? "DND5E.Movement" : "DND5E.Senses");
  }

  /* -------------------------------------------- */

  /**
   * Specific types measured, depending on trait type and actor type.
   * @type {string}
   */
  get types() {
    if ( this.options.type === "senses" ) return Object.keys(CONFIG.DND5E.senses);
    return Object.keys(CONFIG.DND5E.movementTypes);
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _initializeApplicationOptions(options) {
    options = super._initializeApplicationOptions(options);
    options.uniqueId = `${options.type}-${options.document.uuid}`.replace(/\./g, "-");
    return options;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    const source = this.document.system._source;
    const placeholderData = this.document.system.details?.race?.system?.[this.options.type] ?? null;

    context.data = foundry.utils.getProperty(source, this.keyPath) ?? {};
    context.fields = this.document.system.schema.getField(this.keyPath)?.fields;
    if ( context.fields ) {
      context.extras = this._prepareExtraFields(context);
      context.types = this.types.map(key => ({
        field: context.fields[key],
        value: context.data[key],
        placeholder: placeholderData?.[key] ?? ""
      }));

      context.unitsOptions = Object.entries(CONFIG.DND5E.movementUnits).map(([value, { label }]) => ({ value, label }));
      context.unitsOptions.blank = false;
      if ( (this.document.type === "character") || ((this.document.type === "npc") && placeholderData) ) {
        const automaticUnit = CONFIG.DND5E.movementUnits[placeholderData?.units ?? defaultUnits("length")]?.label ?? "";
        context.unitsOptions.blank = true;
        context.unitsOptions.unshift(
          { value: "", label: game.i18n.format("DND5E.AutomaticValue", { value: automaticUnit.toLowerCase() }) },
          { rule: true }
        );
      }
    }

    if ( this.options.type === "movement" ) this._prepareTravelFields(context);

    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare the additional fields listed in the form.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @returns {object[]}
   * @protected
   */
  _prepareExtraFields(context) {
    const extras = [];
    if ( context.fields.hover ) context.hover = {
      field: context.fields.hover,
      input: context.inputs.createCheckboxInput,
      value: context.data.hover,
      localize: true
    };
    if ( context.fields.bonus ) extras.push({
      field: context.fields.bonus,
      value: context.data.bonus,
      localize: true
    });
    if ( context.fields.ignoredDifficultTerrain ) extras.push({
      field: context.fields.ignoredDifficultTerrain,
      value: context.data.ignoredDifficultTerrain,
      options: [
        { value: "all", label: game.i18n.localize("DND5E.REGIONBEHAVIORS.DIFFICULTTERRAIN.Type.All") },
        { value: "magical", label: game.i18n.localize("DND5E.REGIONBEHAVIORS.DIFFICULTTERRAIN.Type.Magical") },
        { value: "nonmagical", label: game.i18n.localize("DND5E.REGIONBEHAVIORS.DIFFICULTTERRAIN.Type.Nonmagical") },
        { rule: true },
        ...Object.entries(CONFIG.DND5E.difficultTerrainTypes).map(([value, { label }]) => ({ value, label }))
      ],
      localize: true
    });
    return extras;
  }

  /* -------------------------------------------- */

  /**
   * Prepare travel-specific fields.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @protected
   */
  _prepareTravelFields(context) {
    const keyPath = this.keyPath.replace("movement", "travel");
    const data = foundry.utils.getProperty(this.document.system._source, keyPath);
    if ( !data ) return;
    const derived = foundry.utils.getProperty(this.document.system, keyPath);
    context.travel = {
      data,
      extras: [],
      fields: this.document.system.schema.getField(keyPath).fields,
      unitsOptions: Object.entries(CONFIG.DND5E.travelUnits).map(([value, { label }]) => ({ value, label }))
    };
    context.travel.types = Object.entries(CONFIG.DND5E.travelTypes).map(([key, config]) => ({
      label: config.label,
      pace: {
        field: context.travel.fields.paces.model,
        name: `system.${keyPath}.paces.${key}`,
        placeholder: derived.paces[key] && !data.paces[key] ? derived.paces[key] : "",
        value: data.paces[key]
      },
      speed: {
        field: context.travel.fields.speeds.model,
        name: `system.${keyPath}.speeds.${key}`,
        placeholder: derived.speeds[key] && !data.speeds[key] ? derived.speeds[key] : "",
        value: data.speeds[key]
      }
    }));
    if ( context.travel.fields.pace ) context.travel.extras.push({
      field: context.travel.fields.pace,
      localize: true,
      options: Object.entries(CONFIG.DND5E.travelPace).map(([value, { label }]) => ({ value, label })),
      value: data.pace
    });
    if ( context.travel.fields.time ) context.travel.extras.push({
      field: context.travel.fields.time,
      localize: true,
      value: data.time
    });
    if ( context.fields ) context.legend = game.i18n.localize("DND5E.MOVEMENT.Speed");
  }
}

/**
 * Application for configuring the source data on actors and items.
 */
class SourceConfig extends DocumentSheet5e {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["source-config", "standard-form"],
    sheetConfig: false,
    position: {
      width: 400
    },
    form: {
      closeOnSubmit: true
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    source: {
      template: "systems/dnd5e/templates/apps/source-config.hbs"
    },
    footer: {
      template: "templates/generic/form-footer.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  get title() {
    return game.i18n.localize("DND5E.SOURCE.Action.Configure");
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    const source = this.document.system._source;
    context.buttons = [{ icon: "fa-regular fa-save", label: "Save", type: "Save" }];
    context.data = foundry.utils.getProperty(this.document, this.options.keyPath);
    context.fields = this.document.system.schema.getField("source").fields;
    context.keyPath = this.options.keyPath;
    context.source = source.source;
    context.sourceUuid = this.document._stats.compendiumSource;
    context.sourceAnchor = (await fromUuid(context.sourceUuid))?.toAnchor().outerHTML;
    context.rulesVersions = [
      { value: "", label: "" },
      { value: "2024", label: game.i18n.localize("SETTINGS.DND5E.RULESVERSION.Modern") },
      { value: "2014", label: game.i18n.localize("SETTINGS.DND5E.RULESVERSION.Legacy") }
    ];
    if ( this.document.system.hasOwnProperty("identifier") ) context.identifier = {
      field: this.document.system.schema.getField("identifier"),
      placeholder: this.document.identifier,
      value: source.identifier
    };
    return context;
  }
}

/**
 * Configuration application for Starting Equipment.
 */
class StartingEquipmentConfig extends DocumentSheet5e {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["starting-equipment"],
    form: {
      submitOnChange: true
    },
    position: {
      width: 480
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    config: {
      template: "systems/dnd5e/templates/apps/starting-equipment-config.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  get title() {
    return game.i18n.localize("DND5E.StartingEquipment.Action.Configure");
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);

    const processEntry = async (entry, depth=1) => {
      const data = {
        id: entry._id, entry, depth,
        groupType: entry.type in EquipmentEntryData.GROUPING_TYPES,
        validTypes: depth < 3 ? EquipmentEntryData.TYPES : EquipmentEntryData.OPTION_TYPES
      };
      if ( entry.type in EquipmentEntryData.GROUPING_TYPES ) {
        data.children = await Promise.all(entry.children.map(c => processEntry(c, depth + 1)));
      } else if ( entry.type === "linked" ) {
        data.linked = fromUuidSync(entry.key);
        data.showRequireProficiency = ["equipment", "tool", "weapon"].includes(data.linked?.type);
      }
      return data;
    };
    context.entries = await Promise.all(
      this.document.system.startingEquipment
        .filter(e => !e.group)
        .sort((lhs, rhs) => lhs.sort - rhs.sort)
        .map(e => processEntry(e))
    );

    return context;
  }

  /* -------------------------------------------- */
  /*  Life-Cycle Handlers                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    new CONFIG.ux.DragDrop({
      dragSelector: ".drag-bar",
      dropSelector: null,
      callbacks: {
        dragstart: this._onDragStart.bind(this),
        drop: this._onDrop.bind(this)
      }
    }).bind(this.element);
  }

  /* -------------------------------------------- */
  /*  Event Listeners                             */
  /* -------------------------------------------- */

  /**
   * Handle an action.
   * @param {HTMLElement} element       The element on which the action is being performed.
   * @param {object} [options={}]
   * @param {string} [options.action]   The specific action to perform.
   * @param {number} [options.depth]    Depth of the element being acted upon.
   * @param {string} [options.entryId]  ID of the entry to act upon.
   */
  _onAction(element, { action, depth, entryId }={}) {
    this.submit({ updateData: {
      action: action ?? element.closest("[data-action]")?.dataset.action,
      depth: depth ?? (Number(event.target.closest("[data-depth]")?.dataset.depth ?? 0) + 1),
      entryId: entryId ?? element.closest("[data-entry-id]")?.dataset.entryId
    } });
  }

  /* -------------------------------------------- */

  /** @override */
  _onClickAction(event, target) {
    this._onAction(target);
  }

  /* -------------------------------------------- */
  /*  Form Handling                               */
  /* -------------------------------------------- */

  /** @override */
  _prepareSubmitData(event, form, formData, updateData) {
    const submitData = this._processFormData(event, form, formData);
    if ( updateData ) foundry.utils.mergeObject(submitData, updateData, { inplace: true, performDeletions: true });
    // Skip the validation step here because it causes a bunch of problems with providing array
    // updates when using the `submit` method
    return submitData;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _processSubmitData(event, form, submitData) {
    let { action, depth, entryId, linkedUuid, startingEquipment } = submitData;
    startingEquipment = Object.values(startingEquipment ?? {});

    const highestSort = startingEquipment.reduce((sort, i) => i.sort > sort ? i.sort : sort, 0);
    switch ( action ) {
      case "add-entry":
        startingEquipment.push({
          _id: foundry.utils.randomID(),
          group: entryId,
          sort: highestSort + CONST.SORT_INTEGER_DENSITY,
          type: (depth < 3) && !linkedUuid ? "OR" : "linked",
          key: linkedUuid
        });
        break;
      case "delete-entry":
        const deleteIds = new Set();
        const getDeleteIds = entry => {
          deleteIds.add(entry._id);
          entry.children?.forEach(c => getDeleteIds(c));
        };
        getDeleteIds(this.document.system.startingEquipment.find(i => i._id === entryId));
        startingEquipment = startingEquipment.filter(e => !deleteIds.has(e._id));
        break;
    }

    await super._processSubmitData(event, form, { "system.startingEquipment": startingEquipment });
  }

  /* -------------------------------------------- */
  /*  Drag & Drop                                 */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDragStart(event) {
    const entry = event.target.closest("[data-entry-id]");
    if ( !entry ) return;
    event.dataTransfer.setData("text/plain", JSON.stringify({
      type: "equipment-entry", uuid: this.document.uuid, entryId: entry.dataset.entryId
    }));
    const box = entry.getBoundingClientRect();
    event.dataTransfer.setDragImage(entry, box.width - 6, box.height / 2);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onDrop(event) {
    // Try to extract the data
    const data = foundry.applications.ux.TextEditor.implementation.getDragEventData(event);

    // Handle re-ordering of list
    if ( data?.entryId && (data.uuid === this.document.uuid) ) return this._onSortEntry(event, data);

    // Handle dropping linked items
    if ( data?.type !== "Item" ) return;
    const item = await Item.implementation.fromDropData(data);

    // Validate that this is a physical item
    if ( !item.system.constructor._schemaTemplates?.includes(PhysicalItemTemplate) ) {
      ui.notifications.error(game.i18n.format("DND5E.StartingEquipment.Warning.ItemTypeInvalid", {
        type: game.i18n.localize(CONFIG.Item.typeLabels[item.type])
      }));
      return null;
    }

    // Determine where this was dropped
    const closestDrop = event.target.closest(
      '[data-entry-type="AND"], [data-entry-type="OR"], [data-entry-type="linked"]'
    );
    const { entryId, entryType } = closestDrop?.dataset ?? {};

    // If no closest entry, create at top level, or if closest is a group, create inside that group
    if ( !entryId || (entryType in EquipmentEntryData.GROUPING_TYPES) ) this.submit({ updateData: {
      action: "add-entry",
      entryId,
      linkedUuid: item.uuid
    } });

    // If closest entry is linked, set its key to be this uuid
    else if ( entryType === "linked" ) this.submit({ updateData: {
      [`startingEquipment.${entryId}.key`]: item.uuid
    } });
  }

  /* -------------------------------------------- */

  /**
   * Sort an entry on drop.
   * @param {DragEvent} event  Triggering drop event.
   * @param {object} data      Drag event data.
   */
  _onSortEntry(event, data) {
    const dropArea = event.target.closest("[data-entry-id]");
    const dragEntry = this.document.system.startingEquipment.find(e => e._id === data?.entryId);
    const dropEntry = this.document.system.startingEquipment.find(e => e._id === dropArea?.dataset.entryId);

    // If drag entry & drop entry are the same, or drop entry is drag entry's group, do nothing
    if ( (dropEntry?._id === dragEntry._id) || (dropEntry?._id === dragEntry.group) ) return;

    let updateData;
    let sortBefore;
    let target;

    // If drop entry is a group, move drag entry into it
    if ( dropEntry?.type in EquipmentEntryData.GROUPING_TYPES ) {
      let depth = Number(dropArea.dataset.depth) + 1;
      if ( dragEntry.children?.length ) {
        depth += 1;
        if ( dragEntry.children.some(c => c.type in EquipmentEntryData.GROUPING_TYPES) ) depth += 1;
      }
      if ( depth > 3 ) {
        ui.notifications.warn("DND5E.StartingEquipment.Warning.Depth", { localize: true });
        return;
      }
      updateData = { [`startingEquipment.${dragEntry._id}.group`]: dropEntry._id };
      target = dropEntry.children.pop();
    }

    // If drag entry and drop entry are in the same group, perform relative sort
    else if ( dropEntry && (dropEntry.group === dragEntry.group) ) {
      target = dropEntry;
    }

    // If dropped outside any entry, move to top level and sort to top or bottom of list
    else if ( !dropEntry ) {
      updateData = { [`startingEquipment.${dragEntry._id}.group`]: null };
      const box = this.element.getBoundingClientRect();
      sortBefore = (event.clientY - box.y) < (box.height * .75);
      const sortedEntries = this.document.system.startingEquipment.filter(e => !e.group)
        .sort((lhs, rhs) => lhs.sort - rhs.sort);
      target = sortBefore ? sortedEntries.shift() : sortedEntries.pop();
    }

    // If they are in different groups, move entry to new group and then sort
    else if ( dropEntry.group !== dragEntry.group ) {
      updateData = { [`startingEquipment.${dragEntry._id}.group`]: dropEntry.group };
      target = dropEntry;
    }

    if ( target && (target !== dragEntry) ) {
      updateData ??= {};
      const siblings = this.document.system.startingEquipment.filter(s => s._id !== dragEntry._id);
      const sortUpdates = foundry.utils.performIntegerSort(dragEntry, { target, siblings, sortBefore });
      for ( const update of sortUpdates ) {
        updateData[`startingEquipment.${update.target._id}.sort`] = update.update.sort;
      }
    }

    if ( updateData ) this.submit({ updateData });
  }
}

const TextEditor$9 = foundry.applications.ux.TextEditor.implementation;

/**
 * Base item sheet built on ApplicationV2.
 */
class ItemSheet5e extends PrimarySheetMixin(DocumentSheet5e) {
  /** @override */
  static DEFAULT_OPTIONS = {
    actions: {
      addRecovery: ItemSheet5e.#addRecovery,
      deleteCraft: ItemSheet5e.#deleteCraft,
      deleteDocument: ItemSheet5e.#deleteDocument,
      deleteRecovery: ItemSheet5e.#deleteRecovery,
      editDescription: ItemSheet5e.#editDescription,
      modifyAdvancementChoices: ItemSheet5e.#modifyAdvancementChoices,
      showConfiguration: ItemSheet5e.#showConfiguration,
      showDocument: ItemSheet5e.#showDocument,
      showIcon: ItemSheet5e.#showIcon,
      toggleState: ItemSheet5e.#toggleState
    },
    classes: ["item"],
    editingDescriptionTarget: null,
    elements: {
      activities: "dnd5e-activities",
      effects: "dnd5e-effects"
    },
    form: {
      submitOnChange: true
    },
    position: {
      width: 500
    },
    window: {
      resizable: false
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    header: {
      template: "systems/dnd5e/templates/items/header.hbs"
    },
    tabs: {
      template: "systems/dnd5e/templates/shared/horizontal-tabs.hbs",
      templates: ["templates/generic/tab-navigation.hbs"]
    },
    activities: {
      template: "systems/dnd5e/templates/items/activities.hbs",
      templates: ["systems/dnd5e/templates/shared/activities.hbs"],
      scrollable: [""]
    },
    advancement: {
      template: "systems/dnd5e/templates/items/advancement.hbs",
      scrollable: [""]
    },
    description: {
      template: "systems/dnd5e/templates/items/description.hbs",
      scrollable: [""]
    },
    details: {
      template: "systems/dnd5e/templates/items/details.hbs",
      scrollable: [""]
    },
    effects: {
      template: "systems/dnd5e/templates/items/effects.hbs",
      scrollable: [""]
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static TABS = [
    { tab: "description", label: "DND5E.ITEM.SECTIONS.Description" },
    { tab: "details", label: "DND5E.ITEM.SECTIONS.Details", condition: this.isItemIdentified.bind(this) },
    { tab: "activities", label: "DND5E.ITEM.SECTIONS.Activities", condition: this.itemHasActivities.bind(this) },
    { tab: "effects", label: "DND5E.ITEM.SECTIONS.Effects", condition: this.itemHasEffects.bind(this) },
    { tab: "advancement", label: "DND5E.ITEM.SECTIONS.Advancement", condition: this.itemHasAdvancement.bind(this) }
  ];

  /* -------------------------------------------- */

  /** @override */
  tabGroups = {
    primary: "description"
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The Actor owning the item, if any.
   * @type {Actor5e}
   */
  get actor() {
    return this.document.actor;
  }

  /* -------------------------------------------- */

  /**
   * Additional toggles added to header buttons.
   * @type {Record<string, HTMLElement>}
   */
  _headerToggles = {};

  /* -------------------------------------------- */

  /**
   * Description currently being edited for items types with multiple descriptions.
   * @type {string|null}
   */
  editingDescriptionTarget = null;

  /* -------------------------------------------- */

  /** @override */
  _filters = {
    effects: { name: "", properties: new Set() }
  };

  /* -------------------------------------------- */

  /**
   * The Item document managed by this sheet.
   * @type {Item5e}
   */
  get item() {
    return this.document;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _configureRenderOptions(options) {
    await super._configureRenderOptions(options);
    if ( options.isFirstRender ) {
      this.expandedSections.set("system.description.value", true);
      if ( !game.user.isGM ) this.expandedSections.set("system.unidentified.description", true);
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _disableFields() {
    this.element.querySelectorAll(":is(document-embed, secret-block) button").forEach(el => {
      el.classList.add("always-interactive");
    });
    super._disableFields();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = {
      ...await super._prepareContext(options),
      concealDetails: !game.user.isGM && (this.item.system.identified === false),
      elements: this.options.elements,
      fields: this.item.system.schema.fields,
      isEmbedded: this.item.isEmbedded,
      isIdentifiable: "identified" in this.item.system,
      isIdentified: this.item.system.identified !== false,
      isPhysical: "quantity" in this.item.system,
      item: this.item,
      labels: this.item.labels,
      system: this.item.system,
      user: game.user
    };
    context.source = context.editable ? this.item.system._source : this.item.system;

    context.properties = {
      active: [],
      object: Object.fromEntries((context.system.properties ?? []).map(p => [p, true])),
      options: (this.item.system.validProperties ?? []).reduce((arr, k) => {
        const { label } = CONFIG.DND5E.itemProperties[k];
        arr.push({
          label,
          value: k,
          selected: context.source.properties?.includes?.(k) ?? context.source.properties?.has?.(k)
        });
        return arr;
      }, [])
    };
    if ( this.item.type !== "spell" ) {
      context.properties.options.sort((a, b) => a.label.localeCompare(b.label, game.i18n.lang));
    }
    if ( game.user.isGM || context.isIdentified ) context.properties.active.push(
      ...this.item.system.cardProperties ?? [],
      ...Object.values(this.item.labels.activations?.[0] ?? {}),
      ...this.item.system.equippableItemCardProperties ?? []
    );

    await this.item.system.getSheetData?.(context);

    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    switch ( partId ) {
      case "activities": context = await this._prepareActivitiesContext(context, options); break;
      case "advancement": context = await this._prepareAdvancementContext(context, options); break;
      case "description": context = await this._prepareDescriptionContext(context, options); break;
      case "details": context = await this._prepareDetailsContext(context, options); break;
      case "effects": context = await this._prepareEffectsContext(context, options); break;
      case "header": context = await this._prepareHeaderContext(context, options); break;
    }

    if ( context.properties?.active ) context.properties.active = context.properties.active.filter(_ => _);

    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare rendering context for the activities tab.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {ApplicationRenderContext}
   * @protected
   */
  async _prepareActivitiesContext(context, options) {
    const activityMap = {};
    const origins = {};
    const riders = [];
    context.activities = (this.item.system.activities ?? []).reduce((arr, activity) => {
      if ( activity.type === "enchant" ) {
        for ( const effect of activity.effects ?? [] ) {
          for ( const id of effect.riders.activity ) {
            origins[id] ??= [];
            origins[id].push(activity._id);
          }
        }
      }
      if ( !activity.canConfigure ) return arr;
      const { _id: id, name, img, labels, sort } = activity.prepareSheetContext();
      const descriptor = activityMap[id] = {
        id, name, sort,
        img: { src: img, svg: img?.endsWith(".svg") },
        riders: new Set(),
        subtitle: labels?.activation ?? "",
        uuid: activity.uuid
      };
      if ( activity.isRider ) riders.push(descriptor);
      else arr.push(descriptor);
      return arr;
    }, []);
    riders.forEach(r => {
      for ( const origin of origins[r.id] ?? [] ) {
        if ( origin in activityMap ) activityMap[origin].riders.add(r);
      }
    });
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare rendering context for the advancement tab.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {ApplicationRenderContext}
   * @protected
   */
  async _prepareAdvancementContext(context, options) {
    context.advancement = this._getAdvancement();
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare rendering context for the description tab.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {ApplicationRenderContext}
   * @protected
   */
  async _prepareDescriptionContext(context, options) {
    context.expanded = this.expandedSections.entries().reduce((obj, [k, v]) => {
      obj[k] = v;
      return obj;
    }, {});

    const enrichmentOptions = {
      secrets: this.item.isOwner, relativeTo: this.item, rollData: this.item.getRollData()
    };
    context.enriched = {
      description: await TextEditor$9.enrichHTML(this.item.system.description.value, enrichmentOptions),
      unidentified: await TextEditor$9.enrichHTML(this.item.system.unidentified?.description, enrichmentOptions),
      chat: await TextEditor$9.enrichHTML(this.item.system.description.chat, enrichmentOptions)
    };
    if ( this.editingDescriptionTarget ) context.editingDescription = {
      target: this.editingDescriptionTarget,
      value: foundry.utils.getProperty(this.item._source, this.editingDescriptionTarget)
    };

    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare rendering context for the details tab.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {ApplicationRenderContext}
   * @protected
   */
  async _prepareDetailsContext(context, options) {
    context.tab = context.tabs.details;
    context.parts ??= [];

    context.baseItemOptions = await this._getBaseItemOptions(context);
    context.coverOptions = Object.entries(CONFIG.DND5E.cover).map(([value, label]) => ({ value, label }));
    context.unitsOptions = Object.entries(CONFIG.DND5E.movementUnits).map(([value, { label }]) => ({ value, label }));

    // If using modern rules, do not show redundant artificer progression unless it is already selected.
    context.spellProgression = { ...CONFIG.DND5E.spellProgression };
    if ( (game.settings.get("dnd5e", "rulesVersion") === "modern")
      && (this.item.system.spellcasting?.progression !== "artificer") ) delete context.spellProgression.artificer;
    context.spellProgression = Object.entries(context.spellProgression).map(([value, config]) => {
      const group = CONFIG.DND5E.spellcasting[config.type]?.label ?? "";
      return { group, value, label: config.label };
    });
    const { progression } = this.item.system.spellcasting ?? {};
    if ( progression && !(progression in CONFIG.DND5E.spellProgression) ) {
      context.spellProgression.push({ value: progression, label: progression });
    }

    // Limited Uses
    context.data = { uses: context.source.uses };
    context.hasLimitedUses = this.item.system.hasLimitedUses;
    context.recoveryPeriods = CONFIG.DND5E.limitedUsePeriods.recoveryOptions;
    context.recoveryTypes = [
      { value: "recoverAll", label: "DND5E.USES.Recovery.Type.RecoverAll" },
      { value: "loseAll", label: "DND5E.USES.Recovery.Type.LoseAll" },
      { value: "formula", label: "DND5E.USES.Recovery.Type.Formula" }
    ];
    context.usesRecovery = (context.source.uses?.recovery ?? []).map((data, index) => ({
      data,
      fields: context.fields.uses.fields.recovery.element.fields,
      prefix: `system.uses.recovery.${index}.`,
      source: context.source.uses.recovery[index] ?? data,
      formulaOptions: data.period === "recharge" ? UsesField.rechargeOptions : null
    }));

    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare rendering context for the effects tab.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {ApplicationRenderContext}
   * @protected
   */
  async _prepareEffectsContext(context, options) {
    const effectMap = {};
    const riders = [];
    const riderIds = new Set(this.item.getFlag("dnd5e", "riders.effect") ?? []);
    context.tab = context.tabs.effects;
    context.effects = EffectsElement.prepareCategories(this.item.effects, { parent: this.item });
    for ( const category of Object.values(context.effects) ) {
      category.effects = await category.effects.reduce(async (arr, effect) => {
        effect.updateDuration();
        const { id, name, img, disabled, duration } = effect;
        const source = await effect.getSource();
        arr = await arr;
        const ctx = effectMap[id] = {
          id, name, img, disabled, duration, source, parent,
          durationParts: duration.remaining ? duration.label.split(", ") : [],
          hasTooltip: true,
          riders: []
        };
        if ( riderIds.has(id) ) riders.push(ctx);
        else arr.push(ctx);
        return arr;
      }, []);
    }
    const origins = (this.item.system.activities?.getByType("enchant") ?? [])
      .flatMap(a => a.effects)
      .reduce((obj, effects) => {
        const { _id, riders } = effects;
        for ( const id of riders.effect ) {
          obj[id] ??= [];
          obj[id].push(_id);
        }
        return obj;
      }, {});
    riders.forEach(r => {
      for ( const origin of origins[r.id] ?? [] ) {
        if ( origin in effectMap ) effectMap[origin].riders.push(r);
      }
    });
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare rendering context for the header.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {ApplicationRenderContext}
   * @protected
   */
  async _prepareHeaderContext(context, options) {
    if ( ("identified" in this.item.system) && !context.isIdentified ) context.name = {
      value: this.item.system.unidentified.name,
      editable: this.item.system._source.unidentified.name,
      field: this.item.system.schema.getField("unidentified.name")
    };
    else context.name = {
      value: this.item.name,
      editable: this.item._source.name,
      field: this.item.schema.getField("name")
    };
    context.img = {
      value: this.item.img,
      editable: this.item._source.img
    };

    return context;
  }

  /* -------------------------------------------- */

  /**
   * Get the display object used to show the advancement tab.
   * @returns {object}     Object with advancement data grouped by levels.
   */
  _getAdvancement() {
    if ( !this.item.system.advancement ) return {};

    const advancement = {};
    const configMode = !this.item.parent || (this._mode === ItemSheet5e.MODES.EDIT);
    const legacyDisplay = this.options.legacyDisplay;
    const maxLevel = this.item.parent ? (this.item.system.levels ?? this.item.class?.system.levels
      ?? this.item.parent.system.details?.level ?? -1) : -1;

    // Improperly configured advancements
    if ( this.item.advancement.needingConfiguration.length ) {
      advancement.unconfigured = {
        items: this.item.advancement.needingConfiguration.map(a => ({
          id: a.id,
          uuid: a.uuid,
          order: a.constructor.order,
          title: a.title,
          icon: a.icon,
          classRestriction: a.classRestriction,
          configured: false,
          tags: this._getAdvancementTags(a),
          classes: [a.icon?.endsWith(".svg") ? "svg" : ""].filterJoin(" ")
        })),
        configured: "partial"
      };
    }

    // All other advancements by level
    for ( let [level, advancements] of Object.entries(this.item.advancement.byLevel) ) {
      if ( !configMode ) advancements = advancements.filter(a => a.appliesToClass);
      const items = advancements.map(advancement => ({
        id: advancement.id,
        uuid: advancement.uuid,
        order: advancement.sortingValueForLevel(level),
        title: advancement.titleForLevel(level, { configMode, legacyDisplay }),
        icon: advancement.icon,
        classRestriction: advancement.classRestriction,
        summary: advancement.summaryForLevel(level, { configMode, legacyDisplay }),
        configured: advancement.configuredForLevel(level),
        tags: this._getAdvancementTags(advancement),
        value: advancement.valueForLevel?.(level),
        classes: [advancement.icon?.endsWith(".svg") ? "svg" : ""].filterJoin(" ")
      }));
      if ( !items.length ) continue;
      advancement[level] = {
        items: items.sort((a, b) => a.order.localeCompare(b.order, game.i18n.lang)),
        configured: (level > maxLevel) ? false : items.some(a => !a.configured) ? "partial" : "full"
      };
    }
    return advancement;
  }

  /* -------------------------------------------- */

  /**
   * Prepare tags for an Advancement.
   * @param {Advancement} advancement  The Advancement.
   * @returns {{ label: string, icon: string }[]}
   * @protected
   */
  _getAdvancementTags(advancement) {
    if ( this.item.isEmbedded && (this._mode !== this.constructor.MODES.EDIT) ) return [];
    const tags = [];
    if ( advancement.classRestriction === "primary" ) {
      tags.push({
        label: "DND5E.AdvancementClassRestrictionPrimary",
        icon: "systems/dnd5e/icons/svg/original-class.svg"
      });
    } else if ( advancement.classRestriction === "secondary" ) {
      tags.push({
        label: "DND5E.AdvancementClassRestrictionSecondary",
        icon: "systems/dnd5e/icons/svg/multiclass.svg"
      });
    }
    return tags;
  }

  /* -------------------------------------------- */

  /**
   * Get the base weapons and tools based on the selected type.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @returns {Promise<FormSelectOptions[]|null>}
   * @protected
   */
  async _getBaseItemOptions(context) {
    const baseIds = this.item.type === "equipment" ? {
      ...CONFIG.DND5E.armorIds,
      ...CONFIG.DND5E.shieldIds
    } : CONFIG.DND5E[`${this.item.type}Ids`];
    if ( baseIds === undefined ) return null;

    const options = [];
    for ( const [value, id] of Object.entries(baseIds) ) {
      const baseItem = await getBaseItem(id);
      if ( context?.source.type.value !== baseItem?.system?.type?.value ) continue;
      options.push({ value, label: baseItem.name });
    }

    if ( !options.length ) return null;

    options.sort((lhs, rhs) => lhs.label.localeCompare(rhs.label, game.i18n.lang));
    const baseId = this.item.system._source.type.baseItem ?? this.item.system.type.baseItem;
    if ( baseId && !(baseId in baseIds) ) options.unshift({ value: baseId, label: baseId });
    options.unshift({ value: "", label: "" });
    return options;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _renderFrame(options) {
    const html = await super._renderFrame(options);

    if ( this.isEditable ) {
      const classes = ["fa-solid", "icon"];
      const buttons = [];
      if ( "identified" in this.item.system ) buttons.push({
        id: "identified", property: "system.identified", classes: [...classes, "fa-wand-sparkles", "toggle-identified"]
      });
      if ( "equipped" in this.item.system ) buttons.push({
        id: "equipped", property: "system.equipped", classes: [...classes, "fa-shield-halved", "toggle-equipped"]
      });

      const sibling = html.querySelector('[data-action="copyUuid"], [data-action="close"]');
      for ( const { id, property, classes } of buttons ) {
        const button = document.createElement("button");
        button.type = "button";
        button.classList.add("header-control", "pseudo-header-control", "state-toggle", ...classes);
        Object.assign(button.dataset, { action: "toggleState", property, tooltipDirection: "DOWN" });
        sibling.before(button);
        this._headerToggles[id] = button;
      }
    }

    this._renderSourceFrame(html);
    return html;
  }

  /* -------------------------------------------- */
  /*  Life-Cycle Handlers                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _attachFrameListeners() {
    super._attachFrameListeners();
    new ContextMenu5e(this.element, ".advancement-item[data-id]", [], {
      onOpen: target => dnd5e.documents.advancement.Advancement.onContextMenu(this.item, target), jQuery: false
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);

    new CONFIG.ux.DragDrop({
      dragSelector: ":is(.advancement-item, [data-activity-id], [data-effect-id], [data-item-id])",
      dropSelector: null,
      callbacks: {
        dragstart: this._onDragStart.bind(this),
        dragover: this._onDragOver.bind(this),
        drop: this._onDrop.bind(this)
      }
    }).bind(this.element);

    if ( this._mode === this.constructor.MODES.PLAY ) this._disableFields();

    this.element.querySelectorAll(".editor-content[data-edit]").forEach(div => this._activateEditor(div));

    if ( this._headerToggles.identified ) {
      const isIdentified = this.item.system.identified;
      const label = isIdentified ? "DND5E.Identified" : "DND5E.Unidentified.Title";
      this._headerToggles.identified.setAttribute("aria-label", game.i18n.localize(label));
      this._headerToggles.identified.dataset.tooltip = label;
      this._headerToggles.identified.classList.toggle("active", isIdentified);
    }

    if ( this._headerToggles.equipped ) {
      const isEquipped = this.item.system.equipped;
      const label = isEquipped ? "DND5E.Equipped" : "DND5E.Unequipped";
      this._headerToggles.equipped.setAttribute("aria-label", game.i18n.localize(label));
      this._headerToggles.equipped.dataset.tooltip = label;
      this._headerToggles.equipped.classList.toggle("active", isEquipped);
    }

    if ( this.editingDescriptionTarget ) {
      this.element.querySelectorAll("prose-mirror").forEach(editor => editor.addEventListener("save", () => {
        this.editingDescriptionTarget = null;
        this.render();
      }));
    }

    this._renderSource();
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @override */
  _addDocument() {
    if ( this.tabGroups.primary === "activities" ) {
      return dnd5e.documents.activity.UtilityActivity.createDialog({}, { parent: this.item });
    }

    if ( this.tabGroups.primary === "advancement" ) {
      return dnd5e.documents.advancement.Advancement.createDialog({}, { parent: this.item });
    }

    if ( this.tabGroups.primary === "effects" ) {
      return ActiveEffect.implementation.create({
        name: this.document.name,
        img: this.document.img,
        origin: this.document.uuid
      }, { parent: this.document, renderSheet: true });
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle adding a recovery option.
   * @this {ItemSheet5e}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   * @returns {any}
   */
  static #addRecovery(event, target) {
    return this.submit({ updateData: { "system.uses.recovery": [...this.item.system.toObject().uses.recovery, {}] } });
  }

  /* -------------------------------------------- */

  /**
   * Handle removing an document.
   * @this {ItemSheet5e}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static async #deleteDocument(event, target) {
    const uuid = target.closest("[data-uuid]").dataset?.uuid;
    const doc = await fromUuid(uuid);
    doc?.deleteDialog();
  }

  /* -------------------------------------------- */

  /**
   * Handle removing an item currently being crafted.
   * @this {ItemSheet5e}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static #deleteCraft(event, target) {
    this.submit({ updateData: { "system.craft": null } });
  }

  /* -------------------------------------------- */

  /**
   * Handle removing a recovery option.
   * @this {ItemSheet5e}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static #deleteRecovery(event, target) {
    const recovery = this.item.system.toObject().uses.recovery;
    recovery.splice(target.closest("[data-index]").dataset.index, 1);
    this.submit({ updateData: { "system.uses.recovery": recovery } });
  }

  /* -------------------------------------------- */

  /**
   * Handle expanding the description editor.
   * @this {ItemSheet5e}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static #editDescription(event, target) {
    if ( target.ariaDisabled ) return;
    this.editingDescriptionTarget = target.dataset.target;
    this.render();
  }

  /* -------------------------------------------- */

  /**
   * Handle modifying the choices for an advancement level.
   * @this {ItemSheet5e}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static #modifyAdvancementChoices(event, target) {
    const level = target.closest("[data-level]")?.dataset.level;
    const manager = AdvancementManager.forModifyChoices(this.actor, this.item.id, Number(level));
    if ( manager.steps.length ) manager.render({ force: true });
  }

  /* -------------------------------------------- */

  /**
   * Handle opening a configuration application.
   * @this {ItemSheet5e}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   * @returns {any}
   */
  static #showConfiguration(event, target) {
    switch ( target.dataset.config ) {
      case "movement":
      case "senses":
        return new MovementSensesConfig({ document: this.item, type: target.dataset.config }).render({ force: true });
      case "source":
        return new SourceConfig({ document: this.item, keyPath: "system.source" }).render({ force: true });
      case "starting-equipment":
        return new StartingEquipmentConfig({ document: this.item }).render({ force: true });
      case "type":
        return new CreatureTypeConfig({ document: this.item, keyPath: "type" }).render({ force: true });
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle opening a document sheet.
   * @this {ItemSheet5e}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static async #showDocument(event, target) {
    const uuid = target.closest("[data-uuid]")?.dataset.uuid;
    const doc = await fromUuid(uuid);
    doc?.sheet?.render({ force: true });
  }

  /* -------------------------------------------- */

  /**
   * Handle showing the Item's art.
   * @this {ItemSheet5e}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static #showIcon(event, target) {
    const title = this.item.system.identified === false ? this.item.system.unidentified.name : this.item.name;
    new foundry.applications.apps.ImagePopout({
      src: this.item.img,
      uuid: this.item.uuid,
      window: { title }
    }).render({ force: true });
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling Item state.
   * @this {ItemSheet5e}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static #toggleState(event, target) {
    const state = target.classList.contains("active");
    this.submit({ updateData: { [target.dataset.property]: !state } });
  }

  /* -------------------------------------------- */
  /*  Form Handling                               */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _processFormData(event, form, formData) {
    const submitData = super._processFormData(event, form, formData);

    // Handle properties
    if ( foundry.utils.hasProperty(submitData, "system.properties") ) {
      submitData.system.properties = filteredKeys(submitData.system.properties);
    }

    return submitData;
  }

  /* -------------------------------------------- */
  /*  Drag & Drop                                 */
  /* -------------------------------------------- */

  /**
   * Handle beginning drag events on the sheet.
   * @param {DragEvent} event  The initiating drag start event.
   * @protected
   */
  _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();
    }

    // Activity
    else if ( li.closest("[data-activity-id]") ) {
      const { activityId } = event.target.closest(".activity[data-activity-id]")?.dataset ?? {};
      const activity = this.item.system.activities?.get(activityId);
      dragData = activity.toDragData();
    }

    // Advancement
    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));
  }

  /* -------------------------------------------- */

  /**
   * Handle dropping items onto the sheet.
   * @param {DragEvent} event  The concluding drag event.
   * @returns {any}
   * @protected
   */
  _onDrop(event) {
    const data = TextEditor$9.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;
    event.stopPropagation();

    switch ( data.type ) {
      case "ActiveEffect":
        return this._onDropActiveEffect(event, data);
      case "Activity":
        return this._onDropActivity(event, data);
      case "Item":
        return this._onDropItem(event, data);
      case "Advancement":
        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
      || (this.item.uuid === effect.parent?.uuid)
      || (this.item.uuid === effect.origin) ) return false;
    const effectData = effect.toObject();
    const options = { parent: this.item, keepOrigin: false };

    if ( effect.type === "enchantment" ) {
      effectData.origin ??= effect.parent.uuid;
      options.keepOrigin = true;
      options.dnd5e = {
        enchantmentProfile: effect.id,
        activityId: data.activityId ?? effect.parent?.system.activities?.getByType("enchant").find(a =>
          a.effects.some(e => e._id === effect.id)
        )?.id
      };
    }

    return ActiveEffect.create(effectData, options);
  }

  /* -------------------------------------------- */

  /**
   * Handle dropping an Activity onto the sheet.
   * @param {DragEvent} event       The drag event.
   * @param {object} transfer       The dropped data.
   * @param {object} transfer.data  The Activity data.
   * @protected
   */
  _onDropActivity(event, { data }) {
    const { _id: id, type } = data;
    const source = this.item.system.activities.get(id);
    const config = CONFIG.DND5E.activityTypes[type] ?? {};

    // Reordering
    if ( source ) {
      const targetId = event.target.closest(".activity[data-activity-id]")?.dataset.activityId;
      const target = this.item.system.activities.get(targetId);
      if ( !target || (target === source) ) return;
      const siblings = this.item.system.activities.filter(a => a._id !== id);
      const sortUpdates = foundry.utils.performIntegerSort(source, { target, siblings });
      const updateData = Object.fromEntries(sortUpdates.map(({ target, update }) => {
        return [target._id, { sort: update.sort }];
      }));
      this.item.update({ "system.activities": updateData });
    }

    // Copying
    else if ( (config?.configurable !== false) && config.documentClass.availableForItem(this.item) ) {
      delete data._id;
      this.item.createActivity(type, data, { renderSheet: false });
    }
  }

  /* -------------------------------------------- */

  /**
   * 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.
   * @returns {Promise}
   */
  async _onDropAdvancement(event, data) {
    if ( !this.item.system.advancement ) return;

    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?.system.advancement ) return false;
      advancements = Object.values(item.advancement.byId);
      showDialog = true;
    } else {
      return false;
    }
    advancements = advancements.filter(a => {
      const validItemTypes = CONFIG.DND5E.advancementTypes[a.constructor.typeName]?.validItemTypes
        ?? a.metadata.validItemTypes;
      return !this.item.advancement.byId[a.id]
        && 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.actor?.system.metadata?.supportsAdvancement && !game.settings.get("dnd5e", "disableAdvancements") ) {
      const manager = AdvancementManager.forNewAdvancement(this.item.actor, this.item.id, advancements);
      if ( manager.steps.length ) return manager.render(true);
    }

    // If no advancements need to be applied, just add them to the item
    const advancementArray = this.item.system.toObject().advancement;
    advancementArray.push(...advancements.map(a => a.toObject()));
    this.item.update({"system.advancement": advancementArray});
  }

  /* -------------------------------------------- */

  /**
   * Handle dropping another item onto this item.
   * @param {DragEvent} event  The drag event.
   * @param {object} data      The dropped data.
   */
  async _onDropItem(event, data) {
    const item = await Item.implementation.fromDropData(data);
    if ( (item?.type === "spell") && this.item.system.activities ) this._onDropSpell(event, item);
    else this._onDropAdvancement(event, data);
  }

  /* -------------------------------------------- */

  /**
   * Handle creating a "Cast" activity when dropping a spell.
   * @param {DragEvent} event  The drag event.
   * @param {Item5e} item      The dropped item.
   */
  _onDropSpell(event, item) {
    this.item.createActivity("cast", { spell: { uuid: item.uuid } });
  }

  /* -------------------------------------------- */
  /*  Filtering                                   */
  /* -------------------------------------------- */

  /**
   * Filter child documents based on the current set of filters.
   * @param {string} collection    The embedded collection name.
   * @param {Set<string>} filters  Filters to apply to the children.
   * @returns {Document[]}
   * @protected
   */
  _filterChildren(collection, filters) {
    if ( collection === "effects" ) return Array.from(this.item.effects);
    return [];
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Determine whether an Item is considered identified.
   * @param {Item5e} item  The Item.
   * @returns {boolean}
   */
  static isItemIdentified(item) {
    return game.user.isGM || (item.system.identified !== false);
  }

  /* -------------------------------------------- */

  /**
   * Determine if an Item support Activities.
   * @param {Item5e} item  The Item.
   * @returns {boolean}
   */
  static itemHasActivities(item) {
    return this.isItemIdentified(item) && ("activities" in item.system);
  }

  /* -------------------------------------------- */

  /**
   * Determine if an Item support Advancement.
   * @param {Item5e} item  The Item.
   * @returns {boolean}
   */
  static itemHasAdvancement(item) {
    return "advancement" in item.system;
  }

  /* -------------------------------------------- */

  /**
   * Determine if an Item should show an effects tab.
   * @param {Item5e} item  The Item.
   * @returns {boolean}
   */
  static itemHasEffects(item) {
    return this.isItemIdentified(item) && item.system.constructor.metadata.hasEffects;
  }
}

/**
 * @import { FilterState5e, ItemListComparator5e } from "../components/_types.mjs";
 * @import { SheetTabDescriptor5e } from "./_types.mjs";
 */

/**
 * Adds V2 sheet functionality shared between primary document sheets (Actors & Items).
 * @param {typeof DocumentSheet5e} Base  The base class being mixed.
 * @returns {typeof PrimarySheet5e}
 */
function PrimarySheetMixin(Base) {
  return class PrimarySheet5e extends DragDropApplicationMixin(Base) {
    /** @override */
    static DEFAULT_OPTIONS = {
      actions: {
        editDocument: PrimarySheet5e.#showDocument,
        deleteDocument: PrimarySheet5e.#deleteDocument,
        showDocument: PrimarySheet5e.#showDocument
      }
    };

    /* -------------------------------------------- */

    /**
     * Sheet tabs.
     * @type {SheetTabDescriptor5e[]}
     */
    static TABS = [];

    /* -------------------------------------------- */

    /**
     * Available sheet modes.
     * @enum {number}
     */
    static MODES = {
      PLAY: 1,
      EDIT: 2
    };

    /* -------------------------------------------- */

    /**
     * Methods for sorting child embedded items.
     * @type {Record<string, ItemListComparator5e>}
     */
    static SORT_MODES = {
      a: this.sortItemsAlphabetical,
      m: this.sortItemsManual,
      p: this.sortItemsPriority
    };

    /* -------------------------------------------- */
    /*  Properties                                  */
    /* -------------------------------------------- */

    /**
     * Filters for applied inventory sections.
     * @type {Record<string, FilterState5e>}
     */
    _filters = {};

    /* -------------------------------------------- */

    /**
     * The mode the sheet is currently in.
     * @type {PrimarySheet5e.MODES|null}
     * @protected
     */
    _mode = null;

    /* -------------------------------------------- */
    /*  Rendering                                   */
    /* -------------------------------------------- */

    /** @inheritDoc */
    _configureRenderOptions(options) {
      super._configureRenderOptions(options);

      // Set initial mode
      let { mode, renderContext } = options;
      if ( (mode === undefined) && (renderContext === "createItem") ) mode = this.constructor.MODES.EDIT;
      this._mode = mode ?? this._mode ?? this.constructor.MODES.PLAY;
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    _configureRenderParts(options) {
      const parts = super._configureRenderParts(options);
      for ( const key of Object.keys(parts) ) {
        const tab = this.constructor.TABS.find(t => t.tab === key);
        if ( tab?.condition && !tab.condition(this.document) ) delete parts[key];
      }
      return parts;
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    async _renderFrame(options) {
      const html = await super._renderFrame(options);
      if ( !game.user.isGM && this.document.limited ) html.classList.add("limited");
      return html;
    }

    /* -------------------------------------------- */

    /**
     * Handle re-rendering the mode toggle on ownership changes.
     * @protected
     */
    _renderModeToggle() {
      const header = this.element.querySelector(".window-header");
      const toggle = header.querySelector(".mode-slider");
      if ( this.isEditable && !toggle ) {
        const toggle = document.createElement("slide-toggle");
        toggle.checked = this._mode === this.constructor.MODES.EDIT;
        toggle.classList.add("mode-slider");
        toggle.dataset.tooltip = "DND5E.SheetModeEdit";
        toggle.setAttribute("aria-label", game.i18n.localize("DND5E.SheetModeEdit"));
        toggle.addEventListener("change", this._onChangeSheetMode.bind(this));
        toggle.addEventListener("dblclick", event => event.stopPropagation());
        toggle.addEventListener("pointerdown", event => event.stopPropagation());
        header.prepend(toggle);
      } else if ( this.isEditable ) {
        toggle.checked = this._mode === this.constructor.MODES.EDIT;
      } else if ( !this.isEditable && toggle ) {
        toggle.remove();
      }
    }

    /* -------------------------------------------- */

    /**
     * Render source information in the Document's title bar.
     * @param {HTMLElement} html  The outer frame HTML.
     * @protected
     */
    _renderSourceFrame(html) {
      const elements = document.createElement("div");
      elements.classList.add("header-elements");
      elements.innerHTML = `
        <div class="source-book">
          <button type="button" class="unbutton control-button" data-action="showConfiguration" data-config="source"
                  data-tooltip aria-label="${game.i18n.localize("DND5E.SOURCE.Action.Configure")}">
            <i class="fas fa-cog" inert></i>
          </button>
          <span></span>
        </div>
      `;
      html.querySelector(".window-subtitle")?.after(elements);
    }

    /* -------------------------------------------- */

    /**
     * Update the source information when re-rendering the sheet.
     * @protected
     */
    _renderSource() {
      const elements = this.element.querySelector(".header-elements .source-book");
      const source = this.document?.system.source;
      if ( !elements || !source ) return;
      const editable = this.isEditable && (this._mode === this.constructor.MODES.EDIT);
      elements.querySelector("button")?.toggleAttribute("hidden", !editable);
      elements.querySelector("span").innerText = editable
        ? (source.label || game.i18n.localize("DND5E.SOURCE.FIELDS.source.label"))
        : source.label;
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    async _prepareContext(options) {
      const context = await super._prepareContext(options);
      context.owner = this.document.isOwner;
      context.locked = !this.isEditable;
      context.editable = this.isEditable && (this._mode === this.constructor.MODES.EDIT);
      context.tabs = this._getTabs();
      return context;
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    async _preparePartContext(partId, context, options) {
      context = await super._preparePartContext(partId, context, options);
      context.tab = context.tabs[partId];

      /**
       * A hook event that fires during preparation of sheet parts.
       * @function dnd5e.prepareSheetContext
       * @memberof hookEvents
       * @param {PrimarySheet5e} sheet  Sheet being rendered.
       * @param {string} partId         The ID of the part being prepared.
       * @param {object} context        Preparation context that should be mutated.
       * @param {object} options        Render options.
       */
      Hooks.callAll("dnd5e.prepareSheetContext", this, partId, context, options);

      return context;
    }

    /* -------------------------------------------- */

    /**
     * Prepare the tab information for the sheet.
     * @returns {Record<string, Partial<ApplicationTab>>}
     * @protected
     */
    _getTabs() {
      return this.constructor.TABS.reduce((tabs, { tab, condition, ...config }) => {
        if ( !condition || condition(this.document) ) tabs[tab] = {
          ...config,
          id: tab,
          group: "primary",
          active: this.tabGroups.primary === tab,
          cssClass: this.tabGroups.primary === tab ? "active" : ""
        };
        return tabs;
      }, {});
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    _toggleDisabled(disabled) {
      super._toggleDisabled(disabled);
      this.element.querySelectorAll(".always-interactive").forEach(input => input.disabled = false);
    }

    /* -------------------------------------------- */
    /*  Life-Cycle Handlers                         */
    /* -------------------------------------------- */

    /** @inheritDoc */
    async _onFirstRender(context, options) {
      await super._onFirstRender(context, options);
      if ( this.tabGroups.primary ) this.element.classList.add(`tab-${this.tabGroups.primary}`);

      // Create child button
      if ( this.isEditable ) {
        const button = document.createElement("button");
        button.type = "button";
        button.ariaLabel = game.i18n.localize("CONTROLS.CommonCreate");
        button.classList.add("create-child", "gold-button", "always-interactive");
        button.dataset.action = "addDocument";
        button.innerHTML = '<i class="fas fa-plus" inert></i>';
        this.element.querySelector(".window-content").append(button);
      }
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    async _onRender(context, options) {
      await super._onRender(context, options);
      this._renderSource();

      // Set toggle state and add status class to frame
      this._renderModeToggle();
      this.element.classList.toggle("editable", this.isEditable && (this._mode === this.constructor.MODES.EDIT));
      this.element.classList.toggle("interactable", this.isEditable && (this._mode === this.constructor.MODES.PLAY));
      this.element.classList.toggle("locked", !this.isEditable);

      // Add event listeners
      this.element.querySelectorAll(".item-tooltip").forEach(this._applyItemTooltips.bind(this));

      // Prevent inputs from firing drag listeners.
      this.element.querySelectorAll(".draggable input").forEach(el => {
        el.draggable = true;
        el.ondragstart = event => {
          event.preventDefault();
          event.stopPropagation();
        };
      });

      if ( this.isEditable ) {
        // Automatically select input contents when focused
        this.element.querySelectorAll("input").forEach(e => e.addEventListener("focus", e.select));
      }
    }

    /* -------------------------------------------- */
    /*  Event Listeners & Handlers                  */
    /* -------------------------------------------- */

    /**
     * Handle creating a new embedded child.
     * @param {Event} event         Triggering click event.
     * @param {HTMLElement} target  Button that was clicked.
     * @returns {any}
     * @protected
     * @abstract
     */
    _addDocument(event, target) {}

    /* -------------------------------------------- */

    /**
     * Initialize item tooltips on an element.
     * @param {HTMLElement} element  The tooltipped element.
     * @protected
     */
    _applyItemTooltips(element) {
      if ( "tooltip" in element.dataset ) return;
      const target = element.closest("[data-item-id], [data-effect-id], [data-uuid]");
      let uuid = target.dataset.uuid;
      if ( !uuid && target.dataset.itemId ) {
        const item = this.actor?.items.get(target.dataset.itemId);
        uuid = item?.uuid;
      } else if ( !uuid && target.dataset.effectId ) {
        const { effectId, parentId } = target.dataset;
        const collection = parentId ? this.actor?.items.get(parentId).effects : this.actor?.effects;
        uuid = collection?.get(effectId)?.uuid;
      }
      if ( !uuid ) return;
      element.dataset.tooltip = `
        <section class="loading" data-uuid="${uuid}"><i class="fas fa-spinner fa-spin-pulse"></i></section>
      `;
      element.dataset.tooltipClass = "dnd5e2 dnd5e-tooltip item-tooltip themed theme-light";
      element.dataset.tooltipDirection ??= "LEFT";
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    changeTab(tab, group, options) {
      super.changeTab(tab, group, options);
      if ( group !== "primary" ) return;
      this.element.className = this.element.className.replace(/tab-\w+/g, "");
      this.element.classList.add(`tab-${tab}`);
    }

    /* -------------------------------------------- */

    /**
     * Handle removing an document.
     * @this {PrimarySheet5e}
     * @param {Event} event         Triggering click event.
     * @param {HTMLElement} target  Button that was clicked.
     */
    static async #deleteDocument(event, target) {
      if ( await this._deleteDocument(event, target) === false ) return;
      const uuid = target.closest("[data-uuid]")?.dataset.uuid;
      const doc = await fromUuid(uuid);
      doc?.deleteDialog();
    }

    /* -------------------------------------------- */

    /**
     * Handle removing an document.
     * @param {Event} event         Triggering click event.
     * @param {HTMLElement} target  Button that was clicked.
     * @returns {any}               Return `false` to prevent default behavior.
     */
    async _deleteDocument(event, target) {}

    /* -------------------------------------------- */

    /**
     * Handle the user toggling the sheet mode.
     * @param {Event} event  The triggering event.
     * @protected
     */
    async _onChangeSheetMode(event) {
      const { MODES } = this.constructor;
      const toggle = event.currentTarget;
      const label = game.i18n.localize(`DND5E.SheetMode${toggle.checked ? "Play" : "Edit"}`);
      toggle.dataset.tooltip = label;
      toggle.setAttribute("aria-label", label);
      this._mode = toggle.checked ? MODES.EDIT : MODES.PLAY;
      await this.submit();
      this.render();
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    _onClickAction(event, target) {
      if ( target.dataset.action === "addDocument" ) this._addDocument(event, target);
      else super._onClickAction(event, target);
    }

    /* -------------------------------------------- */

    /** @override */
    _onRevealSecret(event) {
      if ( super._onRevealSecret(event) ) return;
      const target = event.target.closest("[data-target]")?.dataset.target;
      if ( !target ) return;
      const content = foundry.utils.getProperty(this.document, target);
      const modified = event.target.toggleRevealed(content);
      this.document.update({ [target]: modified });
    }

    /* -------------------------------------------- */

    /**
     * Handle opening a document sheet.
     * @this {PrimarySheet5e}
     * @param {Event} event         Triggering click event.
     * @param {HTMLElement} target  Button that was clicked.
     */
    static async #showDocument(event, target) {
      if ( await this._showDocument(event, target) === false ) return;
      if ( [HTMLInputElement, HTMLSelectElement, CheckboxElement].some(el => event.target instanceof el) ) return;
      const uuid = target.closest("[data-uuid]")?.dataset.uuid;
      const doc = await fromUuid(uuid);
      const mode = target.dataset.action === "showDocument" ? "PLAY" : "EDIT";
      doc?.sheet?.render({ force: true, mode: ItemSheet5e.MODES[mode] });
    }

    /* -------------------------------------------- */

    /**
     * Handle opening a document sheet.
     * @param {Event} event         Triggering click event.
     * @param {HTMLElement} target  Button that was clicked.
     * @returns {any}               Return `false` to prevent default behavior.
     */
    async _showDocument(event, target) {}

    /* -------------------------------------------- */
    /*  Sorting                                     */
    /* -------------------------------------------- */

    /**
     * Sort child embedded documents by the given sort mode.
     * @param {string} collection  The embedded collection name.
     * @param {string} mode        The sort mode.
     * @returns {Document[]}
     * @protected
     */
    _sortChildren(collection, mode) {
      return [];
    }

    /* -------------------------------------------- */

    /**
     * Sort Active Effects by the given sort mode.
     * @param {ActiveEffect5e[]} effects  The effects to sort.
     * @param {string} mode               The sort mode.
     * @returns {ActiveEffect5e[]}
     * @protected
     */
    _sortEffects(effects, mode) {
      return effects;
    }

    /* -------------------------------------------- */

    /**
     * Sort Items by the given sort mode.
     * @param {Item5e[]} items  The items to sort.
     * @param {string} mode     The sort mode.
     * @returns {Item5e[]}
     * @protected
     */
    _sortItems(items, mode) {
      const comparator = this.constructor.SORT_MODES[mode] ?? ((a, b) => a.sort - b.sort);
      return items.sort(comparator);
    }

    /* -------------------------------------------- */

    /**
     * Sort Items alphabetically.
     * @param {Item5e} a
     * @param {Item5e} b
     * @returns {number}
     */
    static sortItemsAlphabetical(a, b) {
      return a.name.localeCompare(b.name, game.i18n.lang);
    }

    /* -------------------------------------------- */

    /**
     * Sort Items the way the user arranged them.
     * @param {Item5e} a
     * @param {Item5e} b
     * @returns {number}
     */
    static sortItemsManual(a, b) {
      return a.sort - b.sort;
    }

    /* -------------------------------------------- */

    /**
     * Sort Items by priority
     * @param {Item5e} a
     * @param {Item5e} b
     * @returns {number}
     */
    static sortItemsPriority(a, b) {
      return a.system.linkedActivity?.item?.name.localeCompare(b.system.linkedActivity?.item?.name, game.i18n.lang)
        || ((a.system.level ?? 0) - (b.system.level ?? 0))
        || ((b.system.prepared ?? 0) - (a.system.prepared ?? 0))
        || (a.system.method ?? "").compare(b.system.method ?? "")
        || a.name.localeCompare(b.name, game.i18n.lang);
    }

    /* -------------------------------------------- */
    /*  Drag & Drop                                 */
    /* -------------------------------------------- */

    /** @override */
    _allowedDropBehaviors(event, data) {
      if ( !data?.uuid ) return new Set(["copy", "link"]);
      const allowed = new Set(["copy", "move", "link"]);
      const s = foundry.utils.parseUuid(data.uuid);
      const t = foundry.utils.parseUuid(this.document.uuid);
      const sCompendium = s.collection instanceof foundry.documents.collections.CompendiumCollection;
      const tCompendium = t.collection instanceof foundry.documents.collections.CompendiumCollection;

      // If either source or target are within a compendium, but not inside the same compendium, move not allowed
      if ( (sCompendium || tCompendium) && (s.collection !== t.collection) ) allowed.delete("move");

      return allowed;
    }

    /* -------------------------------------------- */

    /** @override */
    _defaultDropBehavior(event, data) {
      if ( !data?.uuid ) return "copy";
      const d = foundry.utils.parseUuid(data.uuid);
      const t = foundry.utils.parseUuid(this.document.uuid);
      const base = d.embedded?.length ? "document" : "primary";
      return (d.collection === t.collection) && (d[`${base}Id`] === t[`${base}Id`])
        && (d[`${base}Type`] === t[`${base}Type`]) ? "move" : "copy";
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    async _onDragStart(event) {
      await super._onDragStart(event);
      if ( !this.document.isOwner || this.document.collection?.locked ) {
        event.dataTransfer.effectAllowed = "copyLink";
      }
    }
  };
}

/**
 * Base application for configuring an actor's abilities, skills, or tools.
 */
class BaseProficiencyConfig extends BaseConfigSheet {
  /** @override */
  static DEFAULT_OPTIONS = {
    key: null,
    trait: null,
    position: {
      width: 500
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Configuration data for the ability being edited.
   * @type {object}
   * @abstract
   */
  get propertyConfig() {
    return {};
  }

  /* -------------------------------------------- */

  /**
   * Label for the specific skill or tool being configured.
   * @type {string}
   */
  get propertyLabel() {
    return keyLabel(this.options.key, { trait: this.options.trait });
  }

  /* -------------------------------------------- */

  /** @override */
  get title() {
    return game.i18n.format("DND5E.ABILITY.Configure.Title", { ability: this.propertyLabel });
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _initializeApplicationOptions(options) {
    options = super._initializeApplicationOptions(options);
    options.uniqueId = `${options.trait}-${options.key}-${options.document.uuid}`.replace(/\./g, "-");
    return options;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    const source = this.document.system._source;
    const keyPath = actorKeyPath(this.options.trait).replace("system.", "");

    context.data = foundry.utils.getProperty(source, `${keyPath}.${this.options.key}`) ?? {};
    context.fields = this.document.system.schema.getField(keyPath).model.fields;
    context.label = this.propertyLabel;
    context.prefix = `system.${keyPath}.${this.options.key}.`;

    if ( this.document.system.bonuses?.abilities ) context.global = {
      data: source.bonuses?.abilities ?? {},
      fields: this.document.system.schema.fields.bonuses.fields.abilities.fields
    };

    return context;
  }
}

/**
 * Configuration application for an actor's abilities.
 */
class AbilityConfig extends BaseProficiencyConfig {
  /** @override */
  static DEFAULT_OPTIONS = {
    trait: "saves"
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    config: {
      template: "systems/dnd5e/templates/actors/config/ability-config.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  get propertyConfig() {
    return CONFIG.DND5E.abilities[this.options.key];
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    context.proficiencyOptions = [
      { value: 0, label: CONFIG.DND5E.proficiencyLevels[0] },
      { value: 1, label: CONFIG.DND5E.proficiencyLevels[1] }
    ];
    context.checkLabel = game.i18n.format("DND5E.ABILITY.Configure.CheckLabel", { ability: context.label });
    context.saveLabel = game.i18n.format("DND5E.ABILITY.Configure.SaveLabel", { ability: context.label });
    return context;
  }
}

/**
 * Configuration application for armor class calculation.
 */
class ArmorClassConfig extends BaseConfigSheet {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["armor-class"],
    position: {
      width: 420
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    config: {
      template: "systems/dnd5e/templates/actors/config/armor-class-config.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  get title() {
    return game.i18n.localize("DND5E.ArmorClass");
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    context.data = this.document.system.attributes.ac;
    context.fields = this.document.system.schema.fields.attributes.fields.ac.fields;
    context.source = this.document.system._source.attributes.ac;

    context.calculationOptions = Object.entries(CONFIG.DND5E.armorClasses).reduce((arr, [value, config]) => {
      if ( value === "custom" ) arr.push({ rule: true });
      arr.push({ value, label: config.label });
      if ( value === "natural" ) arr.push({ rule: true });
      return arr;
    }, []);

    const config = CONFIG.DND5E.armorClasses[context.source.calc];
    context.formula = {
      disabled: context.source.calc !== "custom",
      showFlat: ["flat", "natural"].includes(context.source.calc),
      value: (context.source.calc === "custom" ? context.source.formula : config?.formula) ?? ""
    };

    if ( context.formula.value.includes("@attributes.ac.dex") ) context.dexterity = context.data.dex;

    context.calculations = [];
    if ( context.formula.value.includes("@attributes.ac.armor") ) {
      for ( const key of ["armor", "shield"] ) {
        const item = context.data[`equipped${key.capitalize()}`];
        if ( !item ) continue;
        const val = item.system.armor.value - (item.system.magicAvailable ? (item.system.armor.magicalBonus ?? 0) : 0);
        context.calculations.push({
          anchor: item.toAnchor().outerHTML,
          img: item.img,
          magicalBonus: item.system.properties.has("mgc")
            ? formatNumber(item.system.armor.magicalBonus, { signDisplay: "always" }) : "—",
          name: item.name,
          value: formatNumber(val, { signDisplay: key === "shield" ? "always" : "auto" })
        });
      }
    }
    if ( context.source.calc !== "flat" ) {
      for ( const bonus of this.document._prepareActiveEffectAttributions("system.attributes.ac.bonus") ) {
        if ( bonus.mode !== CONST.ACTIVE_EFFECT_MODES.ADD ) continue;
        context.calculations.push({
          anchor: bonus.document.toAnchor().outerHTML,
          img: bonus.document.img,
          magicalBonus: formatNumber(bonus.value, { signDisplay: "always" }),
          name: bonus.label,
          value: "—"
        });
      }
    }

    return context;
  }
}

/**
 * Configuration application for an actor's concentration checks.
 */
class ConcentrationConfig extends BaseConfigSheet {
  /** @override */
  static DEFAULT_OPTIONS = {
    ability: null,
    position: {
      width: 500
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    config: {
      template: "systems/dnd5e/templates/actors/config/concentration-config.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  get title() {
    return game.i18n.format("DND5E.ABILITY.Configure.Title", { ability: game.i18n.localize("DND5E.Concentration") });
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    const source = this.document.system._source;

    context.data = source.attributes?.concentration ?? {};
    context.fields = this.document.system.schema.fields.attributes.fields.concentration.fields;
    const ability = CONFIG.DND5E.abilities[CONFIG.DND5E.defaultAbilities.concentration]?.label?.toLowerCase();
    context.abilityOptions = [
      { value: "", label: ability ? game.i18n.format("DND5E.DefaultSpecific", { default: ability }) : "" },
      { rule: true },
      ...Object.entries(CONFIG.DND5E.abilities).map(([value, { label }]) => ({ value, label }))
    ];

    if ( this.document.system.bonuses?.abilities ) context.global = {
      data: source.bonuses?.abilities ?? {},
      fields: this.document.system.schema.fields.bonuses.fields.abilities.fields
    };

    return context;
  }
}

/**
 * Base application for selecting an actor's proficiencies.
 */
class TraitsConfig extends BaseConfigSheet {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["trait-selector"],
    trait: null,
    position: {
      width: 600
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    traits: {
      template: "systems/dnd5e/templates/actors/config/traits-config.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  get title() {
    return traitLabel(this.options.trait);
  }

  /* -------------------------------------------- */

  /**
   * Label used for the "other" category.
   * @type {string}
   */
  get otherLabel() {
    return game.i18n.localize("DND5E.ProficiencyOther");
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _initializeApplicationOptions(options) {
    options = super._initializeApplicationOptions(options);
    if ( !CONFIG.DND5E.traits[options.trait] ) throw new Error(
      `Cannot instantiate TraitsConfig with a trait not defined in CONFIG.DND5E.traits: ${options.trait}.`
    );
    options.uniqueId = `${options.trait}-${options.document.uuid}`.replace(/\./g, "-");
    return options;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    context.keyPath = actorKeyPath(this.options.trait);
    context.data = foundry.utils.getProperty(this.document._source, context.keyPath);
    context.checkbox = new foundry.data.fields.BooleanField();
    const chosen = new Set(
      filteredKeys(await actorValues(this.document, this.options.trait)).map(k => k.split(":").pop())
    );
    context.choices = await choices(this.options.trait, { chosen });
    context.fields = actorFields(this.document, this.options.trait);

    // Handle custom traits not in a top-level category
    const other = {
      label: this.otherLabel,
      children: new SelectChoices(),
      otherGroup: true
    };
    for ( const [key, choice] of Object.entries(context.choices) ) {
      if ( choice.children ) continue;
      other.children[key] = choice;
      delete context.choices[key];
    }
    if ( !other.children.isEmpty ) context.choices.OTHER = other;
    this._processChoices(context.data, context.choices);

    return context;
  }

  /* -------------------------------------------- */

  /**
   * Processes the choices to ensure that children are checked if the category is checked and that
   * masteries are only enabled if character has proficiency.
   * @param {object} data                     Traits data.
   * @param {SelectChoices} choices           Choices object.
   * @param {boolean} [categoryChosen=false]  Is the category above this one selected?
   * @protected
   */
  _processChoices(data, choices, categoryChosen=false) {
    for ( const [key, choice] of Object.entries(choices) ) {
      this._processChoice(data, key, choice, categoryChosen);
      if ( choice.children ) this._processChoices(data, choice.children, choice.chosen && (key !== "OTHER"));
    }
  }

  /* -------------------------------------------- */

  /**
   * Perform any modification on a choice.
   * @param {object} data                     Traits data.
   * @param {string} key                      Choice key.
   * @param {object} choice                   Data for the choice.
   * @param {boolean} [categoryChosen=false]  Is the category above this one selected?
   * @protected
   */
  _processChoice(data, key, choice, categoryChosen=false) {
    if ( (data.value?.includes?.("ALL") && (key !== "ALL")) || categoryChosen ) {
      choice.chosen = true;
      choice.disabled = true;
    }
  }

  /* -------------------------------------------- */
  /*  Form Submission                             */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _processFormData(event, form, formData) {
    const submitData = super._processFormData(event, form, formData);
    if ( !CONFIG.DND5E.traits[this.options.trait].dataType ) {
      this._filterData(submitData, `${actorKeyPath(this.options.trait)}.value`);
    }
    return submitData;
  }

  /* -------------------------------------------- */

  /**
   * Filter and order list of traits before submission.
   * @param {object} submitData  Form submission data.
   * @param {string} keyPath     Path to the trait to modify.
   * @protected
   */
  _filterData(submitData, keyPath) {
    foundry.utils.setProperty(submitData, keyPath, filteredKeys(
      foundry.utils.getProperty(submitData, keyPath) ?? {}
    ).sort((a, b) => a.localeCompare(b, "en")));
  }
}

/**
 * Configuration application for actor's damage resistances, immunities, and vulnerabilities.
 */
class DamagesConfig extends TraitsConfig {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["damages-config"]
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    traits: {
      template: "systems/dnd5e/templates/actors/config/damages-config.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  get otherLabel() {
    return game.i18n.localize("DND5E.DamageTypes");
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    context.bypasses = new SelectChoices(Object.entries(CONFIG.DND5E.itemProperties).reduce((obj, [k, v]) => {
      if ( v.isPhysical ) obj[k] = {
        label: v.label,
        chosen: context.data.bypasses.includes(k),
        icon: { src: k, svg: false }
      };
      return obj;
    }, {}));
    context.value = {};
    if ( this.options.trait === "dm" ) {
      context.choices.forEach((key, data) => data.chosen = context.data.amount[key] ?? "");
      context.bypassHint = "DND5E.DamageModification.BypassHint";
      context.hint = "DND5E.DamageModification.Hint";
      context.value.field = new FormulaField({ determinstic: true });
      context.value.key = "amount";
    } else {
      context.bypassHint = "DND5E.DamagePhysicalBypassHint";
      context.value.field = context.checkbox;
      context.value.input = context.inputs.createCheckboxInput;
      context.value.key = "value";
    }
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _processChoice(data, key, choice, categoryChosen=false) {
    super._processChoice(data, key, choice, categoryChosen);
    const config = CONFIG.DND5E.damageTypes[key];
    if ( config ) choice.icon = { src: config.icon, svg: config.icon.endsWith(".svg") };
  }

  /* -------------------------------------------- */
  /*  Form Submission                             */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _processFormData(event, form, formData) {
    const submitData = super._processFormData(event, form, formData);
    if ( this.options.trait === "dm" ) {
      for ( const [type, formula] of Object.entries(submitData.system?.traits?.dm?.amount ?? {}) ) {
        if ( !formula ) {
          delete submitData.system.traits.dm.amount[type];
          submitData.system.traits.dm.amount[`-=${type}`] = null;
        }
      }
    }
    this._filterData(submitData, `${actorKeyPath(this.options.trait)}.bypasses`);
    return submitData;
  }
}

/**
 * Configuration application for an actor's death saving throws.
 */
class DeathConfig extends BaseConfigSheet {
  /** @override */
  static DEFAULT_OPTIONS = {
    ability: null,
    position: {
      width: 500
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    config: {
      template: "systems/dnd5e/templates/actors/config/death-config.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  get title() {
    return game.i18n.localize("DND5E.DeathSaveConfigure");
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    const source = this.document.system._source;

    context.data = source.attributes?.death ?? {};
    context.fields = this.document.system.schema.getField("attributes.death").fields;

    if ( this.document.system.bonuses?.abilities ) context.global = {
      data: source.bonuses?.abilities ?? {},
      fields: this.document.system.schema.getField("bonuses.abilities").fields
    };

    return context;
  }
}

/**
 * Configuration application for adjusting hit dice amounts and rolling.
 */
class HitDiceConfig extends BaseConfigSheet {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["hit-dice"],
    actions: {
      decrease: HitDiceConfig.#stepValue,
      increase: HitDiceConfig.#stepValue,
      roll: HitDiceConfig.#rollDie
    },
    position: {
      width: 420
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    config: {
      template: "systems/dnd5e/templates/actors/config/hit-dice-config.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  get title() {
    return game.i18n.localize("DND5E.HitDice");
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    context.classes = Array.from(this.document.system.attributes?.hd?.classes ?? []).map(cls => ({
      data: { ...cls.system.hd },
      denomination: Number(cls.system.hd.denomination.slice(1)),
      id: cls.id,
      label: `${cls.name} (${cls.system.hd.denomination})`
    })).sort((lhs, rhs) => rhs.denomination - lhs.denomination);
    return context;
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle rolling a specific hit die.
   * @this {HitDiceConfig}
   * @param {PointerEvent} event  The triggering click event.
   * @param {HTMLElement} target  The button that was clicked.
   */
  static async #rollDie(event, target) {
    await this.document.rollHitDie({ denomination: target.dataset.denomination });
    this.render();
  }

  /* -------------------------------------------- */

  /**
   * Handle stepping a hit die count up or down.
   * @this {HitDiceConfig}
   * @param {PointerEvent} event  The triggering click event.
   * @param {HTMLElement} target  The button that was clicked.
   */
  static #stepValue(event, target) {
    const valueField = target.closest(".form-group").querySelector("input");
    if ( target.dataset.action === "increase" ) valueField?.stepUp();
    else valueField?.stepDown();
    this.submit();
  }

  /* -------------------------------------------- */
  /*  Form Submission                             */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _processFormData(event, form, formData) {
    if ( form.reportValidity() ) {
      const submitData = super._processFormData(event, form, formData);
      const classUpdates = Object.entries(submitData).map(([_id, value]) => ({
        _id, "system.hd.spent": this.document.items.get(_id).system.levels - value
      }));
      this.document.updateEmbeddedDocuments("Item", classUpdates);
    }
    return {};
  }
}

/**
 * Configuration application for hit point bonuses and current values.
 */
class HitPointsConfig extends BaseConfigSheet {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["hit-points"],
    actions: {
      roll: HitPointsConfig.#rollFormula
    },
    position: {
      width: 420
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    config: {
      template: "systems/dnd5e/templates/actors/config/hit-points-config.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  get title() {
    return game.i18n.localize("DND5E.HitPoints");
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    context.data = this.document.system.attributes.hp;
    context.fields = this.document.system.schema.fields.attributes.fields.hp.fields;
    context.source = this.document.system._source.attributes.hp;

    // Display positive ability modifier as its own row, but if negative merge into classes totals
    const ability = CONFIG.DND5E.abilities[CONFIG.DND5E.defaultAbilities.hitPoints];
    const mod = this.document.system.abilities?.[CONFIG.DND5E.defaultAbilities.hitPoints]?.mod ?? 0;
    if ( ability && (mod > 0) ) context.ability = { mod, name: ability.label };

    // Summarize HP from classes
    context.classes = Object.values(this.document.classes).map(cls => ({
      id: cls.id,
      anchor: cls.toAnchor().outerHTML,
      name: cls.name,
      total: cls.advancement.byType.HitPoints?.[0]?.[mod > 0 ? "total" : "getAdjustedTotal"](mod) ?? 0
    })).sort((lhs, rhs) => rhs.name - lhs.name);

    // Display active effects targeting bonus fields
    context.effects = {
      bonuses: this.document._prepareActiveEffectAttributions("system.attributes.hp.bonuses.level"),
      max: this.document._prepareActiveEffectAttributions("system.attributes.hp.max"),
      overall: this.document._prepareActiveEffectAttributions("system.attributes.hp.bonuses.overall")
    };
    for ( const [key, value] of Object.entries(context.effects) ) {
      context.effects[key] = value
        .filter(e => e.mode === CONST.ACTIVE_EFFECT_MODES.ADD)
        .map(e => ({ ...e, anchor: e.document.toAnchor().outerHTML}));
    }

    context.levels = this.document.system.details?.level ?? 0;
    context.levelMultiplier = `
      <span class="multiplier"><span class="times">&times;</span> ${formatNumber(context.levels)}</span>
    `;
    context.showCalculation = context.classes.length || context.fields.bonuses;
    context.showMaxInCalculation = context.showCalculation && (this.document.type === "npc");

    // Additional fields
    context.otherFields = ["dt", "mt"].flatMap(p => {
      return p in context.fields ? { field: context.fields[p], value: context.source[p] } : [];
    });

    return context;
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle rolling NPC health values using the provided formula.
   * @this {HitPointsConfig}
   * @param {PointerEvent} event  The triggering click event.
   * @param {HTMLElement} target  The button that was clicked.
   * @protected
   */
  static async #rollFormula(event, target) {
    try {
      const roll = await this.document.rollNPCHitPoints();
      this.submit({ updateData: { "system.attributes.hp.max": roll.total } });
    } catch(error) {
      ui.notifications.error("DND5E.HPFormulaError", { localize: true });
      throw error;
    }
  }

  /* -------------------------------------------- */
  /*  Form Submission                             */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _processSubmitData(event, form, submitData) {
    const clone = this.document.clone(foundry.utils.deepClone(submitData));
    const { value, max } = this.document.system.attributes.hp;
    const maxDelta = clone.system.attributes.hp.max - max;
    const current = submitData.system.attributes.hp.value ?? value;
    foundry.utils.setProperty(submitData, "system.attributes.hp.value", Math.max(current + maxDelta, 0));
    super._processSubmitData(event, form, submitData);
  }
}

const { BooleanField: BooleanField$e } = foundry.data.fields;

/**
 * Configuration application for an actor's initiative.
 */
class InitiativeConfig extends BaseConfigSheet {

  /** @override */
  static DEFAULT_OPTIONS = {
    position: {
      width: 420
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    config: {
      template: "systems/dnd5e/templates/actors/config/initiative-config.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  get title() {
    return game.i18n.localize("DND5E.Initiative");
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    const source = this.document._source;

    const defaultAbility = CONFIG.DND5E.abilities[CONFIG.DND5E.defaultAbilities.initiative];
    context.abilityOptions = [
      { value: "", label: game.i18n.format("DND5E.DefaultSpecific", { default: defaultAbility.label.toLowerCase() }) },
      { rule: true },
      ...Object.entries(CONFIG.DND5E.abilities).map(([value, { label }]) => ({ value, label }))
    ];
    context.data = source.system.attributes.init;
    context.fields = this.document.system.schema.fields.attributes.fields.init.fields;

    const ability = this.document.system.attributes.init.ability || CONFIG.DND5E.defaultAbilities.initiative;
    const abilityConfig = CONFIG.DND5E.abilities[ability];
    context.ability = {
      label: game.i18n.format("DND5E.AbilityCheckConfigure", { ability: abilityConfig.label }),
      global: {
        field: this.document.system.schema.fields.bonuses?.fields.abilities.fields.check,
        name: "system.bonuses.abilities.check",
        value: source.system.bonuses?.abilities.check
      },
      local: {
        field: this.document.system.schema.fields.abilities.model.fields.bonuses.fields.check,
        name: `system.abilities.${ability}.bonuses.check`,
        value: source.system.abilities[ability]?.bonuses.check
      }
    };

    context.flags = {
      alert: {
        field: new BooleanField$e({ label: game.i18n.localize("DND5E.FlagsAlert") }),
        name: "flags.dnd5e.initiativeAlert",
        value: source.flags.dnd5e?.initiativeAlert
      }
    };

    return context;
  }
}

/**
 * Configuration application for languages.
 */
class LanguagesConfig extends TraitsConfig {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["languages"],
    trait: "languages"
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    traits: {
      template: "systems/dnd5e/templates/actors/config/languages-config.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  get title() {
    return game.i18n.localize("DND5E.Languages");
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);

    const unitOptions = Object.entries(CONFIG.DND5E.movementUnits).map(([value, { label }]) => ({ value, label }));
    context.communication = Object.entries(CONFIG.DND5E.communicationTypes).map(([key, { label }]) => ({
      label, unitOptions,
      data: context.data.communication[key],
      fields: context.fields.communication.model.fields,
      keyPath: `${context.keyPath}.communication.${key}`
    }));

    return context;
  }
}

/**
 * @import { SkillConfiguration, ToolConfiguration } from "../../../_types.mjs";
 */

/**
 * Configuration application for an actor's skills & tools.
 */
class SkillToolConfig extends BaseProficiencyConfig {

  /** @override */
  static PARTS = {
    config: {
      template: "systems/dnd5e/templates/actors/config/skill-tool-config.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Configuration data for the ability being edited.
   * @type {SkillConfiguration|ToolConfiguration}
   */
  get propertyConfig() {
    return CONFIG.DND5E[this.options.trait === "skills" ? "skills" : "tools"][this.options.key];
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    context.abilityOptions = Object.entries(CONFIG.DND5E.abilities).map(([value, { label }]) => ({ value, label }));
    context.proficiencyOptions = Object.entries(CONFIG.DND5E.proficiencyLevels)
      .map(([value, label]) => ({ value, label }));
    context.section = `DND5E.${this.options.trait === "skills" ? "SKILL" : "TOOL"}.SECTIONS.`;
    context.global.skill = this.options.trait === "skills";
    return context;
  }
}

/**
 * Configuration application for actor's skills.
 */
class SkillsConfig extends TraitsConfig {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["skills"],
    trait: "skills",
    actions: {
      configure: SkillsConfig.#configureSkill
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    traits: {
      template: "systems/dnd5e/templates/actors/config/skills-config.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    context.skills = context.choices.OTHER.children;
    context.rows = Math.ceil(Object.keys(context.skills).length / 2);
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _processChoice(data, key, choice, categoryChosen=false) {
    super._processChoice(data, key, choice, categoryChosen);
    const skill = data[key];
    if ( skill ) {
      choice.value = skill.value;
      choice.total = this.document.system.skills[key]?.total;
      choice.tooltip = CONFIG.DND5E.proficiencyLevels[skill.value];
    }
  }

  /* -------------------------------------------- */
  /*  Life-Cycle Handlers                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onRender(context, options) {
    super._onRender(context, options);
    this.element.querySelectorAll("proficiency-cycle").forEach(e => {
      e.addEventListener("change", event => this.submit());
    });
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Open the configuration sheet for a skill.
   * @this {SkillsConfig}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static async #configureSkill(event, target) {
    const { key } = target.closest("[data-key]").dataset;
    new SkillToolConfig({ document: this.document, trait: "skills", key }).render(true);
  }
}

/**
 * Configuration application for overriding actor spell slots.
 */
class SpellSlotsConfig extends BaseConfigSheet {
  /** @override */
  static DEFAULT_OPTIONS = {
    position: {
      width: 450
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    config: {
      template: "systems/dnd5e/templates/actors/config/spell-slots-config.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  get title() {
    return game.i18n.localize("DND5E.SpellSlotsConfig");
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);

    const { spells } = this.document.system;
    const source = this.document._source.system.spells;
    const maxLevel = Object.keys(CONFIG.DND5E.spellLevels).length - 1;
    const spellcastingMethods = new Set([
      "spell",
      ...Object.values(this.document.spellcastingClasses).map(cls => {
        return cls.system.spellcasting.type;
      }),
      ...this.document.itemTypes.spell.map(s => s.system.method)
    ]);

    const models = Object.entries(CONFIG.DND5E.spellcasting).sort(([a]) => a === "spell" ? -1 : 0);
    context.overrides = models.reduce((arr, [method, model]) => {
      if ( !model.slots ) return arr;
      for ( let i = model.isSingleLevel ? maxLevel : 1; i <= maxLevel; i++ ) {
        const key = model.getSpellSlotKey(i);
        const value = source[key]?.override;
        if ( !value && !spellcastingMethods.has(method) ) continue;
        arr.push({
          value,
          label: model.isSingleLevel
            ? game.i18n.localize(`DND5E.SPELLCASTING.METHODS.${method.capitalize()}.abbr`)
            : model.getLabel({ level: i }),
          name: `system.spells.${key}.override`,
          placeholder: spells[key]?.max ?? 0
        });
      }
      return arr;
    }, []);

    return context;
  }
}

/**
 * Configuration application for actor's tools.
 */
class ToolsConfig extends TraitsConfig {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["trait-columns", "tools"],
    trait: "tool",
    actions: {
      configure: ToolsConfig.#configureTool
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    traits: {
      template: "systems/dnd5e/templates/actors/config/tools-config.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _processChoice(data, key, choice, categoryChosen=false) {
    super._processChoice(data, key, choice, categoryChosen);
    const tool = data[key];
    if ( tool ) {
      choice.hasEntry = true;
      choice.value = tool.value;
      choice.total = this.document.system.tools[key]?.total;
    }
    choice.tooltip = CONFIG.DND5E.proficiencyLevels[tool?.value ?? 0];
  }

  /* -------------------------------------------- */
  /*  Life-Cycle Handlers                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onRender(context, options) {
    super._onRender(context, options);
    this.element.querySelectorAll("proficiency-cycle").forEach(e => {
      e.addEventListener("change", event => this.submit());
    });
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Open the configuration sheet for a tool.
   * @this {ToolsConfig}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static async #configureTool(event, target) {
    const { key } = target.closest("[data-key]").dataset;
    new SkillToolConfig({ document: this.document, trait: this.options.trait, key }).render(true);
  }

  /* -------------------------------------------- */
  /*  Form Submission                             */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _processFormData(event, form, formData) {
    const submitData = Object.entries(formData.object).reduce((obj, [k, value]) => {
      const key = k.split(".")[2];
      const tool = this.document.system._source.tools[key];
      const config = CONFIG.DND5E.tools[key];
      if ( tool && !value ) obj[`system.tools.-=${key}`] = null;
      else if ( !tool && value ) obj[`system.tools.${key}`] = { value, ability: config?.ability || "int" };
      else if ( value ) obj[`system.tools.${key}.value`] = value;
      return obj;
    }, {});
    return foundry.utils.expandObject(submitData);
  }
}

/**
 * Configuration application for weapon proficiencies and masteries.
 */
class WeaponsConfig extends TraitsConfig {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["weapons"],
    trait: "weapon"
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    traits: {
      template: "systems/dnd5e/templates/actors/config/weapons-config.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  get title() {
    return game.i18n.localize("TYPES.Item.weaponPl");
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _processChoice(data, key, choice, categoryChosen=false) {
    super._processChoice(data, key, choice, categoryChosen);
    choice.mastery = {
      chosen: data.mastery.value?.includes(key),
      disabled: !choice.chosen
    };
  }

  /* -------------------------------------------- */
  /*  Form Submission                             */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _processFormData(event, form, formData) {
    const submitData = super._processFormData(event, form, formData);
    this._filterData(submitData, `${actorKeyPath(this.options.trait)}.mastery.value`);
    return submitData;
  }
}

/**
 * Dialog that controls transforming an actor using another actor.
 */
class TransformDialog extends Dialog5e {
  constructor(options) {
    super(options);
    this.#settings = this.options.transform.settings?.clone() ?? new TransformationSetting();
  }

  /* -------------------------------------------- */

  /** @override */
  static DEFAULT_OPTIONS = {
    actions: {
      setPreset: TransformDialog.#setPreset
    },
    buttons: [{
      action: "submit",
      icon: "fa-solid fa-check",
      label: "DND5E.TRANSFORM.Action.Transform",
      type: "submit"
    }],
    classes: ["transformation"],
    form: {
      handler: TransformDialog.#handleFormSubmission,
      submitOnChange: true
    },
    position: {
      width: 1000
    },
    transform: {
      host: null,
      settings: null,
      source: null
    },
    window: {
      title: "DND5E.TRANSFORM.Dialog.Title",
      icon: "fa-solid fa-arrow-right-arrow-left",
      minimizable: true
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    details: {
      template: "systems/dnd5e/templates/apps/transform-details.hbs"
    },
    presets: {
      container: { id: "settings-area" },
      template: "systems/dnd5e/templates/apps/transform-presets.hbs"
    },
    settings: {
      container: { id: "settings-area" },
      template: "systems/dnd5e/templates/apps/transform-settings.hbs"
    },
    footer: {
      template: "templates/generic/form-footer.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Settings that should be applied during transformation.
   * @param {TransformationSetting|null}
   */
  #settings = null;

  get settings() {
    return this.#settings;
  }

  /* -------------------------------------------- */

  /**
   * Was the transform button clicked?
   * @type {boolean}
   */
  #shouldTransform = false;

  get shouldTransform() {
    return this.#shouldTransform;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = { ...(await super._preparePartContext(partId, context, options)) };
    if ( partId === "details" ) return this._prepareDetailsContext(context, options);
    if ( partId === "presets" ) return this._preparePresetsContext(context, options);
    if ( partId === "settings" ) return this._prepareSettingsContext(context, options);
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare rendering context for the details section.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {Promise<ApplicationRenderContext>}
   * @protected
   */
  async _prepareDetailsContext(context, options) {
    context.sourceActor = {
      artwork: (await this.options.transform.source.getTokenImages())[0],
      name: this.options.transform.source.name
    };
    context.hostActor = {
      artwork: (await this.options.transform.host.getTokenImages())[0],
      name: this.options.transform.host.name
    };
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare rendering context for the presets section.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {Promise<ApplicationRenderContext>}
   * @protected
   */
  async _preparePresetsContext(context, options) {
    context.presets = Object.entries(CONFIG.DND5E.transformation.presets).reduce((obj, [key, config]) => {
      obj[key] = {
        ...config,
        selected: this.#settings.preset === key
      };
      return obj;
    }, {});
    context.noneSelected = !this.#settings.preset;
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare rendering context for the settings section.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {Promise<ApplicationRenderContext>}
   * @protected
   */
  async _prepareSettingsContext(context, options) {
    context.categories = this.#settings.createFormCategories({ host: this.options.transform.host });
    return context;
  }

  /* -------------------------------------------- */
  /*  Life-Cycle Handlers                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onRender(context, options) {
    super._onRender(context, options);
    this.#disableFields();
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Disable inputs based on other checked fields.
   * @param {CheckboxElement} [changed]  The recently changed checkbox, if any.
   */
  #disableFields(changed) {
    const handleDisable = field => {
      if ( field.disabled ) return;
      const config = foundry.utils.getProperty(CONFIG.DND5E.transformation, field.name);
      if ( !config?.disables?.length ) return;
      const names = config.disables.map(d => d.includes("*") ? `[name^="${d.replace("*", "")}"]` : `[name="${d}"]`);
      const selector = `:is(${names.join(",")}):not([name="${field.name}"])`;
      this.element.querySelectorAll(selector).forEach(element => {
        element.disabled = field.value;
        if ( element.disabled && element.tagName === "DND5E-CHECKBOX" ) element.checked = false;
        else if ( element.disabled ) element.value = "";
      });
    };
    if ( changed ) handleDisable(changed);
    else this.element.querySelectorAll("dnd5e-checkbox").forEach(e => handleDisable(e));
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onChangeForm(formConfig, event) {
    if ( event.target?.tagName === "DND5E-CHECKBOX" ) this.#disableFields(event.target);
    super._onChangeForm(formConfig, event);
  }

  /* -------------------------------------------- */

  /**
   * Handle applying a preset to the settings.
   * @this {TransformDialog}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static async #setPreset(event, target) {
    const preset = CONFIG.DND5E.transformation.presets[target.dataset.preset];
    if ( preset ) this.#settings = new TransformationSetting({
      ...preset.settings,
      preset: target.dataset.preset,
      transformTokens: this.element.querySelector('[name="transformTokens"]').checked
    });
    else this.#settings = new TransformationSetting();
    this.render({ parts: ["presets", "settings"] });
  }

  /* -------------------------------------------- */
  /*  Form Handling                               */
  /* -------------------------------------------- */

  /**
   * Handle updating the preset display when fields are changes and transforming when the submit button is pressed.
   * @this {TransformDialog}
   * @param {Event|SubmitEvent} event    The form submission event.
   * @param {HTMLFormElement} form       The submitted form.
   * @param {FormDataExtended} formData  Data from the dialog.
   */
  static async #handleFormSubmission(event, form, formData) {
    const data = foundry.utils.expandObject(formData.object);
    for ( const category of ["keep", "merge", "effects", "other"] ) {
      this.#settings.updateSource({ [category]: filteredKeys(data[category] ?? {}) });
      delete data[category];
    }
    this.#settings.updateSource(data);
    if ( event.type === "submit" ) {
      this.#shouldTransform = true;
      this.close();
    }
  }

  /* -------------------------------------------- */
  /*  Factory Methods                             */
  /* -------------------------------------------- */

  /**
   * Display the transform dialog.
   * @param {Actor5e} host                           Actor that will be transformed.
   * @param {Actor5e} source                         Actor whose data will be applied to the host.
   * @param {object} [options={}]                    Additional options for the application.
   * @returns {Promise<TransformationSetting|null>}  Transformation settings to apply.
   */
  static async promptSettings(host, source, options={}) {
    return new Promise(resolve => {
      options.transform ??= {};
      options.transform.host = host;
      options.transform.source = source;
      const dialog = new this(options);
      dialog.addEventListener("close", event =>
        resolve(dialog.shouldTransform ? dialog.settings : null)
      , { once: true });
      dialog.render({ force: true });
    });
  }
}

class FilterMenu extends foundry.applications.ux.ContextMenu {
  /**
   * Handle applying a filter.
   * @param {ItemListControlsElement} controls  The parent list controls element.
   * @param {PointerEvent} event                The triggering event.
   */
  #onClickItem(controls, event) {
    event.preventDefault();
    event.stopPropagation();
    const { filter } = event.target.closest("[data-filter]")?.dataset ?? {};
    if ( !filter ) return;
    const { state } = controls;
    const { properties } = state;
    if ( properties.has(filter) ) properties.delete(filter);
    else properties.add(filter);
    this.#renderEntries(controls);
    controls._applyFilters();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async render(target, options={}) {
    await super.render(target, options);
    this.element.classList.add("dnd5e2");
    const controls = options.event?.currentTarget;
    if ( !(controls instanceof ItemListControlsElement) ) return;
    this.#renderEntries(controls);
    this._setPosition(this.element, target, options);
    game.tooltip.deactivate();
    if ( options.animate !== false ) await this._animate(true);
    return this._onRender(options);
  }

  /* -------------------------------------------- */

  /**
   * Render the filter menu's entries.
   * @param {ItemListControlsElement} controls  The parent list controls element.
   */
  #renderEntries(controls) {
    const { filters, state } = controls;
    if ( foundry.utils.isEmpty(filters) ) return;
    const menu = document.createElement("menu");
    menu.classList.add("context-items");
    for ( const [filter, label] of Object.entries(filters) ) {
      const item = document.createElement("li");
      item.dataset.filter = filter;
      item.classList.add("context-item", "filter-item", "always-interactive");
      item.classList.toggle("active", state.properties.has(filter));
      const span = document.createElement("span");
      span.append(label);
      item.append(span);
      menu.append(item);
    }
    menu.addEventListener("click", this.#onClickItem.bind(this, controls));
    this.element.replaceChildren(menu);
  }
}

const MaybeAdoptable$3 = foundry.applications.elements.AdoptableHTMLElement ?? HTMLElement;

/**
 * @import { TabPreferences5e } from "../../data/user/_types.mjs";
 * @import { FilterState5e, ListControlConfiguration, ListControlDescriptor } from "./_types.mjs";
 */

/**
 * A custom element that encapsulates functionality for sorting, filtering, searching, and grouping lists of items.
 */
class ItemListControlsElement extends MaybeAdoptable$3 {
  /* -------------------------------------------- */
  /*  Configuration                               */
  /* -------------------------------------------- */

  /**
   * Well-known controls configurations.
   * @type {Record<string, ListControlConfiguration>}
   */
  static CONFIG = {
    inventory: {
      label: "DND5E.InventorySearch",
      list: "inventory",
      filters: [
        { key: "action", label: "DND5E.Action" },
        { key: "bonus", label: "DND5E.BonusAction" },
        { key: "reaction", label: "DND5E.Reaction" },
        { key: "equipped", label: "DND5E.Equipped" },
        { key: "mgc", label: "DND5E.ITEM.Property.Magical" }
      ],
      sorting: [
        { key: "m", label: "SIDEBAR.SortModeManual", dataset: { icon: "fa-solid fa-arrow-down-short-wide" } },
        { key: "a", label: "SIDEBAR.SortModeAlpha", dataset: { icon: "fa-solid fa-arrow-down-a-z" } }
      ],
      grouping: [
        {
          key: "type",
          label: "DND5E.FilterGroupCategory",
          dataset: { icon: "fa-solid fa-layer-group", classes: "active" }
        },
        { key: "contents", label: "DND5E.FilterGroupCategory", dataset: { icon: "fa-solid fa-layer-group" } }
      ]
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The HTML tag named used by this element.
   * @type {string}
   */
  static tagName = "item-list-controls";

  /* -------------------------------------------- */

  /**
   * The amount of time to wait after a user's keypress before the name search filter is applied, in milliseconds.
   * @type {number}
   */
  static FILTER_DEBOUNCE_MS = 200;

  /* -------------------------------------------- */

  /**
   * The Application instance that houses this item control.
   * @type {FormApplication}
   */
  get app() {
    return this.#app;
  }

  #app;

  /* -------------------------------------------- */

  /**
   * The configured filtering options.
   * @type {Record<string, string>}
   */
  get filters() {
    return this.#filters;
  }

  #filters;

  /* -------------------------------------------- */

  /**
   * The configured grouping modes.
   * @type {Record<string, ListControlDescriptor>}
   */
  #groups;

  /* -------------------------------------------- */

  /**
   * The managing inventory element.
   * @type {InventoryElement}
   */
  #inventory;

  /* -------------------------------------------- */

  /**
   * The list element that this element manages.
   * @type {HTMLElement}
   */
  get list() {
    return this.#list;
  }

  #list;

  /* -------------------------------------------- */

  /**
   * The configured sort modes.
   * @type {Record<string, ListControlDescriptor>}
   */
  #modes;

  /* -------------------------------------------- */

  /**
   * The current filter state.
   * @type {FilterState5e}
   */
  get state() {
    return this.#state;
  }

  #state;

  /* -------------------------------------------- */

  /**
   * The tab this element is part of.
   * @type {string}
   */
  get tab() {
    return this.#tab;
  }

  #tab;

  /* -------------------------------------------- */

  /**
   * The search input.
   * @type {HTMLInputElement}
   * @protected
   */
  _inputElement;

  /* -------------------------------------------- */

  /**
   * The individual filtering controls.
   * @type {Record<string, HTMLButtonElement>}
   * @protected
   */
  _controls;

  /* -------------------------------------------- */

  /**
   * The user's preferences for this tab.
   * @type {TabPreferences5e}
   */
  get prefs() {
    return game.user.getFlag("dnd5e", `sheetPrefs.${this.app.document.type}.tabs.${this.tab}`);
  }

  /* -------------------------------------------- */

  /**
   * Whether to keep empty sections visible.
   * @type {boolean}
   */
  get keepEmpty() {
    return this.hasAttribute("keep-empty");
  }

  /* -------------------------------------------- */
  /*  Lifecycle                                   */
  /* -------------------------------------------- */

  /** @override */
  connectedCallback() {
    if ( this.#app ) return;
    this.#app = foundry.applications.instances.get(this.closest(".application")?.id);
    const element = this.#app.element;
    this.#list = element.querySelector(`[data-item-list="${this.getAttribute("for")}"]`);
    this.#state = this.#app._filters[this.getAttribute("for")] ??= { name: "", properties: new Set() };
    this.#tab = this.closest(".tab")?.dataset.tab;
    this.#inventory = this.closest(this.#app.options.elements.inventory);

    this.#buildHTML();

    const debouncedFilter = foundry.utils.debounce(this._onFilterName.bind(this), this.constructor.FILTER_DEBOUNCE_MS);
    this._inputElement.addEventListener("input", debouncedFilter);
    this._controls.clear.addEventListener("click", this._onClearFilters.bind(this));
    this._controls.sort?.addEventListener("click", this._onCycleMode.bind(this));
    this._controls.group?.addEventListener("click", this._onCycleMode.bind(this));
    new FilterMenu(this, '.filter-control[data-action="filter"]', [], {
      eventName: "click", jQuery: false, fixed: true
    });

    this._initGrouping();
    this._initSorting();
    this._applyGrouping();
  }

  /* -------------------------------------------- */
  /*  Initialization                              */
  /* -------------------------------------------- */

  /**
   * Construct the element's internal markup.
   */
  #buildHTML() {
    const search = document.createElement("search");
    search.ariaLabel = this.getAttribute("label");
    search.innerHTML = `
      <input type="text" class="always-interactive" placeholder="${this.getAttribute("label")}">
      <ul class="unlist controls">
        <li>
          <button type="button" class="unbutton filter-control always-interactive" data-action="clear"
                  data-tooltip aria-label="${game.i18n.localize("DND5E.FilterClear")}">
            <i class="fas fa-xmark"></i>
          </button>
        </li>
      </ul>
    `;

    const controls = search.querySelector(".controls");

    // Filtering
    this.#filters = Array.from(this.querySelectorAll('[data-list="filters"] option')).reduce((obj, opt) => {
      obj[opt.value] = opt.innerText;
      return obj;
    }, {});
    if ( !foundry.utils.isEmpty(this.#filters) ) {
      const item = document.createElement("li");
      item.innerHTML = `
        <button type="button" class="unbutton filter-control filter always-interactive" data-action="filter"
                aria-label="${game.i18n.localize("DND5E.Filter")}">
          <i class="fa-solid fa-filter" inert></i>
        </button>
      `;
      controls.append(item);
    }

    // Sorting
    this.#modes = this.#parseControlOptions("sort");
    if ( !foundry.utils.isEmpty(this.#modes) ) {
      const item = document.createElement("li");
      item.innerHTML = `
        <button type="button" class="unbutton filter-control active always-interactive" data-action="sort">
          <i inert></i>
        </button>
      `;
      controls.append(item);
    }

    // Grouping
    this.#groups = this.#parseControlOptions("group");
    if ( !foundry.utils.isEmpty(this.#groups) ) {
      const item = document.createElement("li");
      item.innerHTML = `
        <button type="button" class="unbutton filter-control always-interactive" data-action="group">
          <i inert></i>
        </button>
      `;
      controls.append(item);
    }

    this._inputElement = search.querySelector(":scope > input");
    if ( this.state?.name ) this._inputElement.value = this.state.name;
    this._controls = Array.from(search.querySelectorAll(".filter-control")).reduce((obj, el) => {
      obj[el.dataset.action] = el;
      return obj;
    }, {});
    this.replaceChildren(search);
  }

  /* -------------------------------------------- */

  /**
   * Initialize controls based on grouping preferences.
   * @protected
   */
  _initGrouping() {
    const { group } = this._controls;
    if ( !group ) return;
    let key = this.prefs?.group;
    if ( !(key in this.#groups) ) key = Object.keys(this.#groups)[0];
    const { label, icon, classes } = this.#groups[key] ?? {};
    group.ariaLabel = label;
    group.dataset.tooltip = label;
    group.className = `unbutton filter-control always-interactive ${classes ?? ""}`;
    group.querySelector(":scope > i").className = icon;
  }

  /* -------------------------------------------- */

  /**
   * Initialize controls based on sorting preferences.
   * @protected
   */
  _initSorting() {
    const { sort } = this._controls;
    if ( !sort ) return;
    let key = this.prefs?.sort;
    if ( !(key in this.#modes) ) key = Object.keys(this.#modes)[0];
    const { label, icon, classes } = this.#modes[key] ?? {};
    sort.ariaLabel = label;
    sort.dataset.tooltip = label;
    sort.className = `unbutton filter-control always-interactive active ${classes ?? ""}`;
    sort.querySelector(":scope > i").className = icon;
  }

  /* -------------------------------------------- */

  /**
   * Parse configuration markup for this element.
   * @param {string} list  The configuration to parse.
   * @returns {Record<string, ListControlDescriptor>}
   */
  #parseControlOptions(list) {
    return Array.from(this.querySelectorAll(`[data-list="${list}"] option`)).reduce((obj, opt) => {
      const descriptor = { key: opt.value, label: opt.innerText, icon: opt.dataset.icon };
      if ( "classes" in opt.dataset ) descriptor.classes = opt.dataset.classes;
      obj[opt.value] = descriptor;
      return obj;
    }, {});
  }

  /* -------------------------------------------- */
  /*  Filtering, Grouping, & Sorting              */
  /* -------------------------------------------- */

  /**
   * Apply the filters to the managed list.
   * @internal
   */
  _applyFilters() {
    const { name, properties } = this.state;
    this._controls.clear.classList.toggle("active", properties.size || name);
    this._controls.filter?.classList.toggle("active", properties.size);
    let entries = this.app._filterChildren?.(this.getAttribute("collection") ?? "items", properties);
    if ( !entries ) return;
    if ( name ) entries = entries.filter(item => item.name.toLocaleLowerCase(game.i18n.lang).includes(name));
    const elementMap = {};
    if ( !this.keepEmpty ) this.list.querySelectorAll(".items-section").forEach(el => el.hidden = true);
    this.list.querySelectorAll(".item-list .item").forEach(el => {
      const uniqueID = el.dataset.parentId ? `${el.dataset.parentId}.${el.dataset.entryId}` : el.dataset.entryId;
      elementMap[uniqueID] = el;
      el.hidden = true;
    });
    for ( const entry of entries ) {
      const el = elementMap[`${entry.parent?.id}.${entry.id}`] ?? elementMap[entry.id];
      if ( el ) el.hidden = false;
    }
    this.list.querySelectorAll(".items-section:has(.item-list .item:not([hidden]))").forEach(el => el.hidden = false);
  }

  /* -------------------------------------------- */

  /**
   * Group the managed items.
   * @protected
   */
  _applyGrouping() {
    if ( this._controls.group ) {
      let key = this.prefs?.group;
      if ( !(key in this.#groups) ) key = Object.keys(this.#groups)[0];
      const columns = {};
      const sections = {};
      for ( const section of this.list.querySelectorAll(`.items-section[data-group-${key}]`) ) {
        const group = section.getAttribute(`data-group-${key}`);
        sections[group] = section.querySelector(".item-list");
        columns[group] = Array.from(section.querySelectorAll(".items-header [data-column-id]")).reduce((set, el) => {
          set.add(el.dataset.columnId);
          return set;
        }, new Set());
      }
      for ( const item of this.list.querySelectorAll(".item") ) {
        const group = item.getAttribute(`data-group-${key}`);
        const cols = columns[group];
        if ( cols ) {
          item.querySelectorAll("[data-column-id]").forEach(col => {
            col.classList.toggle("hidden-column", !cols.has(col.dataset.columnId));
          });
        }
        sections[group]?.append(item);
      }
      this.#inventory?._cacheSections?.();
    }
    this._applyFilters();
    this._applySorting();
  }

  /* -------------------------------------------- */

  /**
   * Sort the managed list.
   * @protected
   */
  _applySorting() {
    let sort = this.prefs?.sort;
    if ( !(sort in this.#modes) ) sort = Object.keys(this.#modes)[0];
    const entries = this.app._sortChildren?.(this.getAttribute("collection") ?? "items", sort);
    const elementMap = {};
    this.list.querySelectorAll(".item-list .item").forEach(el => {
      const uniqueID = el.dataset.parentId ? `${el.dataset.parentId}.${el.dataset.entryId}` : el.dataset.entryId;
      elementMap[uniqueID] = el;
    });
    for ( const entry of entries ) {
      const el = elementMap[`${entry.parent?.id}.${entry.id}`] ?? elementMap[entry.id];
      if ( el ) el.parentElement.append(el);
    }
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /**
   * Handle clearing all filters.
   * @protected
   */
  _onClearFilters() {
    this.state.name = this._inputElement.value = "";
    this.state.properties.clear();
    this._applyFilters();
  }

  /* -------------------------------------------- */

  /**
   * Handle cycling through the sorting or grouping modes.
   * @param {PointerEvent} event  The triggering event.
   * @protected
   */
  async _onCycleMode(event) {
    const { action } = event.currentTarget.dataset;
    const flag = `sheetPrefs.${this.app.document.type}.tabs.${this.tab}.${action}`;
    const modes = Object.keys(action === "group" ? this.#groups : this.#modes);
    const current = Math.max(0, modes.indexOf(game.user.getFlag("dnd5e", flag)));
    await game.user.setFlag("dnd5e", flag, modes[(current + 1) % modes.length]);
    if ( action === "group" ) {
      this._initGrouping();
      this._applyGrouping();
    } else {
      this._initSorting();
      this._applySorting();
    }
    game.tooltip.deactivate();
  }

  /* -------------------------------------------- */

  /**
   * Handle the user filtering by name.
   * @param {KeyboardEvent} event  The triggering event.
   * @protected
   */
  _onFilterName(event) {
    this.state.name = event.target.value.toLocaleLowerCase(game.i18n.lang);
    this._applyFilters();
  }
}

const { BooleanField: BooleanField$d, NumberField: NumberField$g, SchemaField: SchemaField$l, StringField: StringField$p } = foundry.data.fields;

/**
 * @import { DropEffectValue } from "../../../_types.mjs"
 * @import { InventorySectionDescriptor } from "../../components/_types.mjs";
 */

/**
 * Base actor sheet built on ApplicationV2.
 */
class BaseActorSheet extends PrimarySheetMixin(
  ApplicationV2Mixin(foundry.applications.sheets.ActorSheetV2)
) {
  constructor(options={}) {
    // Set initial size based on saved size
    const key = `${options.document?.type}${options.document?.limited ? ":limited" : ""}`;
    const { width, height } = game.user.getFlag("dnd5e", `sheetPrefs.${key}`) ?? {};
    options.position ??= {};
    if ( width && !("width" in options.position) ) options.position.width = width;
    if ( height && !("height" in options.position) ) options.position.height = height;

    super(options);
  }

  /* -------------------------------------------- */

  /** @override */
  static DEFAULT_OPTIONS = {
    actions: {
      editImage: BaseActorSheet.#onEditImage,
      inspectWarning: BaseActorSheet.#inspectWarning,
      openWarnings: BaseActorSheet.#openWarnings,
      rest: BaseActorSheet.#rest,
      restoreTransformation: BaseActorSheet.#restoreTransformation,
      roll: BaseActorSheet.#roll,
      showArtwork: BaseActorSheet.#showArtwork,
      showConfiguration: BaseActorSheet.#showConfiguration,
      togglePip: BaseActorSheet.#togglePip,
      toggleSidebar: BaseActorSheet.#toggleSidebar
    },
    classes: ["actor", "standard-form"],
    elements: {
      effects: "dnd5e-effects",
      inventory: "dnd5e-inventory"
    },
    form: {
      submitOnChange: true
    },
    window: {
      controls: [
        {
          action: "restoreTransformation",
          icon: "fa-solid fa-backward",
          label: "DND5E.TRANSFORM.Action.Restore",
          ownership: "OWNER",
          visible: BaseActorSheet.#canRestoreTransformation
        }
      ],
      resizable: true
    }
  };

  /* -------------------------------------------- */

  /**
   * Application parts used when rendering the sheet in limited mode.
   * @type {Record<string, HandlebarsTemplatePart>}
   */
  static LIMITED_PARTS = {
    header: {
      template: "systems/dnd5e/templates/actors/limited-header.hbs"
    },
    biography: {
      container: { classes: ["tab-body"], id: "tabs" },
      template: "systems/dnd5e/templates/actors/limited-body.hbs",
      scrollable: [""]
    }
  };

  /* -------------------------------------------- */

  /**
   * 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();

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The Actor whose items are shown on the sheet.
   * @type {Actor5e}
   */
  get inventorySource() {
    return this.actor;
  }

  /* -------------------------------------------- */

  /**
   * The cached concentration information for the character.
   * @type {{ items: Set<Item5e>, effects: Set<ActiveEffect5e> }}
   * @internal
   */
  _concentration;

  /* -------------------------------------------- */

  /**
   * Key path to the sidebar collapsed flag for the current tab.
   * @type {string}
   * @internal
   */
  get _sidebarCollapsedKeyPath() {
    return `sheetPrefs.${this.actor.type}.tabs.${this.tabGroups.primary}.collapseSidebar`;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /**
   * Allow subclasses to make adjustments to inventory section configuration.
   * @param {InventorySectionDescriptor[]} sections  The inventory sections.
   * @returns {Promise<void>}
   * @protected
   */
  async _configureInventorySections(sections) {}

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _configureRenderOptions(options) {
    await super._configureRenderOptions(options);
    if ( options.isFirstRender && options.tab ) this.tabGroups.primary = options.tab;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _configureRenderParts(options) {
    if ( this.actor.limited ) return foundry.utils.deepClone(this.constructor.LIMITED_PARTS);
    const parts = super._configureRenderParts(options);
    if ( "inventory" in parts ) {
      parts.inventory.templates ??= [];
      parts.inventory.templates.push(...customElements.get(this.options.elements.inventory).templates);
    }
    if ( "features" in parts ) {
      parts.features.templates ??= [];
      parts.features.templates.push(...customElements.get(this.options.elements.inventory).templates);
    }
    return parts;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = {
      ...await super._prepareContext(options),
      actor: this.actor,
      elements: this.options.elements,
      fields: this.actor.system.schema.fields,
      labels: {
        damageAndHealing: { ...CONFIG.DND5E.damageTypes, ...CONFIG.DND5E.healingTypes },
        ...this.actor.labels
      },
      limited: this.actor.limited,
      modernRules: this.actor.system.source?.rules
        ? this.actor.system.source.rules === "2024"
        : game.settings.get("dnd5e", "rulesVersion") === "modern",
      rollableClass: this.isEditable ? "rollable" : "",
      sidebarCollapsed: !!game.user.getFlag("dnd5e", this._sidebarCollapsedKeyPath),
      system: this.actor.system,
      user: game.user,
      warnings: foundry.utils.deepClone(this.actor._preparationWarnings)
    };
    context.source = context.editable ? this.actor.system._source : this.actor.system;
    context.config = context.CONFIG; // TODO: Temporary patch until all templates have been updated

    // Cache concentration data and prepare items
    this._concentration = this.actor.concentration;
    await this._prepareItems(context);

    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare rendering context for the effects tab.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {ApplicationRenderContext}
   * @protected
   */
  async _prepareEffectsContext(context, options) {
    context.effects = EffectsElement.prepareCategories(this.actor.allApplicableEffects());

    const conditionIds = new Set();
    context.conditions = Object.entries(CONFIG.DND5E.conditionTypes).reduce((arr, [k, c]) => {
      if ( c.pseudo ) return arr; // Filter out pseudo-conditions.
      let { name, img, reference } = c;
      const id = staticID(`dnd5e${k}`);
      conditionIds.add(id);
      const existing = this.actor.effects.get(id);
      const { disabled } = existing ?? {};
      arr.push({
        name, reference,
        id: k,
        img: existing?.img ?? img,
        disabled: existing ? disabled : true
      });
      return arr;
    }, []);

    for ( const category of Object.values(context.effects) ) {
      category.effects = await category.effects.reduce(async (arr, effect) => {
        effect.updateDuration();
        if ( conditionIds.has(effect.id) && !effect.duration.remaining ) return arr;
        const { id, name, img, disabled, duration } = effect;
        const toggleable = !this._concentration?.effects.has(effect);
        let source = await effect.getSource();
        // If the source is an ActiveEffect from another Actor, note the source as that Actor instead.
        if ( source instanceof ActiveEffect ) {
          source = source.target;
          if ( (source instanceof Item) && source.parent && (source.parent !== this.object) ) source = source.parent;
        }
        arr = await arr;
        arr.push({
          id, name, img, disabled, duration, source, toggleable,
          parentId: effect.target === effect.parent ? null : effect.parent.id,
          durationParts: duration.remaining ? duration.label.split(", ") : [],
          hasTooltip: source instanceof dnd5e.documents.Item5e
        });
        return arr;
      }, []);
    }

    context.effects.suppressed.info = context.effects.suppressed.info[0];

    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare rendering context for the inventory tab.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {ApplicationRenderContext}
   * @protected
   */
  async _prepareInventoryContext(context, options) {
    const Inventory = customElements.get(this.options.elements.inventory);

    // Currency
    context.currency = this.inventorySource.system._source.currency;

    // Containers
    context.itemContext ??= {};
    context.containers = context.itemCategories.containers ?? [];
    for ( const container of context.containers ?? [] ) {
      const ctx = context.itemContext[container.id];
      ctx.capacity = await container.system.computeCapacity();
      ctx.capacity.maxLabel = Number.isFinite(ctx.capacity.max) ? ctx.capacity.max : "&infin;";
      ctx.columns = Inventory.mapColumns(["capacity", "controls"]);
      ctx.clickAction = "view";
    }

    // Inventory
    const sections = Object.values(CONFIG.Item.dataModels)
      .filter(model => "inventorySection" in model)
      .map(model => model.inventorySection);
    sections.push(foundry.utils.deepClone(Inventory.SECTIONS.contents));
    await this._configureInventorySections(sections);
    // Add hidden section that renders the union of columns.
    sections.push({ items: context.itemCategories.inventory ?? [], columns: Inventory.unionColumns(sections) });
    context.sections = Inventory.prepareSections(sections);
    context.showCurrency = true;

    // Filtering, Grouping, & Sorting
    context.listControls = foundry.utils.deepClone(ItemListControlsElement.CONFIG.inventory);

    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare rendering context for the special traits tab.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {ApplicationRenderContext}
   * @protected
   */
  async _prepareSpecialTraitsContext(context, options) {
    const sections = [];
    const source = context.editable ? this.document._source : this.document;
    const flags = context.flags = {
      classes: Object.values(this.document.classes)
        .map(cls => ({ value: cls.id, label: cls.name }))
        .sort((lhs, rhs) => lhs.label.localeCompare(rhs.label, game.i18n.lang)),
      data: source.flags?.dnd5e ?? {},
      disabled: this._mode === this.constructor.MODES.PLAY
    };

    // Character Flags
    for ( const [key, config] of Object.entries(CONFIG.DND5E.characterFlags) ) {
      const flag = { ...config, name: `flags.dnd5e.${key}`, value: foundry.utils.getProperty(flags.data, key) };
      const fieldOptions = { label: config.name, hint: config.hint };
      if ( config.type === Boolean ) {
        flag.field = new BooleanField$d(fieldOptions);
        flag.input = createCheckboxInput;
      }
      else if ( config.type === Number ) flag.field = new NumberField$g(fieldOptions);
      else flag.field = new StringField$p(fieldOptions);

      if ( !config.deprecated || flag.value ) {
        sections[config.section] ??= [];
        sections[config.section].push(flag);
      }
    }

    // Global Bonuses
    const globals = [];
    const addBonus = field => {
      if ( field instanceof SchemaField$l ) Object.values(field.fields).forEach(f => addBonus(f));
      else globals.push({ field, name: field.fieldPath, value: foundry.utils.getProperty(source, field.fieldPath) });
    };
    addBonus(this.document.system.schema.fields.bonuses);
    if ( globals.length ) sections[game.i18n.localize("DND5E.BONUSES.FIELDS.bonuses.label")] = globals;

    flags.sections = Object.entries(sections).map(([label, fields]) => ({ label, fields }));

    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare rendering context for the spells tab.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {ApplicationRenderContext}
   * @protected
   */
  async _prepareSpellsContext(context, options) {
    const Inventory = customElements.get(this.options.elements.inventory);
    context.sections = Inventory.prepareSections(Object.values(context.spellbook));
    context.listControls = {
      label: "DND5E.SpellsSearch",
      list: "spells",
      filters: [
        { key: "action", label: "DND5E.Action" },
        { key: "bonus", label: "DND5E.BonusAction" },
        { key: "reaction", label: "DND5E.Reaction" },
        { key: "concentration", label: "DND5E.Concentration" },
        { key: "ritual", label: "DND5E.Ritual" },
        { key: "prepared", label: "DND5E.Prepared" },
        ...Object.entries(CONFIG.DND5E.spellSchools).map(([key, { label }]) => ({ key, label }))
      ],
      sorting: [
        { key: "a", label: "SIDEBAR.SortModeAlpha", dataset: { icon: "fa-solid fa-arrow-down-a-z" } },
        { key: "p", label: "SIDEBAR.SortModePriority", dataset: { icon: "fa-solid fa-arrow-down-1-9" } },
        { key: "m", label: "SIDEBAR.SortModeManual", dataset: { icon: "fa-solid fa-arrow-down-short-wide" } }
      ]
    };
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare rendering context for the tabs.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {Promise<ApplicationRenderContext>}
   * @protected
   */
  async _prepareTabsContext(context, options) {
    context.tabs = foundry.utils.deepClone(this.constructor.TABS);
    const activeTab = context.tabs.find(t => t.tab === this.tabGroups.primary) ?? context.tabs[0];
    activeTab.active = true;
    return context;
  }

  /* -------------------------------------------- */
  /*  Actor Preparation Helpers                   */
  /* -------------------------------------------- */

  /**
   * Prepare actor abilities for display.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @returns {object[]}
   * @protected
   */
  _prepareAbilities(context) {
    return Object.entries(context.system.abilities).map(([key, ability]) => ({
      ...ability, key,
      abbr: CONFIG.DND5E.abilities[key]?.abbreviation ?? "",
      hover: CONFIG.DND5E.proficiencyLevels[ability.proficient],
      icon: CONFIG.DND5E.abilities[key]?.icon,
      label: CONFIG.DND5E.abilities[key]?.label,
      source: context.source.abilities[key]
    }));
  }

  /* -------------------------------------------- */

  /**
   * Prepare items display across the sheet.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @protected
   */
  async _prepareItems(context) {
    context.itemCategories = {};
    context.itemContext = {};
    context.items = Array.from(this.inventorySource.items).filter(i => !this.actor.items.has(i.system.container));
    await Promise.all(context.items.map(async item => {
      // Prepare item context
      const ctx = context.itemContext[item.id] ??= {};
      ctx.clickAction = "use";
      this._prepareItem(item, ctx);
      if ( item.type === "spell" ) await this._prepareItemSpell(item, ctx);
      else if ( "quantity" in item.system ) await this._prepareItemPhysical(item, ctx);
      else await this._prepareItemFeature(item, ctx);

      // Handle expanded data
      ctx.isExpanded = this.expandedSections.get(item.id) === true;
      if ( ctx.isExpanded ) ctx.expanded = await item.getChatData({ secrets: item.isOwner });

      // Place the item into specific categories.
      const categories = this._assignItemCategories(item) ?? [];
      for ( const category of categories ) {
        context.itemCategories[category] ??= [];
        context.itemCategories[category].push(item);
      }

      // Grouping
      ctx.dataset = Object.fromEntries(Object.entries(ctx.groups ?? {}).map(([k, v]) => [`group-${k}`, v]));
    }));
  }

  /* -------------------------------------------- */

  /**
   * Prepare actor portrait for display.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @returns {Promise<object>}
   * @protected
   */
  async _preparePortrait(context) {
    const portraitData = await this.actor.getPreferredArtwork();
    return {
      ...portraitData,
      action: portraitData.isRandom ? "configurePrototypeToken" : "editImage",
      path: portraitData.isToken ? this.actor.isToken ? "token.texture.src" : "prototypeToken.texture.src" : "img",
      token: portraitData.isToken,
      type: portraitData.token ? "imagevideo" : "image"
    };
  }

  /* -------------------------------------------- */

  /**
   * Prepare actor senses for display.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @returns {object[]}
   * @protected
   */
  _prepareSenses(context) {
    return [
      ...Object.entries(CONFIG.DND5E.senses).map(([k, label]) => {
        const value = context.system.attributes.senses[k];
        return value ? { label, value } : null;
      }, {}).filter(_ => _),
      ...splitSemicolons(context.system.attributes.senses.special)
        .map(label => ({ label }))
    ];
  }

  /* -------------------------------------------- */

  /**
   * Prepare actor skills or tools for display.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {"skills"|"tools"} property         Type of data being prepared.
   * @returns {object[]}
   * @protected
   */
  _prepareSkillsTools(context, property) {
    const baseAbility = key => {
      let src = context.source[property]?.[key]?.ability;
      if ( src ) return src;
      if ( property === "skills" ) src = CONFIG.DND5E.skills[key]?.ability;
      return src ?? "int";
    };
    return Object.entries(context.system[property] ?? {}).map(([key, entry]) => ({
      ...entry, key,
      abbreviation: CONFIG.DND5E.abilities[entry.ability]?.abbreviation,
      baseAbility: baseAbility(key),
      hover: CONFIG.DND5E.proficiencyLevels[entry.value],
      label: (property === "skills") ? CONFIG.DND5E.skills[key]?.label : keyLabel(key, { trait: "tool" }),
      source: context.source[property]?.[key]
    })).sort((a, b) => a.label.localeCompare(b.label, game.i18n.lang));
  }

  /* -------------------------------------------- */

  /**
   * Prepare spells sections for display.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @returns {object}
   * @protected
   */
  _prepareSpellbook(context) {
    const spellbook = {};
    const columns = customElements.get(this.options.elements.inventory).mapColumns([
      "school", "time", "range", "target", "roll", { id: "uses", order: 650, priority: 300 },
      { id: "formula", priority: 200 }, "controls"
    ]);

    /**
     * Register a section in the spellbook.
     * @param {string} key                  The section's unique identifier.
     * @param {number} [level]              The level of spells in this section. Only relevant for spellcasting methods
     *                                      that provide multi-level slots.
     * @param {SpellcastingModel} [config]  The spellcasting model, if any.
     */
    const registerSection = (key, level, config) => {
      level = config?.slots ? level : 1;
      if ( key in spellbook ) return;
      const label = config?.getLabel({ level }) ?? game.i18n.localize("DND5E.CAST.SECTIONS.Spellbook");
      const method = config?.key ?? key;
      const order = level === 0 ? 0 : (config?.order ?? 1000);
      const usesSlots = config?.slots && level;
      const section = spellbook[key] = {
        label, columns, order, usesSlots,
        id: method,
        slot: key,
        items: [],
        minWidth: 220,
        draggable: true,
        dataset: { level, method, type: "spell" }
      };
      if ( !usesSlots ) return;
      const spells = foundry.utils.getProperty(this.actor.system.spells, key);
      const maxSlots = spells.override ?? spells.max ?? 0;
      section.pips = Array.fromRange(Math.max(maxSlots, spells.value ?? 0), 1).map(n => {
        const filled = spells.value >= n;
        const temp = n > maxSlots;
        const label = temp
          ? game.i18n.localize("DND5E.SpellSlotTemporary")
          : filled
            ? game.i18n.format(`DND5E.SpellSlotN.${getPluralRules({ type: "ordinal" }).select(n)}`, { n })
            : game.i18n.localize("DND5E.SpellSlotExpended");
        const classes = ["pip"];
        if ( filled ) classes.push("filled");
        if ( temp ) classes.push("tmp");
        return { n, label, filled, tooltip: label, classes: classes.join(" ") };
      });
    };

    // Register sections for the available spellcasting methods this character has.
    for ( const spellcasting of Object.values(CONFIG.DND5E.spellcasting) ) {
      const levels = spellcasting.getAvailableLevels?.(this.actor) ?? [];
      if ( !levels.length ) continue;
      if ( spellcasting.cantrips ) registerSection("spell0", 0, CONFIG.DND5E.spellcasting.spell);
      levels.forEach(l => registerSection(spellcasting.getSpellSlotKey(l), l, spellcasting));
    }

    // Iterate over every spell item, adding spells to the spellbook by section
    (context.itemCategories.spells ?? []).forEach(spell => {
      let method = spell.system.method;
      if ( !(method in CONFIG.DND5E.spellcasting) ) method = "innate";
      const spellcasting = CONFIG.DND5E.spellcasting[method];
      const level = spell.system.level || 0;
      method = spellcasting?.getSpellSlotKey?.(level) ?? method;

      // Spells from items
      if ( spell.getFlag("dnd5e", "cachedFor") ) {
        method = "item";
        if ( !spell.system.linkedActivity?.displayInSpellbook ) return;
        registerSection(method);
      }

      // Sections for higher-level spells which the caster does not have any slots for.
      else registerSection(method, level, spellcasting);

      // Add the spell to the relevant heading
      spellbook[method].items.push(spell);
    });

    // Sort the spellbook by section level
    return spellbook;
  }

  /* -------------------------------------------- */

  /**
   * Prepare actor traits for display.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @returns {Record<string, object[]>}
   * @protected
   */
  _prepareTraits(context) {
    const traits = {};
    for ( const [trait$1, config] of Object.entries(CONFIG.DND5E.traits) ) {
      const key = config.actorKeyPath ?? `system.traits.${trait$1}`;
      const data = foundry.utils.deepClone(foundry.utils.getProperty(this.actor, key));
      if ( ["dm", "languages"].includes(trait$1) || !data ) continue;

      let values = data.value;
      if ( !values ) values = [];
      else if ( values instanceof Set ) values = Array.from(values);
      else if ( !Array.isArray(values) ) values = [values];
      values = values.map(key => {
        const value = { key, label: keyLabel(key, { trait: trait$1 }) ?? key };
        const icons = value.icons = [];
        if ( data.bypasses?.size && CONFIG.DND5E.damageTypes[key]?.isPhysical ) icons.push(...data.bypasses.map(p => {
          const type = CONFIG.DND5E.itemProperties[p]?.label;
          return { icon: p, label: game.i18n.format("DND5E.DamagePhysicalBypassesShort", { type }) };
        }));
        return value;
      });
      if ( data.custom ) splitSemicolons(data.custom).forEach(label => values.push({ label }));
      if ( values.length ) traits[trait$1] = values;
    }

    // If petrified, display "All Damage" instead of all damage types separately
    if ( this.document.hasConditionEffect("petrification") ) {
      traits.dr = [{ label: game.i18n.localize("DND5E.DamageAll") }];
    }

    // Combine damage & condition immunities in play mode.
    if ( (this._mode === this.constructor.MODES.PLAY) && traits.ci ) {
      traits.di ??= [];
      traits.di.push(...traits.ci);
      delete traits.ci;
    }

    // Prepare damage modifications
    const dm = this.actor.system.traits?.dm;
    if ( dm ) {
      const rollData = this.actor.getRollData({ deterministic: true });
      const values = Object.entries(dm.amount).map(([k, v]) => {
        const total = simplifyBonus(v, rollData);
        if ( !total ) return null;
        const value = {
          label: `${CONFIG.DND5E.damageTypes[k]?.label ?? k} ${formatNumber(total, { signDisplay: "always" })}`,
          color: total > 0 ? "maroon" : "green"
        };
        const icons = value.icons = [];
        if ( dm.bypasses.size && CONFIG.DND5E.damageTypes[k]?.isPhysical ) icons.push(...dm.bypasses.map(p => {
          const type = CONFIG.DND5E.itemProperties[p]?.label;
          return { icon: p, label: game.i18n.format("DND5E.DamagePhysicalBypassesShort", { type }) };
        }));
        return value;
      }).filter(f => f);
      if ( values.length ) traits.dm = values;
    }

    // Prepare languages
    const languages = this.actor.system.traits?.languages?.labels;
    if ( languages?.languages?.length ) traits.languages = languages.languages.map(label => ({ label }));
    for ( const [key, { label }] of Object.entries(CONFIG.DND5E.communicationTypes) ) {
      const data = this.actor.system.traits?.languages?.communication?.[key];
      if ( !data?.value ) continue;
      traits.languages ??= [];
      traits.languages.push({ label, value: data.value });
    }

    // Display weapon masteries
    for ( const key of this.actor.system.traits?.weaponProf?.mastery?.value ?? [] ) {
      let value = traits.weapon?.find(w => w.key === key);
      if ( !value ) {
        value = { key, label: keyLabel(key, { trait: "weapon" }) ?? key, icons: [] };
        traits.weapon ??= [];
        traits.weapon.push(value);
      }
      value.icons.push({ icon: "mastery", label: game.i18n.format("DND5E.WEAPON.Mastery.Label") });
    }

    return traits;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _renderFrame(options) {
    const html = await super._renderFrame(options);

    // Preparation warnings
    const warnings = document.createElement("button");
    warnings.type = "button";
    warnings.classList.add(
      "header-control", "preparation-warnings", "icon", "fa-solid", "fa-triangle-exclamation"
    );
    Object.assign(warnings.dataset, { action: "openWarnings", tooltip: "Warnings", tooltipDirection: "DOWN" });
    warnings.setAttribute("aria-label", game.i18n.localize("Warnings"));
    html.querySelector(".window-header .window-subtitle").after(warnings);

    return html;
  }

  /* -------------------------------------------- */
  /*  Item Preparation Helpers                    */
  /* -------------------------------------------- */

  /**
   * Place an item into specific categories.
   * @param {Item5e} item    Item being prepared for display.
   * @returns {Set<string>}  Names of categories into which to place item.
   * @protected
   */
  _assignItemCategories(item) {
    const origin = item.dependentOrigin;
    if ( (origin?.active === false) && (origin.parent !== item) ) return [];
    if ( item.type === "container" ) return new Set(["containers", "inventory"]);
    if ( item.type === "spell" ) return new Set(["spells"]);
    if ( "inventorySection" in item.system.constructor ) return new Set(["inventory"]);
    return new Set(["features"]);
  }

  /* -------------------------------------------- */

  /**
   * Prepare specific activity's context.
   * @param {Activity} activity  Activity being prepared for display.
   * @returns {object}
   * @protected
   */
  _prepareActivity(activity) {
    let { _id, activation, img, labels, name, range, save, uses } = activity.prepareSheetContext();

    // Activation
    const activationAbbr = {
      action: "DND5E.ActionAbbr",
      bonus: "DND5E.BonusActionAbbr",
      reaction: "DND5E.ReactionAbbr",
      minute: "DND5E.TimeMinuteAbbr",
      hour: "DND5E.TimeHourAbbr",
      day: "DND5E.TimeDayAbbr"
    }[activation?.type || ""];

    // To Hit
    const toHit = parseInt(labels.modifier);

    // Limited Uses
    uses = { ...(uses ?? {}) };
    uses.hasRecharge = uses.max && (uses.recovery?.[0]?.period === "recharge");
    uses.isOnCooldown = uses.hasRecharge && (uses.value < 1);
    uses.hasUses = uses.max;
    uses.prop = "uses.value";

    return {
      _id, labels, name, range, uses,
      activation: activationAbbr
        ? `${activation.value ?? ""}${game.i18n.localize(activationAbbr)}`
        : labels.activation,
      icon: {
        src: img,
        svg: img.endsWith(".svg")
      },
      isSpell: activity.item.type === "spell",
      save: save ? {
        ...save,
        ability: save.ability?.size
          ? save.ability.size === 1
            ? CONFIG.DND5E.abilities[save.ability.first()]?.abbreviation
            : game.i18n.localize("DND5E.AbbreviationDC")
          : null
      } : null,
      toHit: Number.isNaN(toHit) ? null : toHit
    };
  }

  /* -------------------------------------------- */

  /**
   * Prepare specific item's context.
   * @param {Item5e} item  Item being prepared for display.
   * @param {object} ctx   Item specific context.
   * @protected
   */
  _prepareItem(item, ctx) {
    ctx.groups = {};

    // Activities
    ctx.activities = item.system.activities
      ?.filter(a => a.canUse)
      ?.map(this._prepareActivity.bind(this));

    // Concentration
    if ( this._concentration.items.has(item) ) ctx.concentration = true;

    // To Hit
    const toHit = parseInt(item.labels.modifier);
    ctx.toHit = item.hasAttack && !isNaN(toHit) ? toHit : null;

    // Save
    ctx.save = { ...item.system.activities?.getByType("save")[0]?.save };
    ctx.save.ability = ctx.save.ability?.size ? ctx.save.ability.size === 1
      ? CONFIG.DND5E.abilities[ctx.save.ability.first()]?.abbreviation
      : game.i18n.localize("DND5E.AbbreviationDC") : null;

    // Linked Uses
    const cachedFor = fromUuidSync(item.flags.dnd5e?.cachedFor, { relative: item.parent, strict: false });
    if ( cachedFor ) {
      const targetItemUses = cachedFor.consumption?.targets.find(t => t.type === "itemUses");
      ctx.linkedUses = cachedFor.consumption?.targets.find(t => t.type === "activityUses")
        ? cachedFor.uses : targetItemUses
          ? (this.actor.items.get(targetItemUses.target) ?? cachedFor.item).system.uses : null;
    }
    ctx.uses = { ...(item.system.uses ?? {}) };
    ctx.uses.hasRecharge = item.hasRecharge;
    ctx.uses.hasUses = item.hasLimitedUses;
    ctx.uses.isOnCooldown = item.isOnCooldown;
    ctx.uses.prop = "system.uses.value";
    ctx.uses.pct = ctx.uses.max ? Math.clamp((ctx.uses.value / ctx.uses.max) * 100, 0, 100) : 0;
  }

  /* -------------------------------------------- */

  /**
   * Prepare context for a feature. Called in addition to the standard `_prepareItem` for this item.
   * @param {Item5e} item  Item being prepared for display.
   * @param {object} ctx   Item specific context.
   * @protected
   */
  async _prepareItemFeature(item, ctx) {
    // Classes & Subclasses
    if ( ["class", "subclass"].includes(item.type) ) {
      ctx.prefixedImage = item.img ? foundry.utils.getRoute(item.img) : null;
      if ( item.type === "class" ) ctx.availableLevels = Array.fromRange(CONFIG.DND5E.maxLevel, 1).map(level => {
        const value = level - item.system.levels;
        const label = value ? `${level} (${formatNumber(value, { signDisplay: "always" })})` : `${level}`;
        return { label, value, disabled: value > (CONFIG.DND5E.maxLevel - (item.parent.system.details?.level ?? 0)) };
      });
    }

    ctx.subtitle = [item.system.type?.label, item.isActive ? item.labels.activation : null].filterJoin(" • ");
  }

  /* -------------------------------------------- */

  /**
   * Prepare context for a physical item. Called in addition to the standard `_prepareItem` for this item.
   * @param {Item5e} item  Item being prepared for display.
   * @param {object} ctx   Item specific context.
   * @protected
   */
  async _prepareItemPhysical(item, ctx) {
    // Attuned
    if ( ctx.attunement ) {
      ctx.attunement.applicable = true;
      ctx.attunement.disabled = !item.isOwner;
      ctx.attunement.cls = ctx.attunement.cls === "attuned" ? "active" : "";
    }
    else ctx.attunement = { applicable: false };

    // Equipped
    if ( "equipped" in item.system ) ctx.equip = {
      applicable: true,
      cls: item.system.equipped ? "active" : "",
      title: `DND5E.${item.system.equipped ? "Equipped" : "Unequipped"}`,
      disabled: !item.isOwner
    };
    else ctx.equip = { applicable: false };

    // Subtitles
    ctx.subtitle = [item.system.type?.label, item.isActive ? item.labels.activation : null].filterJoin(" • ");

    // Weight
    ctx.totalWeight = item.system.totalWeight?.toNearest(0.1);

    // Grouping
    Object.assign(ctx.groups, { contents: "contents", type: item.type });
  }

  /* -------------------------------------------- */

  /**
   * Prepare context for a spell. Called in addition to the standard `_prepareItem` for this item.
   * @param {Item5e} item  Item being prepared for display.
   * @param {object} ctx   Item specific context.
   * @protected
   */
  async _prepareItemSpell(item, ctx) {
    const linked = item.system.linkedActivity?.item;

    // Activation
    const cost = item.system.activation?.value ?? "";
    const abbr = {
      action: "DND5E.ActionAbbr",
      bonus: "DND5E.BonusActionAbbr",
      reaction: "DND5E.ReactionAbbr",
      minute: "DND5E.TimeMinuteAbbr",
      hour: "DND5E.TimeHourAbbr",
      day: "DND5E.TimeDayAbbr"
    }[item.system.activation.type];
    ctx.activation = abbr ? `${cost}${game.i18n.localize(abbr)}` : item.labels.activation;

    // Range
    const units = item.system.range?.units;
    if ( units && (units !== "none") ) {
      if ( units in CONFIG.DND5E.movementUnits ) ctx.range = {
        distance: true,
        value: item.system.range.value,
        unit: CONFIG.DND5E.movementUnits[units].abbreviation,
        parts: formatLength(item.system.range.value, units, { parts: true })
      };
      else ctx.range = { distance: false };
    }

    // Prepared
    const { method, prepared } = item.system;
    const config = CONFIG.DND5E.spellcasting[method];
    if ( config?.prepares && !linked ) {
      const isAlways = prepared === CONFIG.DND5E.spellPreparationStates.always.value;
      ctx.preparation = {
        applicable: true,
        disabled: !item.isOwner || isAlways,
        cls: prepared ? "active" : "",
        icon: `<i class="fa-${prepared ? "solid" : "regular"} fa-${isAlways ? "certificate" : "sun"}" inert></i>`,
        title: CONFIG.DND5E.spellPreparationStates[isAlways ? "always" : prepared ? "prepared" : "unprepared"].label
      };
    }
    else ctx.preparation = { applicable: false };

    // Subtitle
    ctx.subtitle = [
      linked ? linked.name : item.parent.classes[item.system.sourceClass]?.name,
      item.labels.components.vsm
    ].filterJoin(" • ");
  }

  /* -------------------------------------------- */

  /**
   * Augment inventory display with attunement indicator.
   * @param {ApplicationRenderContext} context
   * @param {ApplicationRenderOptions} options
   * @protected
   */
  _renderAttunement(context, options) {
    const { attunement } = context.system.attributes;
    const element = document.createElement("div");
    element.classList.add("attunement");
    element.innerHTML = `
      <i class="fa-solid fa-sun" data-tooltip="DND5E.Attunement"
         aria-label="${game.i18n.localize("DND5E.Attunement")}"></i>
      <span class="value"></span>
      <span class="separator">&sol;</span>
    `;
    element.querySelector(".value").append(attunement.value);
    if ( context.editable ) {
      const input = document.createElement("input");
      Object.assign(input, {
        type: "number", name: "system.attributes.attunement.max", className: "max", min: "0", step: "1",
        value: context.source.attributes.attunement.max
      });
      element.append(input);
    } else {
      element.insertAdjacentHTML("beforeend", '<span class="max"></span>');
      element.querySelector(".max").append(attunement.max);
    }
    this.element.querySelector('[data-application-part="inventory"] .middle').append(element);
  }

  /* -------------------------------------------- */

  /**
   * Render a button for creating items in the inventory tab.
   * @protected
   */
  _renderCreateInventory() {
    const button = document.createElement("button");
    Object.assign(button, {
      type: "button", className: "create-child gold-button",
      ariaLabel: game.i18n.format("SIDEBAR.Create", { type: game.i18n.localize("DOCUMENT.Item") })
    });
    button.dataset.action = "addDocument";
    button.insertAdjacentHTML("beforeend", '<i class="fa-solid fa-plus" inert></i>');
    const bottom = this.element.querySelector('[data-application-part="inventory"] .bottom');
    bottom.classList.add("with-child");
    bottom.append(button);
  }

  /* -------------------------------------------- */

  /**
   * Augment spellbook display.
   * @param {ApplicationRenderContext} context
   * @param {ApplicationRenderOptions} options
   * @protected
   */
  _renderSpellbook(context, options) {
    for ( const { usesSlots, pips, slot, dataset } of Object.values(context.spellbook) ) {
      if ( !usesSlots ) continue;
      const query = `[data-application-part="spells"] .items-section[data-level="${
        dataset.level}"][data-method="${dataset.method}"] .items-header`;
      const header = this.element.querySelector(query);
      if ( !header ) continue;
      if ( context.editable ) {
        const config = document.createElement("button");
        Object.assign(config, {
          type: "button", className: "unbutton config-button", ariaLabel: game.i18n.localize("DND5E.SpellSlotsConfig")
        });
        Object.assign(config.dataset, {
          action: "showConfiguration", config: "spellSlots", tooltip: "DND5E.SpellSlotsConfig"
        });
        config.insertAdjacentHTML("afterbegin", '<i class="fa-solid fa-cog" inert></i>');
        header.append(config);
        continue;
      }
      const slots = document.createElement("div");
      slots.classList.add("pips");
      slots.dataset.prop = `system.spells.${slot}.value`;
      pips.forEach(({ classes, n, tooltip, label, filled}) => {
        const button = document.createElement("button");
        Object.assign(button, { type: "button", className: classes, ariaLabel: label, ariaPressed: filled });
        Object.assign(button.dataset, { n, tooltip, action: "togglePip" });
        const icon = '<dnd5e-icon src="systems/dnd5e/icons/svg/spell-slot.svg"></dnd5e-icon>';
        button.insertAdjacentHTML("afterbegin", icon);
        slots.append(button);
      });
      header.insertAdjacentElement("afterend", slots);
    }
  }

  /* -------------------------------------------- */
  /*  Life-Cycle Handlers                         */
  /* -------------------------------------------- */

  /**
   * Apply a property attribution tooltip to an element.
   * @param {HTMLElement} element  The element to get the tooltip.
   * @protected
   */
  _applyTooltips(element) {
    if ( "tooltip" in element.dataset ) return;
    const uuid = element.dataset.referenceTooltip ?? this.actor.uuid;
    element.dataset.tooltip = `
      <section class="loading" data-uuid="${uuid}"><i class="fas fa-spinner fa-spin-pulse"></i></section>
    `;
    if ( element.dataset.attribution ) element.dataset.tooltipClass = "property-attribution";
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onFirstRender(context, options) {
    super._onFirstRender(context, options);

    let mainContent = this.element.querySelector(".main-content");
    if ( !mainContent && this.element.querySelector(".tab-body") ) {
      mainContent = document.createElement("div");
      mainContent.classList.add("main-content");
      mainContent.dataset.containerId = "main";
      this.element.querySelector(".tab-body").after(mainContent);
    }
    if ( mainContent ) {
      // Move .main-content into .sheet-body
      const sheetBody = document.createElement("div");
      sheetBody.classList.add("sheet-body");
      mainContent.after(sheetBody);
      sheetBody.replaceChildren(mainContent);

      // Move .tab-body into .main-content
      const tabBody = this.element.querySelector(".tab-body");
      if ( tabBody ) mainContent.append(tabBody);
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onPosition(position) {
    super._onPosition(position);
    this._saveSheetPosition ??= foundry.utils.debounce(this.#saveSheetSize, 250);
    this._saveSheetPosition(position);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);

    // Apply attribution & reference tooltips
    this.element.querySelectorAll("[data-attribution],[data-reference-tooltip]").forEach(e => this._applyTooltips(e));

    // Collapse sidebar
    if ( this.tabGroups.primary ) {
      const sidebarCollapsed = !!game.user.getFlag("dnd5e", this._sidebarCollapsedKeyPath);
      this.element.classList.toggle("sidebar-collapsed", sidebarCollapsed);
    }

    // Play video elements.
    this.element.querySelectorAll("video").forEach(v => {
      if ( v.paused ) v.play();
    });

    // Display warnings
    const warnings = this.element.querySelector(".window-header .preparation-warnings");
    warnings?.toggleAttribute("hidden", (!game.user.isGM && this.actor.limited)
      || !this.actor._preparationWarnings?.length);

    if ( this.isEditable ) {
      // Class level changes
      for ( const element of this.element.querySelectorAll(".level-selector") ) {
        element.addEventListener("change", event => this.#changeLevel(event));
      }

      // Handle delta inputs
      this.element.querySelectorAll('input[type="text"]:is([data-dtype="Number"], [inputmode="numeric"])')
        .forEach(i => i.addEventListener("change", this._onChangeInputDelta.bind(this)));

      // Meter editing
      for ( const meter of this.element.querySelectorAll('.meter > [role="meter"]:has(> input)') ) {
        meter.addEventListener("click", event => this.#toggleMeter(event, true));
        meter.querySelector(":scope > input")?.addEventListener("blur", event => this.#toggleMeter(event, false));
      }
    }

    // Prevent default middle-click scrolling when locking a tooltip
    this.element.addEventListener("pointerdown", event => {
      if ( (event.button === 1) && document.getElementById("tooltip")?.classList.contains("active") ) {
        event.preventDefault();
      }
    });
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @override */
  _addDocument(event, target) {
    if ( this.tabGroups.primary === "effects" ) return ActiveEffect.implementation.create({
      name: game.i18n.localize("DND5E.EffectNew"),
      icon: "icons/svg/aura.svg"
    }, { parent: this.actor, renderSheet: true });

    const actor = this.inventorySource;
    const types = this._addDocumentItemTypes(this.tabGroups.primary)
      .filter(type => !CONFIG.Item.dataModels[type].metadata?.singleton || !actor.itemTypes[type].length);
    if ( types.length > 1 ) return Item.implementation.createDialog({}, { types, parent: actor });

    const type = types[0];
    return Item.implementation.create({
      type, name: game.i18n.format("DOCUMENT.New", { type: game.i18n.format(CONFIG.Item.typeLabels[type]) })
    }, { parent: actor, renderSheet: true });
  }

  /* -------------------------------------------- */

  /**
   * Determine the types of items that can be added depending on the current tab.
   * @param {string} tab  Currently viewed tab.
   * @returns {string[]}  Types of items to allow to create.
   */
  _addDocumentItemTypes(tab) {
    switch ( tab ) {
      case "features": return ["feat", "race", "background", "class", "subclass"];
      case "inventory": return Object.entries(CONFIG.Item.dataModels)
        .filter(([type, model]) => ("inventorySection" in model) && (type !== "backpack"))
        .map(([type]) => type);
      case "spells": return ["spell"];
      default: return [];
    }
  }

  /* -------------------------------------------- */

  /**
   * Respond to a new level being selected from the level selector.
   * @param {Event} event  The originating change.
   * @returns {Promise<any>}
   * @private
   */
  async #changeLevel(event) {
    const delta = Number(event.target.value);
    const classId = event.target.closest("[data-item-id]")?.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({ force: true });
        try {
          const shouldRemoveAdvancements = await AdvancementConfirmationDialog.forLevelDown(classItem);
          if ( shouldRemoveAdvancements ) return manager.render({ force: true });
        }
        catch(err) {
          return;
        }
      }
    }
    classItem.update({ "system.levels": classItem.system.levels + delta });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  changeTab(tab, group, options) {
    super.changeTab(tab, group, options);
    if ( group !== "primary" ) return;

    // Adjust create child tooltip
    const createChild = this.element.querySelector(".create-child");
    createChild?.setAttribute("aria-label", game.i18n.format("SIDEBAR.Create", {
      type: game.i18n.localize(`DOCUMENT.${tab === "effects" ? "ActiveEffect" : "Item"}`)
    }));

    // Toggle sidebar
    const sidebarCollapsed = game.user.getFlag("dnd5e", this._sidebarCollapsedKeyPath);
    if ( sidebarCollapsed !== undefined ) this._toggleSidebar(sidebarCollapsed);
  }

  /* -------------------------------------------- */

  /**
   * Handle following a warning link.
   * @this {BaseActorSheet}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static async #inspectWarning(event, target) {
    if ( this._inspectWarning(event, target) === false ) return;
    switch ( target.dataset.target ) {
      case "armor":
        new ArmorClassConfig({ document: this.actor }).render({ force: true });
        break;
      default:
        const item = await fromUuid(target.dataset.target);
        item?.sheet.render({ force: true });
        break;
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle following a warning link.
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   * @returns {any}               Return `false` to prevent default behavior.
   * @protected
   */
  _inspectWarning(event, target) {}

  /* -------------------------------------------- */

  /**
   * 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 { itemId } = input.closest("[data-item-id]")?.dataset ?? {};
    const actor = input.closest(".inventory-element") ? this.inventorySource : this.actor;
    const target = actor.items.get(itemId) ?? actor;
    const { activityId } = input.closest("[data-activity-id]")?.dataset ?? {};
    const activity = target?.system.activities?.get(activityId);
    const result = parseInputDelta(input, activity ?? target);
    if ( (result !== undefined) && input.dataset.name ) {
      event.stopPropagation();
      // Special case handling for Item uses.
      if ( input.dataset.name === "system.uses.value" ) {
        target.update({ "system.uses.spent": target.system.uses.max - result });
      } else if ( activity && (input.dataset.name === "uses.value") ) {
        target.updateActivity(activityId, { "uses.spent": activity.uses.max - result });
      }
      else target.update({ [input.dataset.name]: result });
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle editing an image via the file browser.
   * @this {BaseActorSheet}
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   * @returns {Promise<void>}
   */
  static async #onEditImage(event, target) {
    const attr = target.dataset.edit;
    const current = foundry.utils.getProperty(this.document._source, attr);
    const defaultArtwork = this.document.constructor.getDefaultArtwork?.(this.document._source) ?? {};
    const defaultImage = foundry.utils.getProperty(defaultArtwork, attr);
    const fp = new CONFIG.ux.FilePicker({
      current,
      type: target.dataset.type,
      redirectToRoot: defaultImage ? [defaultImage] : [],
      callback: path => {
        const isVideo = foundry.helpers.media.VideoHelper.hasVideoExtension(path);
        if ( ((target instanceof HTMLVideoElement) && isVideo) || ((target instanceof HTMLImageElement) && !isVideo) ) {
          target.src = path;
        } else {
          const repl = document.createElement(isVideo ? "video" : "img");
          Object.assign(repl.dataset, target.dataset);
          if ( isVideo ) Object.assign(repl, {
            autoplay: true, muted: true, disablePictureInPicture: true, loop: true, playsInline: true
          });
          repl.src = path;
          target.replaceWith(repl);
        }
        this._onEditPortrait(attr, path);
      },
      position: {
        top: this.position.top + 40,
        left: this.position.left + 10
      }
    });
    await fp.browse();
  }

  /* -------------------------------------------- */

  /**
   * Handle editing the portrait.
   * @param {string} target  The target property being edited.
   * @param {string} path    The image or video path.
   * @protected
   */
  async _onEditPortrait(target, path) {
    if ( target.startsWith("token.") ) await this.token.update({ [target.slice(6)]: path });
    else {
      const submit = new Event("submit", { cancelable: true });
      this.form.dispatchEvent(submit);
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle opening the preparation warnings dialog.
   * @this {BaseActorSheet}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static async #openWarnings(event, target) {
    event.stopImmediatePropagation();
    const { top, left, height } = event.target.getBoundingClientRect();
    const { clientWidth } = document.documentElement;
    const dialog = this.form.querySelector("dialog.warnings");
    Object.assign(dialog.style, { top: `${top + height}px`, left: `${Math.min(left - 16, clientWidth - 300)}px` });
    dialog.showModal();
    dialog.addEventListener("click", () => dialog.close(), { once: true });
  }

  /* -------------------------------------------- */

  /**
   * Handle resting the actor.
   * @this {BaseActorSheet}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static #rest(event, target) {
    if ( target.dataset.type === "short" ) this.actor.shortRest();
    else this.actor.longRest();
  }

  /* -------------------------------------------- */

  /**
   * Handle restoring a transformed action.
   * @this {BaseActorSheet}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static #restoreTransformation(event, target) {
    this.actor.revertOriginalForm();
  }

  /* -------------------------------------------- */

  /**
   * Handle rolling from the sheet.
   * @this {BaseActorSheet}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   * @returns {any}
   */
  static #roll(event, target) {
    if ( !target.classList.contains("rollable") ) return;
    if ( this._roll(event, target) === false ) return;
    switch ( target.dataset.type ) {
      case "ability":
        const ability = target.closest("[data-ability]")?.dataset.ability;
        if ( ability === "concentration" ) return this.actor.rollConcentration({ event, legacy: false });
        else if ( target.classList.contains("saving-throw") ) return this.actor.rollSavingThrow({ ability, event });
        else return this.actor.rollAbilityCheck({ ability, event });
      case "deathSave":
        return this.actor.rollDeathSave({ event, legacy: false });
      case "initiative":
        return this.actor.rollInitiativeDialog({ event });
      case "skill":
        return this.actor.rollSkill({ event, skill: target.closest("[data-key]")?.dataset.key });
      case "tool":
        return this.actor.rollToolCheck({ event, tool: target.closest("[data-key]")?.dataset.key });
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle rolling from the sheet.
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   * @returns {any}               Return `false` to prevent default behavior.
   * @protected
   */
  _roll(event, target) {}

  /* -------------------------------------------- */

  /**
   * Save the sheet's current size to preferences.
   * @param {ApplicationPosition} position
   */
  #saveSheetSize(position) {
    const { width, height } = position;
    const prefs = {};
    if ( width !== "auto" ) prefs.width = width;
    if ( height !== "auto" ) prefs.height = height;
    if ( foundry.utils.isEmpty(prefs) ) return;
    const key = `${this.actor.type}${this.actor.limited ? ":limited": ""}`;
    game.user.setFlag("dnd5e", `sheetPrefs.${key}`, prefs);
  }

  /* -------------------------------------------- */

  /**
   * Handle showing the Item's art.
   * @this {ItemSheet5e}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static #showArtwork(event, target) {
    new foundry.applications.apps.ImagePopout({
      src: target.getAttribute("src"),
      uuid: this.actor.uuid,
      window: { title: this.actor.name }
    }).render({ force: true });
  }

  /* -------------------------------------------- */

  /**
   * Handle opening a configuration application.
   * @this {ItemSheet5e}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   * @returns {any}
   */
  static #showConfiguration(event, target) {
    if ( this._showConfiguration(event, target) === false ) return;
    const config = { document: this.actor };

    if ( target.dataset.trait ) {
      config.trait = target.dataset.trait;
      if ( config.trait === "ci" ) config.position = { width: 400 };
      switch ( config.trait ) {
        case "di":
        case "dm":
        case "dr":
        case "dv": return new DamagesConfig(config).render({ force: true });
        case "languages": return new LanguagesConfig(config).render({ force: true });
        case "tool": return new ToolsConfig(config).render({ force: true });
        case "weapon": return new WeaponsConfig(config).render({ force: true });
        default: return new TraitsConfig(config).render({ force: true });
      }
    }

    switch ( target.dataset.config ) {
      case "ability":
        const ability = target.closest("[data-ability]")?.dataset.ability;
        if ( ability === "concentration" ) return new ConcentrationConfig(config).render({ force: true });
        return new AbilityConfig({ ...config, key: ability }).render({ force: true });
      case "armorClass":
        return new ArmorClassConfig(config).render({ force: true });
      case "creatureType":
        return new CreatureTypeConfig(this.actor.system.details.race?.id
          ? { document: this.actor.system.details.race, keyPath: "type" } : config).render({ force: true });
      case "death":
        return new DeathConfig(config).render({ force: true });
      case "hitDice":
        return new HitDiceConfig(config).render({ force: true });
      case "hitPoints":
        return new HitPointsConfig(config).render({ force: true });
      case "initiative":
        return new InitiativeConfig(config).render({ force: true });
      case "movement":
      case "senses":
        return new MovementSensesConfig({ ...config, type: target.dataset.config }).render({ force: true });
      case "skill":
        const skill = target.closest("[data-key]").dataset.key;
        return new SkillToolConfig({ ...config, trait: "skills", key: skill }).render({ force: true });
      case "tool":
        const tool = target.closest("[data-key]").dataset.key;
        return new SkillToolConfig({ ...config, trait: "tool", key: tool }).render({ force: true });
      case "skills":
        return new SkillsConfig(config).render({ force: true });
      case "source":
        return new SourceConfig(config).render({ force: true });
      case "spellSlots":
        return new SpellSlotsConfig(config).render({ force: true });
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle opening a configuration application.
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   * @returns {any}               Return `false` to prevent default behavior.
   * @abstract
   */
  _showConfiguration(event, target) {}

  /* -------------------------------------------- */

  /**
   * Toggle editing hit points.
   * @param {PointerEvent} event  The triggering event.
   * @param {boolean} edit        Whether to toggle to the edit state.
   */
  #toggleMeter(event, edit) {
    const target = event.currentTarget.closest('[role="meter"]');
    if ( event.target.nodeName === "BUTTON" ) return;
    const label = target.querySelector(":scope > .label");
    const input = target.querySelector(":scope > input");
    label.hidden = edit;
    input.hidden = !edit;
    if ( edit ) input.focus();
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling a pip on the character sheet.
   * @this {BaseActorSheet}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static #togglePip(event, target) {
    const n = Number(target.closest("[data-n]")?.dataset.n);
    const prop = target.dataset.prop ?? target.closest("[data-prop]")?.dataset.prop;
    if ( !Number.isNumeric(n) || !prop ) return;
    let value = foundry.utils.getProperty(this.actor, prop);
    if ( (value === n) && prop.endsWith(".spent") ) value++;
    else if ( value === n ) value--;
    else value = n;
    this.submit({ updateData: { [prop]: value } });
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling the sidebar collapsed state.
   * @this {BaseActorSheet}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static #toggleSidebar(event, target) {
    const collapsed = this._toggleSidebar();
    game.user.setFlag("dnd5e", this._sidebarCollapsedKeyPath, collapsed);
  }

  /* -------------------------------------------- */

  /**
   * Toggle the sidebar collapsed state.
   * @param {boolean} [collapsed]  Force a particular collapsed state.
   * @returns {boolean}            The new collapsed state.
   * @protected
   */
  _toggleSidebar(collapsed) {
    this.element.classList.toggle("sidebar-collapsed", collapsed);
    collapsed = this.element.classList.contains("sidebar-collapsed");
    const collapser = this.form.querySelector(".sidebar-collapser");
    if ( !collapser ) return collapsed;
    const icon = collapser.querySelector("i");
    collapser.dataset.tooltip = `JOURNAL.View${collapsed ? "Expand" : "Collapse"}`;
    collapser.setAttribute("aria-label", game.i18n.localize(collapser.dataset.tooltip));
    icon.classList.remove("fa-caret-left", "fa-caret-right");
    icon.classList.add(`fa-caret-${collapsed ? "right" : "left"}`);
    return collapsed;
  }

  /* -------------------------------------------- */
  /*  Form Handling                               */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _processFormData(event, form, formData) {
    const submitData = super._processFormData(event, form, formData);

    // Remove any flags that are false-ish
    for ( const [key, value] of Object.entries(submitData.flags?.dnd5e ?? {}) ) {
      if ( value ) continue;

      // Keep the flag for synthetic actor overrides
      if ( this.actor.isToken && this.actor.parent.baseActor.getFlag("dnd5e", key) ) continue;

      delete submitData.flags.dnd5e[key];
      if ( foundry.utils.hasProperty(this.document._source, `flags.dnd5e.${key}`) ) {
        submitData.flags.dnd5e[`-=${key}`] = null;
      }
    }

    // Correctly process data-edit video elements.
    form.querySelectorAll("video[data-edit]").forEach(v => {
      foundry.utils.setProperty(submitData, v.dataset.edit, v.src);
    });

    // Prevent wildcard textures from being clobbered.
    const proto = submitData.prototypeToken;
    if ( proto ) {
      const randomImg = proto.randomImg ?? this.actor.prototypeToken.randomImg;
      if ( randomImg ) delete submitData.prototypeToken;
    }

    return submitData;
  }

  /* -------------------------------------------- */
  /*  Drag & Drop                                 */
  /* -------------------------------------------- */

  /** @override */
  _defaultDropBehavior(event, data) {
    if ( !data?.uuid ) return "copy";
    const d = foundry.utils.parseUuid(data.uuid);
    const t = foundry.utils.parseUuid(this.inventorySource.uuid);
    const base = d.embedded?.length ? "document" : "primary";
    return (d.collection === t.collection) && (d[`${base}Id`] === t[`${base}Id`])
    && (d[`${base}Type`] === t[`${base}Type`]) ? "move" : "copy";
  }

  /* -------------------------------------------- */

  /**
   * Handling beginning a drag-drop operation on an Activity.
   * @param {DragEvent} event  The originating drag event.
   * @protected
   */
  _onDragActivity(event) {
    const { itemId } = event.target.closest("[data-item-id]").dataset;
    const { activityId } = event.target.closest("[data-activity-id]").dataset;
    const activity = this.actor.items.get(itemId)?.system.activities?.get(activityId);
    if ( activity ) event.dataTransfer.setData("text/plain", JSON.stringify(activity.toDragData()));
  }

  /* -------------------------------------------- */

  /**
   * Handling beginning a drag-drop operation on an Active Effect.
   * @param {DragEvent} event  The originating drag event.
   * @protected
   */
  _onDragEffect(event) {
    const { effectId, parentId } = event.currentTarget.closest("[data-effect-id]")?.dataset ?? {};
    const collection = this.actor.items.get(parentId)?.effects ?? this.actor.effects;
    const effect = collection.get(effectId);
    if ( effect ) event.dataTransfer.setData("text/plain", JSON.stringify(effect.toDragData()));
  }

  /* -------------------------------------------- */

  /**
   * Handle beginning a drag-drop operation on an Item.
   * @param {DragEvent} event  The originating drag event.
   * @protected
   */
  _onDragItem(event) {
    const { itemId } = event.target.closest("[data-item-id]").dataset;
    const item = this.inventorySource.items.get(itemId);
    if ( item ) event.dataTransfer.setData("text/plain", JSON.stringify(item.toDragData()));
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onDragStart(event) {
    if ( "link" in event.target.dataset ) return;

    // Add another deferred deactivation to catch the second pointerenter event that seems to be fired on Firefox.
    requestAnimationFrame(() => game.tooltip.deactivate());
    game.tooltip.deactivate();

    const li = event.currentTarget;
    if ( li.dataset.activityId ) return this._onDragActivity(event);
    if ( li.matches("[data-effect-id] > .item-row") ) return this._onDragEffect(event);
    if ( li.matches("[data-item-id] > .item-row") ) return this._onDragItem(event);

    return super._onDragStart(event);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onDrop(event) {
    event._behavior = this._dropBehavior(event);
    await super._onDrop(event);
  }

  /* -------------------------------------------- */

  /** @override */
  async _onDropActor(event, actor) {
    const canPolymorph = game.user.isGM || (this.actor.isOwner && game.settings.get("dnd5e", "allowPolymorphing"));
    if ( !canPolymorph || (this.tabGroups.primary === "bastion") ) return;

    // Configure the transformation
    const settings = await TransformDialog.promptSettings(this.actor, actor, {
      transform: { settings: game.settings.get("dnd5e", "transformationSettings") }
    });
    if ( !settings ) return;
    await game.settings.set("dnd5e", "transformationSettings", settings.toObject());

    return this.actor.transformInto(actor, settings);
  }

  /* -------------------------------------------- */

  /** @override */
  async _onDropItem(event, item) {
    if ( !this.inventorySource.isOwner || (event._behavior === "none") ) return;

    // Handle moving out of container & item sorting
    if ( (event._behavior === "move") && (this.inventorySource.uuid === item.parent?.uuid) ) {
      if ( item.system.container !== null ) await item.update({ "system.container": null });
      return this._onSortItem(event, item);
    }

    return this._onDropCreateItems(event, [item]);
  }

  /* -------------------------------------------- */

  /** @override */
  async _onDropFolder(event, data) {
    const folder = await Folder.implementation.fromDropData(data);
    if ( !this.actor.isOwner || (event._behavior === "none") || (folder.type !== "Item") ) return;

    const items = await Promise.all(folder.contents.map(async item => {
      if ( !(item instanceof Item) ) item = await fromUuid(item.uuid);
      return item;
    }));
    return this._onDropCreateItems(event, items);
  }

  /* -------------------------------------------- */

  /**
   * Handle the final creation of dropped Item data on the Actor.
   * @param {DragEvent} event             The concluding DragEvent which provided the drop data.
   * @param {Item5e[]} items              The items requested for creation.
   * @param {DropEffectValue} [behavior]  The specific drop behavior.
   * @returns {Promise<Item5e[]>}
   * @protected
   */
  async _onDropCreateItems(event, items, behavior) {
    behavior ??= event._behavior;
    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;
    }

    // Filter out items already in containers to avoid creating duplicates
    const containers = new Set(items.filter(i => i.type === "container").map(i => i._id));
    items = items.filter(i => !containers.has(i.system.container));

    // Create the owned items & contents as normal
    const toCreate = await Item5e.createWithContents(items, {
      transformFirst: item => {
        if ( item instanceof foundry.abstract.Document ) item = item.toObject();
        return this._onDropSingleItem(event, item);
      }
    });
    const created = await Item5e.createDocuments(toCreate, { parent: this.inventorySource, keepId: true });
    if ( behavior === "move" ) items.forEach(i => i.delete({ deleteContents: true }));
    return created;
  }

  /* -------------------------------------------- */

  /**
   * Handles dropping of a single item onto this character sheet.
   * @param {DragEvent} event            The concluding DragEvent which provided the drop data.
   * @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(event, itemData) {
    const actor = this.inventorySource;

    // Check to make sure items of this type are allowed on this actor
    if ( this.constructor.unsupportedItemTypes.has(itemData.type) ) {
      ui.notifications.warn("DND5E.ACTOR.Warning.InvalidItem", {
        format: {
          itemType: game.i18n.localize(CONFIG.Item.typeLabels[itemData.type]),
          actorType: game.i18n.localize(CONFIG.Actor.typeLabels[actor.type])
        }
      });
      return false;
    }

    // Create a Consumable spell scroll on the Inventory tab
    if ( (itemData.type === "spell")
      && ((this.tabGroups.primary === "inventory") || (actor.type === "vehicle")) ) {
      const scroll = await Item5e.createScrollFromSpell(itemData);
      return scroll?.toObject?.() ?? false;
    }

    // Clean up data
    this._onDropResetData(event, itemData);

    // Stack identical consumables
    const stacked = this._onDropStackConsumables(event, itemData);
    if ( stacked ) return false;

    // Bypass normal creation flow for any items with advancement
    if ( actor.system.metadata?.supportsAdvancement && itemData.system.advancement?.length
        && !game.settings.get("dnd5e", "disableAdvancements") ) {
      // Ensure that this item isn't violating the singleton rule
      const dataModel = CONFIG.Item.dataModels[itemData.type];
      const singleton = dataModel?.metadata.singleton ?? false;
      if ( singleton && actor.itemTypes[itemData.type].length ) {
        ui.notifications.error("DND5E.ACTOR.Warning.Singleton", {
          format: {
            itemType: game.i18n.localize(CONFIG.Item.typeLabels[itemData.type]),
            actorType: game.i18n.localize(CONFIG.Actor.typeLabels[actor.type])
          }
        });
        return false;
      }

      const manager = AdvancementManager.forNewItem(actor, itemData);
      if ( manager.steps.length ) {
        manager.render(true);
        return false;
      }
    }

    // Let specific item types apply any changes from a drop event
    CONFIG.Item.dataModels[itemData.type]?.onDropCreate?.(event, actor, itemData);

    return itemData;
  }

  /* -------------------------------------------- */

  /**
   * Reset certain pieces of data stored on items when they are dropped onto the actor.
   * @param {DragEvent} event    The concluding DragEvent which provided the drop data.
   * @param {object} itemData    The item data requested for creation. **Will be mutated.**
   */
  _onDropResetData(event, itemData) {
    if ( !itemData.system ) return;
    ["attuned", "equipped", "prepared", "crew.value"].forEach(k => foundry.utils.deleteProperty(itemData.system, k));
  }

  /* -------------------------------------------- */

  /**
   * Stack identical consumables when a new one is dropped rather than creating a duplicate item.
   * @param {DragEvent} event                  The concluding DragEvent which provided the drop data.
   * @param {object} itemData                  The item data requested for creation.
   * @param {object} [options={}]
   * @param {string} [options.container=null]  ID of the container into which this item is being dropped.
   * @returns {Promise<Item5e>|null}           If a duplicate was found, returns the adjusted item stack.
   */
  _onDropStackConsumables(event, itemData, { container=null }={}) {
    const droppedSourceId = itemData._stats?.compendiumSource ?? itemData.flags?.core?.sourceId;
    if ( itemData.type !== "consumable" || !droppedSourceId ) return null;
    const similarItem = this.inventorySource.sourcedItems.get(droppedSourceId, { legacy: false })
      ?.filter(i => (i.system.container === container) && (i.name === itemData.name))?.first();
    if ( !similarItem ) return null;
    return similarItem.update({
      "system.quantity": similarItem.system.quantity + Math.max(itemData.system.quantity, 1)
    });
  }

  /* -------------------------------------------- */

  /** @override */
  _onSortItem(event, item) {
    const items = this.inventorySource.items;
    const source = items.get(item.id);

    // Confirm the drop target
    const dropTarget = event.target.closest("[data-item-id]");
    if ( !dropTarget ) return;
    const target = items.get(dropTarget.dataset.itemId);
    if ( source.id === target.id ) return;

    // Identify sibling items based on adjacent HTML elements
    const siblings = [];
    for ( const element of dropTarget.parentElement.children ) {
      const siblingId = element.dataset.itemId;
      if ( siblingId && (siblingId !== source.id) ) siblings.push(items.get(element.dataset.itemId));
    }

    // Perform the sort
    const sortUpdates = foundry.utils.performIntegerSort(source, {target, siblings});
    const updateData = sortUpdates.map(u => {
      const update = u.update;
      update._id = u.target._id;
      return update;
    });

    // Perform the update
    return this.inventorySource.updateEmbeddedDocuments("Item", updateData);
  }

  /* -------------------------------------------- */
  /*  Filtering                                   */
  /* -------------------------------------------- */

  /**
   * Filter child embedded Documents based on the current set of filters.
   * @param {string} collection    The embedded collection name.
   * @param {Set<string>} filters  Filters to apply to the children.
   * @returns {Document[]}
   * @protected
   */
  _filterChildren(collection, filters) {
    switch ( collection ) {
      case "items": return this._filterItems(this.inventorySource.items, filters);
      case "effects": return this._filterEffects(Array.from(this.actor.allApplicableEffects()), filters);
    }
    return [];
  }

  /* -------------------------------------------- */

  /**
   * Filter Active Effects based on the current set of filters.
   * @param {ActiveEffect5e[]} effects  The effects to filter.
   * @param {Set<string>} filters       Filters to apply to the effects.
   * @returns {ActiveEffect5e[]}
   * @protected
   */
  _filterEffects(effects, filters) {
    return effects;
  }

  /* -------------------------------------------- */

  /**
   * Filter items based on the current set of filters.
   * @param {Item5e[]} items       The items to be filtered.
   * @param {Set<string>} filters  Filters applied to the item list.
   * @returns {Item5e[]}           Subset of input items limited by the provided filters.
   * @protected
   */
  _filterItems(items, filters) {
    const actions = ["action", "bonus", "reaction", "lair", "legendary"];
    const recoveries = ["lr", "sr"];
    const spellSchools = new Set(Object.keys(CONFIG.DND5E.spellSchools));
    const schoolFilter = spellSchools.intersection(filters);
    const spellcastingClasses = new Set(Object.keys(this.actor.spellcastingClasses));
    const classFilter = spellcastingClasses.intersection(filters);

    return items.filter(item => {

      // Subclass-specific logic.
      const filtered = this._filterItem(item, filters);
      if ( filtered !== undefined ) return filtered;

      // Action usage
      for ( const f of actions ) {
        if ( !filters.has(f) ) continue;
        if ( item.type === "spell" ) {
          if ( item.system.activation.type !== f ) return false;
          continue;
        }
        if ( !item.system.activities?.size ) return false;
        if ( item.system.activities.every(a => a.activation?.type !== f) ) return false;
      }

      // Spell-specific filters
      if ( filters.has("ritual") && !item.system.properties?.has("ritual") ) return false;
      if ( filters.has("concentration") && !item.system.properties?.has("concentration") ) return false;
      if ( schoolFilter.size && !schoolFilter.has(item.system.school) ) return false;
      if ( classFilter.size && !classFilter.has(item.system.sourceClass) ) return false;
      if ( filters.has("prepared") ) return item.system.canPrepare && item.system.prepared;

      // Equipment-specific filters
      if ( filters.has("equipped") && (item.system.equipped !== true) ) return false;
      if ( filters.has("mgc") && !item.system.properties?.has("mgc") ) return false;

      // Recovery
      for ( const f of recoveries ) {
        if ( !filters.has(f) ) continue;
        if ( !item.system.uses?.recovery.length ) return false;
        if ( item.system.uses.recovery.every(r => r.period !== f) ) return false;
      }

      return true;
    });
  }

  /* -------------------------------------------- */

  /**
   * Determine whether an Item will be shown based on the current set of filters.
   * @param {Item5e} item          The item.
   * @param {Set<string>} filters  Filters applied to the Item.
   * @returns {boolean|void}
   * @protected
   */
  _filterItem(item, filters) {
    /** @import ContainerSheet from "../../item/container-sheet.mjs" */

    /**
     * A hook event that fires when a sheet filters an item.
     * @function dnd5e.filterItem
     * @memberof hookEvents
     * @param {BaseActorSheet|ContainerSheet} sheet     The sheet the item is being rendered on.
     * @param {Item5e} item                             The item being filtered.
     * @param {Set<string>} filters                     Filters applied to the Item.
     * @returns {false|void} Return false to hide the item, otherwise other filters will continue to apply.
     */
    if ( Hooks.call("dnd5e.filterItem", this, item, filters) === false ) return false;
  }

  /* -------------------------------------------- */
  /*  Sorting                                     */
  /* -------------------------------------------- */

  /** @override */
  _sortChildren(collection, mode) {
    switch ( collection ) {
      case "items": return this._sortItems(this.inventorySource.items.contents, mode);
      case "effects": return this._sortEffects(Array.from(this.actor.allApplicableEffects()), mode);
    }
    return [];
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Can an item be expanded on the sheet?
   * @param {Item5e} item  Item on the sheet.
   * @returns {boolean}
   */
  canExpand(item) {
    return !["class", "subclass"].includes(item.type);
  }

  /* -------------------------------------------- */

  /**
   * Control whether the restore transformation button is visible.
   * @this {BaseActorSheet}
   * @returns {boolean}
   */
  static #canRestoreTransformation() {
    return this.isEditable && this.actor.isPolymorphed;
  }
}

const TextEditor$8 = foundry.applications.ux.TextEditor.implementation;

/**
 * @import { FavoriteData5e } from "../../data/abstract/_types.mjs";
 * @import { ActorFavorites5e } from "../../data/actor/_types.mjs";
 * @import { FacilityOccupants } from "../../data/item/_types.mjs";
 */

/**
 * Extension of base actor sheet for characters.
 */
class CharacterActorSheet extends BaseActorSheet {
  /** @override */
  static DEFAULT_OPTIONS = {
    actions: {
      deleteFavorite: CharacterActorSheet.#deleteFavorite,
      deleteOccupant: CharacterActorSheet.#deleteOccupant,
      findItem: CharacterActorSheet.#findItem,
      setSpellcastingAbility: CharacterActorSheet.#setSpellcastingAbility,
      toggleDeathTray: CharacterActorSheet.#toggleDeathTray,
      toggleInspiration: CharacterActorSheet.#toggleInspiration,
      useFacility: CharacterActorSheet.#useFacility,
      useFavorite: CharacterActorSheet.#useFavorite
    },
    classes: ["character", "vertical-tabs"],
    position: {
      width: 800,
      height: 1000
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    header: {
      template: "systems/dnd5e/templates/actors/character-header.hbs"
    },
    sidebar: {
      container: { classes: ["main-content"], id: "main" },
      template: "systems/dnd5e/templates/actors/character-sidebar.hbs"
    },
    details: {
      classes: ["col-2"],
      container: { classes: ["tab-body"], id: "tabs" },
      template: "systems/dnd5e/templates/actors/tabs/character-details.hbs",
      scrollable: [""]
    },
    inventory: {
      container: { classes: ["tab-body"], id: "tabs" },
      template: "systems/dnd5e/templates/actors/tabs/character-inventory.hbs",
      templates: [
        "systems/dnd5e/templates/inventory/inventory.hbs", "systems/dnd5e/templates/inventory/activity.hbs",
        "systems/dnd5e/templates/inventory/encumbrance.hbs", "systems/dnd5e/templates/inventory/containers.hbs"
      ],
      scrollable: [""]
    },
    features: {
      container: { classes: ["tab-body"], id: "tabs" },
      template: "systems/dnd5e/templates/actors/tabs/character-features.hbs",
      templates: ["systems/dnd5e/templates/inventory/inventory.hbs", "systems/dnd5e/templates/inventory/activity.hbs"],
      scrollable: [""]
    },
    spells: {
      container: { classes: ["tab-body"], id: "tabs" },
      template: "systems/dnd5e/templates/actors/tabs/creature-spells.hbs",
      templates: ["systems/dnd5e/templates/inventory/inventory.hbs", "systems/dnd5e/templates/inventory/activity.hbs"],
      scrollable: [""]
    },
    effects: {
      container: { classes: ["tab-body"], id: "tabs" },
      template: "systems/dnd5e/templates/actors/tabs/actor-effects.hbs",
      scrollable: [""]
    },
    biography: {
      container: { classes: ["tab-body"], id: "tabs" },
      template: "systems/dnd5e/templates/actors/tabs/character-biography.hbs",
      scrollable: [""]
    },
    bastion: {
      container: { classes: ["tab-body"], id: "tabs" },
      template: "systems/dnd5e/templates/actors/tabs/character-bastion.hbs",
      scrollable: [""]
    },
    specialTraits: {
      classes: ["flexcol"],
      container: { classes: ["tab-body"], id: "tabs" },
      template: "systems/dnd5e/templates/actors/tabs/creature-special-traits.hbs",
      scrollable: [""]
    },
    abilityScores: {
      template: "systems/dnd5e/templates/actors/character-ability-scores.hbs"
    },
    warnings: {
      template: "systems/dnd5e/templates/actors/parts/actor-warnings-dialog.hbs"
    },
    tabs: {
      id: "tabs",
      classes: ["tabs-right"],
      template: "systems/dnd5e/templates/shared/sidebar-tabs.hbs"
    }
  };

  /* -------------------------------------------- */

  /**
   * Proficiency class names.
   * @enum {string}
   */
  static PROFICIENCY_CLASSES = {
    0: "none",
    0.5: "half",
    1: "full",
    2: "double"
  };

  /* -------------------------------------------- */

  /** @override */
  static TABS = [
    { tab: "details", label: "DND5E.Details", icon: "fas fa-cog" },
    { tab: "inventory", label: "DND5E.Inventory", svg: "systems/dnd5e/icons/svg/backpack.svg" },
    { tab: "features", label: "DND5E.Features", icon: "fas fa-list" },
    { tab: "spells", label: "TYPES.Item.spellPl", icon: "fas fa-book" },
    { tab: "effects", label: "DND5E.Effects", icon: "fas fa-bolt" },
    { tab: "biography", label: "DND5E.Biography", icon: "fas fa-feather" },
    { tab: "bastion", label: "DND5E.Bastion.Label", icon: "fas fa-chess-rook" },
    { tab: "specialTraits", label: "DND5E.SpecialTraits", icon: "fas fa-star" }
  ];

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Whether the user has manually opened the death save tray.
   * @type {boolean}
   * @protected
   */
  _deathTrayOpen = false;

  /* -------------------------------------------- */

  /** @override */
  _filters = {
    features: { name: "", properties: new Set() },
    effects: { name: "", properties: new Set() },
    inventory: { name: "", properties: new Set() },
    spells: { name: "", properties: new Set() }
  };

  /* -------------------------------------------- */

  /** @override */
  tabGroups = {
    primary: "details"
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @override */
  async _configureInventorySections(sections) {
    sections.forEach(s => {
      s.minWidth = 250;
      if ( s.id === "weapons" ) s.columns = ["price", "weight", "quantity", "charges", "roll", "formula", "controls"];
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = {
      ...await super._prepareContext(options),
      abilityRows: {
        bottom: [], top: [], optional: Object.keys(CONFIG.DND5E.abilities).length - 6
      },
      isCharacter: true
    };
    context.spellbook = this._prepareSpellbook(context);
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    switch ( partId ) {
      case "abilityScores": return this._prepareAbilityScoresContext(context, options);
      case "bastion": return this._prepareBastionContext(context, options);
      case "biography": return this._prepareBiographyContext(context, options);
      case "details": return this._prepareDetailsContext(context, options);
      case "effects": return this._prepareEffectsContext(context, options);
      case "features": return this._prepareFeaturesContext(context, options);
      case "header": return this._prepareHeaderContext(context, options);
      case "inventory": return this._prepareInventoryContext(context, options);
      case "sidebar": return this._prepareSidebarContext(context, options);
      case "specialTraits": return this._prepareSpecialTraitsContext(context, options);
      case "spells": return this._prepareSpellsContext(context, options);
      case "tabs": return this._prepareTabsContext(context, options);
      default: return context;
    }
  }

  /* -------------------------------------------- */

  /**
   * Prepare rendering context for the ability scores.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {ApplicationRenderContext}
   * @protected
   */
  async _prepareAbilityScoresContext(context, options) {
    for ( const ability of this._prepareAbilities(context) ) {
      if ( context.abilityRows.bottom.length > 5 ) context.abilityRows.top.push(ability);
      else context.abilityRows.bottom.push(ability);
    }
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare rendering context for the bastion tab.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {ApplicationRenderContext}
   * @protected
   */
  async _prepareBastionContext(context, options) {
    context.bastion = {
      description: await TextEditor$8.enrichHTML(this.actor.system.bastion.description, {
        secrets: this.actor.isOwner, relativeTo: this.actor, rollData: context.rollData
      })
    };
    context.defenders = [];
    context.facilities = { basic: { chosen: [] }, special: { chosen: [] } };

    for ( const facility of context.itemCategories.facilities ?? [] ) {
      const ctx = context.itemContext[facility.id] ?? {};
      context.defenders.push(...ctx.defenders.map(({ actor }) => {
        if ( !actor ) return null;
        const { img, name, uuid } = actor;
        return { img, name, uuid, facility: facility.id };
      }).filter(_ => _));
      if ( ctx.isSpecial ) context.facilities.special.chosen.push(ctx);
      else context.facilities.basic.chosen.push(ctx);
    }

    for ( const [type, facilities] of Object.entries(context.facilities) ) {
      const config = CONFIG.DND5E.facilities.advancement[type];
      let [, available] = Object.entries(config).reverse().find(([level]) => {
        return level <= this.actor.system.details.level;
      }) ?? [];
      facilities.value = facilities.chosen.filter(({ free }) => (type === "basic") || !free).length;
      facilities.max = available ?? 0;
      available = (available ?? 0) - facilities.value;
      facilities.available = Array.fromRange(Math.max(0, available)).map(() => {
        return { label: `DND5E.FACILITY.AvailableFacility.${type}.free` };
      });
    }

    if ( !context.facilities.basic.available.length ) {
      context.facilities.basic.available.push({ label: "DND5E.FACILITY.AvailableFacility.basic.build" });
    }

    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare rendering context for the biography tab.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {ApplicationRenderContext}
   * @protected
   */
  async _prepareBiographyContext(context, options) {
    const enrichmentOptions = {
      secrets: this.actor.isOwner, relativeTo: this.actor, rollData: context.rollData
    };
    context.enriched = {
      label: "DND5E.Biography",
      value: await TextEditor$8.enrichHTML(this.actor.system.details.biography.value, enrichmentOptions)
    };

    // Characteristics
    context.characteristics = [
      "alignment", "eyes", "height", "faith", "hair", "weight", "gender", "skin", "age"
    ].map(k => {
      const field = this.actor.system.schema.fields.details.fields[k];
      const name = `system.details.${k}`;
      return {
        name, label: field.label,
        value: foundry.utils.getProperty(this.actor, name) ?? "",
        source: foundry.utils.getProperty(this.actor._source, name) ?? ""
      };
    });

    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare rendering context for the details tab.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {ApplicationRenderContext}
   * @protected
   */
  async _prepareDetailsContext(context, options) {
    const { details, traits } = this.actor.system;

    // Origin
    context.creatureType = {
      class: details.type.value === "custom" ? "none" : "",
      icon: CONFIG.DND5E.creatureTypes[details.type.value]?.icon ?? "icons/svg/mystery-man.svg",
      title: details.type.value === "custom"
        ? details.type.custom
        : CONFIG.DND5E.creatureTypes[details.type.value]?.label,
      reference: CONFIG.DND5E.creatureTypes[details.type.value]?.reference,
      subtitle: details.type.subtype
    };
    if ( details.race instanceof dnd5e.documents.Item5e ) context.species = details.race;
    if ( details.background instanceof dnd5e.documents.Item5e ) context.background = details.background;
    context.labels.size = CONFIG.DND5E.actorSizes[traits.size]?.label ?? traits.size;

    // Saving Throws
    context.saves = {};
    for ( let ability of Object.values(this._prepareAbilities(context)) ) {
      ability = context.saves[ability.key] = { ...ability };
      ability.class = this.constructor.PROFICIENCY_CLASSES[context.editable ? ability.baseProf : ability.proficient];
    }
    if ( this.actor.statuses.has(CONFIG.specialStatusEffects.CONCENTRATING) || context.editable ) {
      context.saves.concentration = {
        isConcentration: true,
        class: "colspan concentration",
        label: game.i18n.localize("DND5E.Concentration"),
        abbr: game.i18n.localize("DND5E.Concentration"),
        save: context.system.attributes.concentration.save
      };
    }

    // Senses
    context.senses = this._prepareSenses(context);

    // Skills & Tools
    context.skills = this._prepareSkillsTools(context, "skills");
    context.tools = this._prepareSkillsTools(context, "tools");
    for ( const [key, entry] of Object.entries(context.skills).concat(Object.entries(context.tools)) ) {
      entry.class = this.constructor.PROFICIENCY_CLASSES[context.editable ? entry.baseValue : entry.value];
      if ( key in CONFIG.DND5E.skills ) entry.reference = CONFIG.DND5E.skills[key].reference;
      else if ( key in CONFIG.DND5E.tools ) entry.reference = getBaseItemUUID(CONFIG.DND5E.tools[key].id);
    }

    // Traits
    context.traits = this._prepareTraits(context);

    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareEffectsContext(context, options) {
    context = await super._prepareEffectsContext(context, options);
    context.hasConditions = true;
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare rendering context for the features tab.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {ApplicationRenderContext}
   * @protected
   */
  async _prepareFeaturesContext(context, options) {
    // Classes
    context.subclasses = context.itemCategories.subclasses ?? [];
    context.classes = (context.itemCategories.classes ?? [])
      .sort((lhs, rhs) => rhs.system.levels - lhs.system.levels);
    for ( const cls of context.classes ) {
      const ctx = context.itemContext[cls.id] ??= {};
      const subclass = context.subclasses.findSplice(s => s.system.classIdentifier === cls.identifier);
      if ( !subclass ) {
        const subclassAdvancement = cls.advancement.byType.Subclass?.[0];
        if ( subclassAdvancement && (subclassAdvancement.level <= cls.system.levels) ) ctx.needsSubclass = true;
      }
    }

    // List
    const Inventory = customElements.get(this.options.elements.inventory);
    const columns = Inventory.mapColumns([{ id: "uses", order: 200 }, "recovery", "controls"]);
    const sections = [
      { columns, id: "active", label: "DND5E.FeatureActive", order: 100, groups: { activation: "active" }, items: [] },
      { columns, id: "passive", label: "DND5E.FeaturePassive", order: 200, groups: { activation: "passive" } },
      ...Object.values(this.actor.classes ?? {})
        .sort((a, b) => b.system.levels - a.system.levels)
        .map((cls, i) => {
          return {
            columns, id: cls.identifier, order: i * 100, groups: { origin: cls.identifier },
            label: game.i18n.format("DND5E.FeaturesClass", { class: cls.name })
          };
        }),
      this.actor.system.details.race instanceof Item5e ? {
        columns, id: "species", label: "DND5E.Species.Features", order: 1000, groups: { origin: "species" }
      } : null,
      this.actor.system.details.background instanceof Item5e ? {
        columns, id: "background", label: "DND5E.FeaturesBackground", order: 2000, groups: { origin: "background" }
      } : null,
      { columns, id: "other", label: "DND5E.FeaturesOther", order: 3000, groups: { origin: "other" } }
    ].filter(_ => _);
    sections[0].items = [...(context.itemCategories.features ?? []), ...context.subclasses];
    context.sections = Inventory.prepareSections(sections);
    context.listControls = {
      label: "DND5E.FeatureSearch",
      list: "features",
      filters: [
        { key: "action", label: "DND5E.Action" },
        { key: "bonus", label: "DND5E.BonusAction" },
        { key: "reaction", label: "DND5E.Reaction" },
        { key: "sr", label: "DND5E.REST.Short.Label" },
        { key: "lr", label: "DND5E.REST.Long.Label" },
        { key: "concentration", label: "DND5E.Concentration" },
        { key: "mgc", label: "DND5E.ITEM.Property.Magical" }
      ],
      sorting: [
        { key: "m", label: "SIDEBAR.SortModeManual", dataset: { icon: "fa-solid fa-arrow-down-short-wide" } },
        { key: "a", label: "SIDEBAR.SortModeAlpha", dataset: { icon: "fa-solid fa-arrow-down-a-z" } }
      ],
      grouping: [
        {
          key: "origin",
          label: "DND5E.FilterGroupOrigin",
          dataset: { icon: "fa-solid fa-layer-group", classes: "active" }
        },
        { key: "activation", label: "DND5E.FilterGroupOrigin", dataset: { icon: "fa-solid fa-layer-group" } }
      ]
    };

    // TODO: Add this warning during data preparation instead
    // const message = game.i18n.format("DND5E.SubclassMismatchWarn", {
    //   name: subclass.name, class: subclass.system.classIdentifier
    // });
    // context.warnings.push({ message, type: "warning" });
    context.showClassDrop = !context.classes.length || (this._mode === this.constructor.MODES.EDIT);
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare rendering context for the header.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {ApplicationRenderContext}
   * @protected
   */
  async _prepareHeaderContext(context, options) {
    if ( this.actor.limited ) {
      context.portrait = await this._preparePortrait(context);
      return context;
    }

    // Classes Label
    context.labels.class = Object.values(this.actor.classes).sort((a, b) => {
      return b.system.levels - a.system.levels;
    }).map(c => `${c.name} ${c.system.levels}`).join(" / ");

    // Experience & Epic Boons
    if ( context.system.details.xp.boonsEarned !== undefined ) {
      const pluralRules = new Intl.PluralRules(game.i18n.lang);
      context.epicBoonsEarned = game.i18n.format(
        `DND5E.ExperiencePoints.Boons.${pluralRules.select(context.system.details.xp.boonsEarned ?? 0)}`,
        { number: formatNumber(context.system.details.xp.boonsEarned ?? 0, { signDisplay: "always" }) }
      );
    }

    // Visibility
    context.showExperience = game.settings.get("dnd5e", "levelingMode") !== "noxp";
    context.showRests = game.user.isGM || (this.actor.isOwner && game.settings.get("dnd5e", "allowRests"));

    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareInventoryContext(context, options) {
    context.itemCategories.inventory = context.itemCategories.inventory?.filter(i => i.type !== "container");
    context = await super._prepareInventoryContext(context, options);
    context.size = {
      label: CONFIG.DND5E.actorSizes[this.actor.system.traits.size]?.label ?? this.actor.system.traits.size,
      abbr: CONFIG.DND5E.actorSizes[this.actor.system.traits.size]?.abbreviation ?? "—",
      mod: this.actor.system.attributes.encumbrance.mod
    };
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare rendering context for the sidebar.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {ApplicationRenderContext}
   * @protected
   */
  async _prepareSidebarContext(context, options) {
    const { attributes } = this.actor.system;
    context.portrait = await this._preparePortrait(context);

    // Death Saves
    const plurals = new Intl.PluralRules(game.i18n.lang, { type: "ordinal" });
    context.death = {
      open: this._deathTrayOpen
    };
    for ( const deathSave of ["success", "failure"] ) {
      context.death[deathSave] = [];
      for ( let i = 1; i < 4; i++ ) {
        const n = deathSave === "failure" ? i : 4 - i;
        const i18nKey = `DND5E.DeathSave${deathSave.titleCase()}Label`;
        const filled = attributes.death[deathSave] >= n;
        const classes = ["pip"];
        if ( filled ) classes.push("filled");
        if ( deathSave === "failure" ) classes.push("failure");
        context.death[deathSave].push({
          n, filled,
          tooltip: i18nKey,
          label: game.i18n.localize(`${i18nKey}N.${plurals.select(n)}`),
          classes: classes.join(" ")
        });
      }
    }

    // Exhaustion
    if ( CONFIG.DND5E.conditionTypes.exhaustion ) {
      const max = CONFIG.DND5E.conditionTypes.exhaustion.levels;
      context.exhaustion = Array.fromRange(max, 1).reduce((acc, n) => {
        const label = game.i18n.format("DND5E.ExhaustionLevel", { n });
        const classes = ["pip"];
        const filled = attributes.exhaustion >= n;
        if ( filled ) classes.push("filled");
        if ( n === max ) classes.push("death");
        const pip = { n, label, filled, tooltip: label, classes: classes.join(" ") };

        if ( n <= max / 2 ) acc.left.push(pip);
        else acc.right.push(pip);
        return acc;
      }, { left: [], right: [] });
    }

    // Favorites
    context.favorites = await this._prepareFavorites();

    // Speed
    context.speed = Object.entries(CONFIG.DND5E.movementTypes).reduce((obj, [k, { label }]) => {
      const value = attributes.movement[k];
      if ( value > obj.value ) Object.assign(obj, { value, label });
      return obj;
    }, { value: 0, label: CONFIG.DND5E.movementTypes.walk?.label });

    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareSpellsContext(context, options) {
    context = await super._prepareSpellsContext(context, options);

    // Spellcasting
    context.spellcasting = [];
    const spellcastingClasses = Object.values(this.actor.spellcastingClasses)
      .sort((lhs, rhs) => rhs.system.levels - lhs.system.levels);
    for ( const item of spellcastingClasses ) {
      const sc = item.spellcasting;
      const ability = this.actor.system.abilities[sc.ability];
      const mod = ability?.mod ?? 0;
      const name = item.system.spellcasting.progression === sc.progression ? item.name : item.subclass?.name;
      context.spellcasting.push({
        label: game.i18n.format("DND5E.SpellcastingClass", { class: name }),
        ability: { mod, ability: sc.ability },
        attack: sc.attack,
        preparation: sc.preparation,
        primary: this.actor.system.attributes.spellcasting === sc.ability,
        save: sc.save
      });
      const key = item.system.spellcasting.progression === sc.progression ? item.identifier : item.subclass?.identifier;
      context.listControls.filters.push({ key, label: name });
    }

    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareTabsContext(context, options) {
    const { basic, special } = CONFIG.DND5E.facilities.advancement;
    const threshold = Math.min(...Object.keys(basic), ...Object.keys(special));
    const showBastion = game.settings.get("dnd5e", "bastionConfiguration")?.enabled
      && (this.actor.system.details.level >= threshold);
    if ( !showBastion && (this.tabGroups.primary === "bastion") ) this.tabGroups.primary = "details";

    context = await super._prepareTabsContext(context, options);

    if ( !showBastion ) context.tabs.findSplice(t => t.tab === "bastion");
    return context;
  }

  /* -------------------------------------------- */
  /*  Actor Preparation Helpers                   */
  /* -------------------------------------------- */

  /**
   * Prepare favorites for display.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @returns {Promise<object>}
   * @protected
   */
  async _prepareFavorites(context) {
    // Legacy resources
    const resources = Object.entries(this.actor.system.resources).reduce((arr, [k, r]) => {
      const { value, max, sr, lr, label } = r;
      const source = this.actor._source.system.resources[k];
      if ( label && max ) arr.push({
        id: `resources.${k}`,
        type: "resource",
        img: "icons/svg/upgrade.svg",
        resource: { value, max, source },
        css: "uses",
        title: label,
        subtitle: [
          sr ? game.i18n.localize("DND5E.AbbreviationSR") : null,
          lr ? game.i18n.localize("DND5E.AbbreviationLR") : null
        ].filterJoin(" &bull; ")
      });
      return arr;
    }, []);

    return resources.concat(await this.actor.system.favorites.reduce(async (arr, f) => {
      const { id, type, sort } = f;
      const favorite = await fromUuid(id, { relative: this.actor });
      if ( !favorite && ((type === "item") || (type === "effect") || (type === "activity")) ) return arr;
      if ( favorite?.dependentOrigin?.active === false ) return arr;
      arr = await arr;

      let data;
      if ( type === "item" ) data = await favorite.system.getFavoriteData();
      else if ( (type === "effect") || (type === "activity") ) data = await favorite.getFavoriteData();
      else data = await this._getFavoriteData(type, id);
      if ( !data ) return arr;
      let {
        img, title, subtitle, value, uses, quantity, modifier, passive,
        save, range, reference, toggle, suppressed, level
      } = data;

      if ( foundry.utils.getType(save?.ability) === "Set" ) save = {
        ...save, ability: save.ability.size > 2
          ? game.i18n.localize("DND5E.AbbreviationDC")
          : Array.from(save.ability).map(k => CONFIG.DND5E.abilities[k]?.abbreviation).filterJoin(" / ")
      };

      const css = [];
      if ( uses?.max ) {
        css.push("uses");
        uses.value = Math.round(uses.value);
      }
      else if ( modifier !== undefined ) css.push("modifier");
      else if ( save?.dc ) css.push("save");
      else if ( value !== undefined ) css.push("value");

      if ( toggle === false ) css.push("disabled");
      if ( uses?.max > 99 ) css.push("uses-sm");
      if ( modifier !== undefined ) {
        const value = Number(modifier.replace?.(/\s+/g, "") ?? modifier);
        if ( !isNaN(value) ) modifier = value;
      }

      const rollableClass = [];
      if ( this.isEditable && (type !== "slots") ) rollableClass.push("rollable");
      if ( type === "skill" ) rollableClass.push("skill-name");
      else if ( type === "tool" ) rollableClass.push("tool-name");

      if ( suppressed ) subtitle = game.i18n.localize("DND5E.Suppressed");
      const itemId = type === "item" ? favorite.id : type === "activity" ? favorite.item.id : null;
      arr.push({
        id, img, type, title, value, uses, sort, save, modifier, passive, range, reference, suppressed, level, itemId,
        draggable: ["item", "effect"].includes(type),
        effectId: type === "effect" ? favorite.id : null,
        parentId: (type === "effect") && (favorite.parent !== favorite.target) ? favorite.parent.id: null,
        activityId: type === "activity" ? favorite.id : null,
        key: (type === "skill") || (type === "tool") ? id : null,
        toggle: toggle === undefined ? null : { applicable: true, value: toggle },
        quantity: quantity > 1 ? quantity : "",
        rollableClass: rollableClass.filterJoin(" "),
        css: css.filterJoin(" "),
        bareName: type === "slots",
        subtitle: Array.isArray(subtitle) ? subtitle.filterJoin(" &bull; ") : subtitle
      });
      return arr;
    }, [])).sort((a, b) => a.sort - b.sort);
  }

  /* -------------------------------------------- */

  /**
   * Prepare data for a favorited entry.
   * @param {"skill"|"tool"|"slots"} type  The type of favorite.
   * @param {string} id                    The favorite's identifier.
   * @returns {Promise<FavoriteData5e|void>}
   * @protected
   */
  async _getFavoriteData(type, id) {
    // Spell slots
    if ( type === "slots" ) {
      const { value, max, level, type: method } = this.actor.system.spells?.[id] ?? {};
      const model = CONFIG.DND5E.spellcasting[method];
      const uses = { value, max, name: `system.spells.${id}.value` };
      if ( !model || model.isSingleLevel ) return {
        uses, level, method,
        title: game.i18n.localize(`DND5E.SpellSlots${id.capitalize()}`),
        subtitle: [
          game.i18n.localize(`DND5E.SpellLevel${level}`),
          game.i18n.localize(`DND5E.Abbreviation${model?.isSR ? "SR" : "LR"}`)
        ],
        img: model?.img || CONFIG.DND5E.spellcasting.pact.img
      };

      const plurals = new Intl.PluralRules(game.i18n.lang, { type: "ordinal" });
      return {
        uses, level, method,
        title: game.i18n.format(`DND5E.SpellSlotsN.${plurals.select(level)}`, { n: level }),
        subtitle: game.i18n.localize(`DND5E.Abbreviation${model.isSR ? "SR" : "LR"}`),
        img: model.img.replace("{id}", id)
      };
    }

    // Skills & Tools
    else {
      const data = this.actor.system[`${type}s`]?.[id];
      if ( !data ) return;
      const { total, ability, passive } = data ?? {};
      const subtitle = game.i18n.format("DND5E.AbilityPromptTitle", {
        ability: CONFIG.DND5E.abilities[ability].label
      });
      let img;
      let title;
      let reference;
      if ( type === "tool" ) {
        reference = getBaseItemUUID(CONFIG.DND5E.tools[id]?.id);
        ({ img, name: title } = getBaseItem(reference, { indexOnly: true }));
      }
      else if ( type === "skill" ) ({ icon: img, label: title, reference } = CONFIG.DND5E.skills[id]);
      return { img, title, subtitle, modifier: total, passive, reference };
    }
  }

  /* -------------------------------------------- */
  /*  Item Preparation Helpers                    */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _assignItemCategories(item) {
    switch ( item.type ) {
      case "background": return new Set(["background"]);
      case "class": return new Set(["classes"]);
      case "facility": return new Set(["facilities"]);
      case "race": return new Set(["species"]);
      case "subclass": return new Set(["subclasses"]);
      default: return super._assignItemCategories(item);
    }
  }

  /* -------------------------------------------- */

  /**
   * Prepare context for a facility.
   * @param {Item5e} item  Item being prepared for display.
   * @param {object} ctx   Item specific context.
   * @protected
   */
  async _prepareItemFacility(item, ctx) {
    const { id, img, labels, name, system } = item;
    const { building, craft, defenders, disabled, free, hirelings, progress, size, trade, type } = system;
    const subtitle = [
      building.built ? CONFIG.DND5E.facilities.sizes[size].label : game.i18n.localize("DND5E.FACILITY.Build.Unbuilt")
    ];
    if ( trade.stock.max ) subtitle.push(`${trade.stock.value ?? 0} &sol; ${trade.stock.max}`);
    Object.assign(ctx, {
      id, labels, name, building, disabled, free, progress,
      craft: craft.item ? await fromUuid(craft.item) : null,
      creatures: await this._prepareItemFacilityLivestock(trade),
      defenders: await this._prepareItemFacilityOccupants(defenders),
      executing: CONFIG.DND5E.facilities.orders[progress.order]?.icon,
      hirelings: await this._prepareItemFacilityOccupants(hirelings),
      img: foundry.utils.getRoute(img),
      isSpecial: type.value === "special",
      subtitle: subtitle.join(" &bull; ")
    });
  }

  /* -------------------------------------------- */

  /**
   * Prepare facility livestock for display.
   * @param {object} trade  Facility trade information.
   * @returns {Promise<object[]>}
   * @protected
   */
  async _prepareItemFacilityLivestock(trade) {
    const creatures = await this._prepareItemFacilityOccupants(trade.creatures);
    const pending = trade.pending.creatures;
    return [
      ...(await Promise.all((pending ?? []).map(async (uuid, index) => {
        return { index, actor: await fromUuid(uuid), pending: true };
      }))),
      ...creatures
    ];
  }

  /* -------------------------------------------- */

  /**
   * Prepare facility occupants for display.
   * @param {FacilityOccupants} occupants  The occupants.
   * @returns {Promise<object[]>}
   * @protected
   */
  _prepareItemFacilityOccupants(occupants) {
    const { max, value } = occupants;
    return Promise.all(Array.fromRange(max).map(async index => {
      const uuid = value[index];
      if ( uuid ) return { index, actor: await fromUuid(uuid) };
      return { empty: true };
    }));
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareItemFeature(item, ctx) {
    if ( item.type === "facility" ) return this._prepareItemFacility(item, ctx);

    await super._prepareItemFeature(item, ctx);

    const [originId] = (item.getFlag("dnd5e", "advancementRoot") ?? item.getFlag("dnd5e", "advancementOrigin"))
      ?.split(".") ?? [];
    const group = item.parent.items.get(originId);
    ctx.groups.origin = "other";
    switch ( group?.type ) {
      case "race": ctx.groups.origin = "species"; break;
      case "background": ctx.groups.origin = "background"; break;
      case "class": ctx.groups.origin = group.identifier; break;
      case "subclass": ctx.groups.origin = group.class?.identifier ?? "other"; break;
    }

    ctx.groups.activation = item.system.properties?.has("trait") || !item.system.activities?.size
      ? "passive"
      : "active";
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareItemPhysical(item, ctx) {
    ctx.concealDetails = !game.user.isGM && (item.system.identified === false);
    ctx.isStack = Number.isNumeric(item.system.quantity) && (item.system.quantity !== 1);

    if ( item.system.attunement ) ctx.attunement = item.system.attuned ? {
      icon: "fa-sun",
      cls: "attuned",
      title: "DND5E.AttunementAttuned"
    } : {
      icon: "fa-sun",
      cls: "not-attuned",
      title: CONFIG.DND5E.attunementTypes[item.system.attunement]
    };

    return super._prepareItemPhysical(item, ctx);
  }

  /* -------------------------------------------- */
  /*  Life-Cycle Handlers                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onFirstRender(context, options) {
    await super._onFirstRender(context, options);

    // Apply special context menus for items outside inventory elements
    const featuresElement = this.element.querySelector(`[data-tab="features"] ${this.options.elements.inventory}`);
    if ( featuresElement ) new ContextMenu5e(
      this.element, ".pills-lg [data-item-id], .favorites [data-item-id], .facility[data-item-id]", [],
      { onOpen: (...args) => featuresElement._onOpenContextMenu(...args), jQuery: false }
    );
    const inventoryElement = this.element.querySelector(`[data-tab="inventory"] ${this.options.elements.inventory}`);
    if ( inventoryElement ) new ContextMenu5e(
      this.element, ".containers [data-item-id]", [],
      { onOpen: (...args) => featuresElement._onOpenContextMenu(...args), jQuery: false }
    );
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);

    if ( !this.actor.limited ) {
      this._renderAttunement(context, options);
      this._renderSpellbook(context, options);
    }

    // Show death tray at 0 HP
    const renderContext = options.renderContext ?? options.action;
    const renderData = options.renderData ?? options.data;
    const isUpdate = (renderContext === "update") || (renderContext === "updateActor");
    const hp = foundry.utils.getProperty(renderData ?? {}, "system.attributes.hp.value");
    if ( isUpdate && (hp === 0) ) this._toggleDeathTray(true);
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle removing a favorite.
   * @this {CharacterActorSheet}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static #deleteFavorite(event, target) {
    const { favoriteId } = target.closest("[data-favorite-id]")?.dataset ?? {};
    if ( favoriteId ) this.actor.system.removeFavorite(favoriteId);
  }

  /* -------------------------------------------- */

  /**
   * Handle deleting an occupant from a facility.
   * @this {CharacterActorSheet}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static async #deleteOccupant(event, target) {
    const { facilityId } = target.closest("[data-facility-id]")?.dataset ?? {};
    const { prop } = target.closest("[data-prop]")?.dataset ?? {};
    const { index } = target.closest("[data-index]")?.dataset ?? {};
    const facility = this.actor.items.get(facilityId);
    if ( !facility || !prop || (index === undefined) ) return;

    // Prompt to clear a pending trade
    if ( target.closest(".occupant-slot.pending") ) {
      const result = await foundry.applications.api.DialogV2.confirm({
        content: `
          <p>
            <strong>${game.i18n.localize("AreYouSure")}</strong> ${game.i18n.localize("DND5E.Bastion.Trade.Invalid")}
          </p>
        `,
        window: {
          icon: "fa-solid fa-coins",
          title: "DND5E.Bastion.Trade.Cancel"
        },
        position: { width: 400 }
      }, { rejectClose: false });
      if ( result ) facility.update({
        system: {
          progress: { max: null, order: "", value: null },
          trade: {
            pending: { creatures: [], operation: null }
          }
        }
      });
    }

    // Remove the occupant
    else {
      let { value } = foundry.utils.getProperty(facility, prop);
      value = value.filter((_, i) => i !== Number(index));
      facility.update({ [`${prop}.value`]: value });
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle finding an available item of a given type.
   * @this {CharacterActorSheet}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static async #findItem(event, target) {
    if ( !this.isEditable ) return;
    const { classIdentifier, facilityType, itemType: type } = target.dataset;
    const filters = { locked: { types: new Set([type]) } };

    if ( classIdentifier ) filters.locked.additional = { class: { [classIdentifier]: 1 } };
    if ( type === "class" ) {
      const existingIdentifiers = new Set(Object.keys(this.actor.classes));
      filters.initial = { additional: { properties: { sidekick: -1 } } };
      filters.locked.arbitrary = [{ o: "NOT", v: { k: "system.identifier", o: "in", v: existingIdentifiers } }];
    }
    if ( type === "facility" ) {
      const otherType = facilityType === "basic" ? "special" : "basic";
      filters.locked.additional = {
        type: { [facilityType]: 1, [otherType]: -1 },
        level: { max: this.actor.system.details.level }
      };
    }

    const result = await CompendiumBrowser.selectOne({ filters });
    if ( result ) this._onDropCreateItems(event, [game.items.fromCompendium(await fromUuid(result), { keepId: true })]);
  }

  /* -------------------------------------------- */

  /**
   * Handle setting the character's spellcasting ability.
   * @this {CharacterActorSheet}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static #setSpellcastingAbility(event, target) {
    const ability = target.closest("[data-ability]")?.dataset.ability;
    this.submit({ updateData: { "system.attributes.spellcasting": ability } });
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling the death saves tray.
   * @this {CharacterActorSheet}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static #toggleDeathTray(event, target) {
    this._toggleDeathTray();
  }

  /* -------------------------------------------- */

  /**
   * Toggle the death save tray.
   * @param {boolean} [open]  Force a particular open state.
   * @protected
   */
  _toggleDeathTray(open) {
    const tray = this.form.querySelector(".death-tray");
    const tab = tray.querySelector(".death-tab");
    tray.classList.toggle("open", open);
    this._deathTrayOpen = tray.classList.contains("open");
    tab.dataset.tooltip = `DND5E.DeathSave${this._deathTrayOpen ? "Hide" : "Show"}`;
    tab.setAttribute("aria-label", game.i18n.localize(tab.dataset.tooltip));
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling inspiration.
   * @this {CharacterActorSheet}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static #toggleInspiration(event, target) {
    this.submit({ updateData: { "system.attributes.inspiration": !this.actor.system.attributes.inspiration } });
  }

  /* -------------------------------------------- */

  /**
   * Handle using a facility.
   * @this {CharacterActorSheet}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static #useFacility(event, target) {
    if ( !target.classList.contains("rollable") ) return;
    const { facilityId } = target.closest("[data-facility-id]")?.dataset ?? {};
    const facility = this.actor.items.get(facilityId);
    facility?.use({ legacy: false, chooseActivity: true, event });
  }

  /* -------------------------------------------- */

  /**
   * Handle using a favorited item.
   * @this {CharacterActorSheet}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static async #useFavorite(event, target) {
    if ( !this.isEditable || (event.target.tagName === "INPUT") ) return;
    const { favoriteId } = target.closest("[data-favorite-id]").dataset;
    const favorite = await fromUuid(favoriteId, { relative: this.actor });
    if ( (favorite instanceof dnd5e.documents.Item5e) || target.dataset.activityId ) {
      if ( favorite.type === "container" ) favorite.sheet.render({ force: true });
      else favorite.use({ event });
    }
    else if ( favorite instanceof dnd5e.dataModels.activity.BaseActivityData ) {
      if ( favorite.canUse ) favorite.use({ event });
    }
    else if ( favorite instanceof dnd5e.documents.ActiveEffect5e ) favorite.update({ disabled: !favorite.disabled });
    else {
      const { key } = target.closest("[data-key]")?.dataset ?? {};
      if ( key ) {
        if ( target.classList.contains("skill-name") ) this.actor.rollSkill({ event, skill: key });
        else if ( target.classList.contains("tool-name") ) this.actor.rollToolCheck({ event, tool: key });
      }
    }
  }

  /* -------------------------------------------- */
  /*  Drag & Drop                                 */
  /* -------------------------------------------- */

  /** @override */
  _defaultDropBehavior(event, data) {
    if ( data.dnd5e?.action === "favorite" || (["Activity", "Item"].includes(data.type)
      && event.target.closest(".favorites")) ) return "link";
    return super._defaultDropBehavior(event, data);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDragStart(event) {
    const methods = CONFIG.DND5E.spellcasting;
    const { key } = event.target.closest("[data-key]")?.dataset ?? {};
    const { level, method } = event.target.closest("[data-level]")?.dataset ?? {};
    const isSlots = event.target.closest("[data-favorite-id]") || event.target.classList.contains("items-header");
    let type;
    if ( key in CONFIG.DND5E.skills ) type = "skill";
    else if ( key in CONFIG.DND5E.tools ) type = "tool";
    else if ( methods[method]?.slots && (level !== "0") && isSlots ) type = "slots";
    if ( !type ) return super._onDragStart(event);

    // Add another deferred deactivation to catch the second pointerenter event that seems to be fired on Firefox.
    requestAnimationFrame(() => game.tooltip.deactivate());
    game.tooltip.deactivate();

    const dragData = { dnd5e: { action: "favorite", type } };
    if ( type === "slots" ) dragData.dnd5e.id = methods[method].getSpellSlotKey(Number(level));
    else dragData.dnd5e.id = key;
    event.dataTransfer.setData("application/json", JSON.stringify(dragData));
    event.dataTransfer.effectAllowed = "link";
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onDrop(event) {
    if ( !event.target.closest(".favorites") ) return super._onDrop(event);
    const dragData = event.dataTransfer.getData("application/json") || event.dataTransfer.getData("text/plain");
    if ( !dragData ) return super._onDrop(event);
    let data;
    try {
      data = JSON.parse(dragData);
    } catch(e) {
      console.error(e);
      return;
    }
    const { action, type, id } = data.dnd5e ?? {};
    if ( action === "favorite" ) return this._onDropFavorite(event, { type, id });
    if ( data.type === "Activity" ) {
      const activity = await fromUuid(data.uuid);
      if ( activity ) return this._onDropActivity(event, activity);
    }
    return super._onDrop(event);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onDropActiveEffect(event, effect) {
    if ( !event.target.closest(".favorites") || (effect.target !== this.actor) ) {
      return super._onDropActiveEffect(event, effect);
    }
    const uuid = effect.getRelativeUUID(this.actor);
    return this._onDropFavorite(event, { type: "effect", id: uuid });
  }

  /* -------------------------------------------- */

  /**
   * Handle dropping an Activity onto the sheet.
   * @param {DragEvent} event    The originating drag event.
   * @param {Activity} activity  The dropped Activity document.
   * @returns {Promise<Actor5e|void>}
   * @protected
   */
  async _onDropActivity(event, activity) {
    if ( !event.target.closest(".favorites") || (activity.actor !== this.actor) ) return;
    const uuid = `${activity.item.getRelativeUUID(this.actor)}.Activity.${activity.id}`;
    return this._onDropFavorite(event, { type: "activity", id: uuid });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onDropActor(event, actor) {
    if ( !event.target.closest(".facility-occupants") || !actor.uuid ) return super._onDropActor(event, actor);
    const { facilityId } = event.target.closest("[data-facility-id]").dataset;
    const facility = this.actor.items.get(facilityId);
    if ( !facility ) return;
    const { prop } = event.target.closest("[data-prop]").dataset;
    const { max, value } = foundry.utils.getProperty(facility, prop);
    if ( (value.length + 1) > max ) return;
    return facility.update({ [`${prop}.value`]: [...value, actor.uuid] });
  }

  /* -------------------------------------------- */

  /**
   * Handle an owned item or effect being dropped in the favorites area.
   * @param {DragEvent} event            The triggering event.
   * @param {ActorFavorites5e} favorite  The favorite that was dropped.
   * @returns {Promise<Actor5e>|void}
   * @protected
   */
  _onDropFavorite(event, favorite) {
    if ( this.actor.system.hasFavorite(favorite.id) ) return this._onSortFavorites(event, favorite.id);
    return this.actor.system.addFavorite(favorite);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onDropItem(event, item) {
    if ( !event.target.closest(".favorites") || (item.parent !== this.actor) ) return super._onDropItem(event, item);
    const uuid = item.getRelativeUUID(this.actor);
    return this._onDropFavorite(event, { type: "item", id: uuid });
  }

  /* -------------------------------------------- */

  /** @override */
  async _onDropSingleItem(event, 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;
      }

      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({ force: true });
            return;
          }
        }
        cls.update({ "system.levels": priorLevel + itemData.system.levels });
        return;
      }
    }

    // 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;
      }
      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;
      }
    }

    return super._onDropSingleItem(event, itemData);
  }

  /* -------------------------------------------- */

  /**
   * Handle re-ordering the favorites list.
   * @param {DragEvent} event  The drop event.
   * @param {string} srcId     The identifier of the dropped favorite.
   * @returns {Promise<Actor5e>|void}
   * @protected
   */
  _onSortFavorites(event, srcId) {
    const dropTarget = event.target.closest("[data-favorite-id]");
    if ( !dropTarget ) return;
    let source;
    let target;
    const targetId = dropTarget.dataset.favoriteId;
    if ( srcId === targetId ) return;
    const siblings = this.actor.system.favorites.filter(f => {
      if ( f.id === targetId ) target = f;
      else if ( f.id === srcId ) source = f;
      return f.id !== srcId;
    });
    const updates = foundry.utils.performIntegerSort(source, { target, siblings });
    const favorites = this.actor.system.favorites.reduce((map, f) => map.set(f.id, { ...f }), new Map());
    for ( const { target, update } of updates ) {
      const favorite = favorites.get(target.id);
      foundry.utils.mergeObject(favorite, update);
    }
    return this.actor.update({ "system.favorites": Array.from(favorites.values()) });
  }

  /* -------------------------------------------- */
  /*  Filtering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _filterItem(item, filters) {
    const allowed = super._filterItem(item, filters);
    if ( allowed !== undefined ) return allowed;
    if ( item.type === "container" ) return true;
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  canExpand(item) {
    return !["background", "race", "facility"].includes(item.type) && super.canExpand(item);
  }
}

/**
 * An abstract class that implements functionality for sheets that contain multiple actors.
 * @extends {BaseActorSheet}
 */
class MultiActorSheet extends BaseActorSheet {
  /** @override */
  static DEFAULT_OPTIONS = {
    actions: {
      editDescription: MultiActorSheet.#onEditDescription,
      placeMembers: MultiActorSheet.#onPlaceMembers,
      removeMember: MultiActorSheet.#onRemoveMember
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  static unsupportedItemTypes = new Set([
    "background", "class", "facility", "feat", "race", "spell", "subclass"
  ]);

  /* -------------------------------------------- */

  /**
   * Description currently being edited.
   * @type {string|null}
   */
  editingDescriptionTarget = null;

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /**
   * Prepare rendering context for the description tab.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {ApplicationRenderContext}
   * @protected
   */
  async _prepareDescriptionContext(context, options) {
    const { full, summary } = this.actor.system.description;
    const enrichmentOptions = {
      secrets: this.actor.isOwner, relativeTo: this.actor, rollData: context.rollData
    };
    context.enriched = {
      label: "DND5E.Description",
      summary: await CONFIG.ux.TextEditor.enrichHTML(summary, enrichmentOptions),
      full: await CONFIG.ux.TextEditor.enrichHTML(full, enrichmentOptions)
    };
    context.enriched.value = full ? context.enriched.full : context.enriched.summary;
    if ( this.editingDescriptionTarget ) context.editingDescription = {
      target: this.editingDescriptionTarget,
      value: foundry.utils.getProperty(this.actor._source, this.editingDescriptionTarget)
    };

    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    switch ( partId ) {
      case "biography": return this._prepareDescriptionContext(context, options); // Limited sheets.
    }
    return context;
  }

  /* -------------------------------------------- */
  /*  Member Preparation                          */
  /* -------------------------------------------- */

  /**
   * Prepare portrait context for members.
   * @param {Actor5e} actor   The actor instance.
   * @param {object} context  The render context.
   * @protected
   */
  async _prepareMemberPortrait(actor, context) {
    const showTokenPortrait = this.actor.getFlag("dnd5e", "showTokenPortrait");
    const token = actor.isToken ? actor.token : actor.prototypeToken;
    const defaults = Actor.implementation.getDefaultArtwork(actor._source);
    let src = showTokenPortrait ? token.texture.src : actor.img;
    if ( showTokenPortrait && token?.randomImg ) {
      const images = await actor.getTokenImages();
      src = images[Math.floor(Math.random() * images.length)];
    }
    if ( !src ) src = showTokenPortrait ? defaults.texture.src : defaults.img;
    context.portrait = { src, isVideo: foundry.helpers.media.VideoHelper.hasVideoExtension(src) };
  }

  /* -------------------------------------------- */
  /*  Life-Cycle Handlers                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _attachFrameListeners() {
    super._attachFrameListeners();
    new ContextMenu5e(this.element, ".member[data-uuid]", this._getEntryContextOptions(), { jQuery: false });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    if ( this.editingDescriptionTarget ) {
      this.element.querySelectorAll("prose-mirror").forEach(editor => editor.addEventListener("save", () => {
        this.editingDescriptionTarget = null;
        this.render();
      }));
    }
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /** @override */
  async _onDropActor(event, actor) {
    await this.actor.system.addMember(actor);
    return actor;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onDropCreateItems(event, items, behavior) {
    let foundNonPhysical = false;
    items = items.filter(item => {
      if ( !item.system.constructor._schemaTemplates?.includes(PhysicalItemTemplate) ) {
        foundNonPhysical = true;
        return false;
      }
      return true;
    });
    if ( foundNonPhysical ) ui.notifications.warn("DND5E.Group.Warning.PhysicalItemOnly", { localize: true });
    return super._onDropCreateItems(event, items, behavior);
  }

  /* -------------------------------------------- */

  /**
   * Handle expanding the description editor.
   * @this {GroupActorSheet}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static #onEditDescription(event, target) {
    if ( target.ariaDisabled ) return;
    this.editingDescriptionTarget = target.dataset.target;
    this.render();
  }

  /* -------------------------------------------- */

  /**
   * Handle placing group members.
   * @this {MultiActorSheet}
   */
  static #onPlaceMembers() {
    this.actor.system.placeMembers();
  }

  /* -------------------------------------------- */

  /**
   * Handle removing a group member.
   * @this {MultiActorSheet}
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   * @returns {Promise<Actor5e>}
   */
  static async #onRemoveMember(event, target) {
    return this.actor.system.removeMember(await fromUuid(target.closest(".member[data-uuid]")?.dataset.uuid));
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Get context menu entries for group members.
   * @returns {ContextMenuEntry[]}
   * @protected
   */
  _getEntryContextOptions() {
    return [{
      name: "DND5E.Group.Action.View",
      icon: '<i class="fa-solid fa-eye"></i>',
      callback: async li => (await fromUuid(li.dataset.uuid))?.sheet.render(true)
    }, {
      name: "DND5E.Group.Action.Remove",
      icon: '<i class="fa-solid fa-xmark"></i>',
      callback: async li => this.actor.system.removeMember(await fromUuid(li.dataset.uuid))
    }];
  }

  /* -------------------------------------------- */
  /*  Sheet Configuration                         */
  /* -------------------------------------------- */

  /**
   * Augment the DocumentSheetConfig with additional options.
   * @param {DocumentSheetConfig} app  The application.
   * @param {HTMLElement} html         The rendered HTML.
   */
  static addDocumentSheetConfigOptions(app, html) {
    const { document: doc } = app.options;
    const showTokenPortrait = doc.getFlag("dnd5e", "showTokenPortrait");
    const artOptions = {
      false: game.i18n.localize("DND5E.Group.Config.Art.portraits"),
      true: game.i18n.localize("DND5E.Group.Config.Art.tokens")
    };
    const fieldset = document.createElement("fieldset");
    fieldset.innerHTML = `
      <legend>${game.i18n.localize("DND5E.Group.Config.Legend")}</legend>
      <div class="form-group">
        <label>${game.i18n.localize("DND5E.Group.Config.Art.Label")}</label>
        <div class="form-fields">
          <select name="flags.dnd5e.showTokenPortrait" data-dtype="Boolean">
            ${foundry.applications.handlebars.selectOptions(artOptions, { hash: { selected: showTokenPortrait } })}
          </select>
        </div>
      </div>
    `;
    html.querySelector("fieldset").insertAdjacentElement("afterend", fieldset);
    html.removeEventListener("submit", this._applyDocumentSheetConfigOptions);
    html.addEventListener("submit", this._applyDocumentSheetConfigOptions);
  }

  /* -------------------------------------------- */

  /**
   * Handle persisting additional sheet configuration options.
   * @param {SubmitEvent} event  The form submission event.
   * @protected
   */
  static _applyDocumentSheetConfigOptions(event) {
    const app = foundry.applications.instances.get(event.target.id);
    if ( !app?.document ) return;
    const submitData = foundry.utils.expandObject(new foundry.applications.ux.FormDataExtended(event.target).object);
    if ( "flags" in submitData ) app.document.update({ flags: submitData.flags });
  }
}

/**
 * Extension of the base actor sheet for encounter actors.
 */
class EncounterActorSheet extends MultiActorSheet {
  /** @override */
  static DEFAULT_OPTIONS = {
    actions: {
      award: EncounterActorSheet.#onAward,
      browse: EncounterActorSheet.#onBrowse,
      decrease: EncounterActorSheet.#onDecrease,
      increase: EncounterActorSheet.#onIncrease,
      rollQuantities: EncounterActorSheet.#onRollQuantities,
      rollQuantity: EncounterActorSheet.#onRollQuantity
    },
    classes: ["encounter"],
    position: {
      width: 500,
      height: "auto"
    },
    window: {
      resizable: false
    },
    tab: "members"
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    header: {
      template: "systems/dnd5e/templates/actors/encounter/header.hbs"
    },
    tabs: {
      template: "systems/dnd5e/templates/shared/horizontal-tabs.hbs",
      templates: ["templates/generic/tab-navigation.hbs"]
    },
    members: {
      container: { classes: ["tab-body"], id: "tabs" },
      template: "systems/dnd5e/templates/actors/encounter/members.hbs",
      scrollable: [""]
    },
    inventory: {
      container: { classes: ["tab-body"], id: "tabs" },
      template: "systems/dnd5e/templates/actors/tabs/actor-inventory.hbs",
      templates: ["systems/dnd5e/templates/inventory/inventory.hbs"],
      scrollable: [""]
    },
    description: {
      container: { classes: ["tab-body"], id: "tabs" },
      template: "systems/dnd5e/templates/actors/group/biography.hbs",
      scrollable: [""]
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static TABS = [
    { tab: "members", label: "DND5E.ENCOUNTER.Tab.Members" },
    { tab: "inventory", label: "DND5E.ENCOUNTER.Tab.Loot" },
    { tab: "description", label: "DND5E.ENCOUNTER.Tab.Description" }
  ];

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  tabGroups = {
    primary: "members"
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /**
   * Prepare rendering context for the header.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {ApplicationRenderContext}
   * @protected
   */
  async _prepareHeaderContext(context, options) {
    const difficulty = await this.actor.system.getDifficulty();
    context.subtitles = [];
    if ( difficulty ) context.subtitles.push(game.i18n.localize(`DND5E.ENCOUNTER.Difficulty.${difficulty}`));
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareInventoryContext(context, options) {
    context = await super._prepareInventoryContext(context, options);
    const Inventory = customElements.get(this.options.elements.inventory);
    const inventory = Object.values(CONFIG.Item.dataModels)
      .filter(model => "inventorySection" in model)
      .map(model => {
        const section = model.inventorySection;
        if ( foundry.utils.isSubclass(model, dnd5e.dataModels.item.ContainerData) ) return section;
        return { ...section, columns: ["price", "weight", "quantity", "controls"] };
      });
    inventory.push(foundry.utils.deepClone(Inventory.SECTIONS.contents));
    inventory.at(-1).items = context.itemCategories.inventory ?? [];
    inventory.forEach(s => s.minWidth = 190);
    context.sections = Inventory.prepareSections(inventory);
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare context for the members of the encounter.
   * @param {ApplicationRenderContext} context  Shared context provided by _prepareContext.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {Promise<ApplicationRenderContext>}
   * @protected
   */
  async _prepareMembersContext(context, options) {
    const formatter = new Intl.NumberFormat(game.i18n.lang);
    const members = await this.actor.system.getMembers();
    context.members = await Promise.all(members.map(async ({ actor, quantity }, index) => {
      if ( actor.type !== "npc" ) return null;
      const { name, system, uuid } = actor;
      const member = { index, name, quantity, uuid };
      member.cr = system.details.cr;
      member.subtitle = [
        CONFIG.DND5E.actorSizes[system.traits.size]?.label,
        system.details.type.label,
        game.i18n.format("DND5E.ExperiencePoints.Format", { value: formatter.format(system.details.xp.value) })
      ].filterJoin(" • ");
      member.underlay = `var(--underlay-npc-${system.details.type.value})`;
      member.showFormula = context.editable || (quantity.formula && (quantity.value === null));
      member.showQuantity = context.editable || (quantity.value !== null) || !quantity.formula;
      member.showRoll = !context.editable && quantity.formula;
      await this._prepareMemberPortrait(actor, member);
      return member;
    }));
    context.members = context.members.filter(_ => _).sort((a, b) => a.name.localeCompare(b.name, game.i18n.lang));

    // Difficulty
    const { party } = game.actors;
    const { creatures, level } = party?.system ?? {};
    const [low, med, high] = (CONFIG.DND5E.ENCOUNTER_DIFFICULTY[level] ?? []).map(t => t * creatures.length);
    const xp = await this.actor.system.getXPValue();

    context.difficulty = {
      value: xp,
      max: high ?? Infinity,
      pct: high ? Math.min((xp / high) * 100, 100) : 0,
      stops: { low: high ? (low / high) * 100 : 0, high: high ? (med / high) * 100 : 0 }
    };

    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    switch ( partId ) {
      case "description": return this._prepareDescriptionContext(context, options);
      case "header": return this._prepareHeaderContext(context, options);
      case "inventory": return this._prepareInventoryContext(context, options);
      case "members": return this._prepareMembersContext(context, options);
    }
    return context;
  }

  /* -------------------------------------------- */
  /*  Item Preparation Helpers                    */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _prepareItem(item, ctx) {
    super._prepareItem(item, ctx);
    delete ctx.activities;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareItemPhysical(item, ctx) {
    await super._prepareItemPhysical(item, ctx);
    delete ctx.equip;
    delete ctx.attunement;
  }

  /* -------------------------------------------- */
  /*  Life-Cycle Handlers                         */
  /* -------------------------------------------- */

  /** @override */
  _saveSheetPosition() {}

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /**
   * Handle incrementing or decrementing a numeric input.
   * @param {HTMLInputElement} input  The input.
   * @param {number} delta            The delta.
   * @protected
   */
  _onAdjustInput(input, delta) {
    const min = input.min ? Number(input.min) : -Infinity;
    const max = input.max ? Number(input.max) : Infinity;
    let value = Number(input.value);
    if ( Number.isNaN(value) ) return;
    value += delta;
    input.value = Math.clamp(value, min, max);
    input._debouncedChange ??= foundry.utils.debounce(() => {
      input.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
    }, 250);
    input._debouncedChange();
  }

  /* -------------------------------------------- */

  /**
   * Handle distributing XP & currency.
   * @this {EncounterActorSheet}
   */
  static async #onAward() {
    new Award({
      award: {
        currency: { ...this.actor.system.currency },
        savedDestinations: this.actor.getFlag("dnd5e", "awardDestinations"),
        xp: await this.actor.system.getXPValue()
      }
    }).render({ force: true });
  }

  /* -------------------------------------------- */

  /**
   * Handle opening the compendium browser to add actors.
   * @this {EncounterActorSheet}
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   */
  static async #onBrowse(event, target) {
    if ( !this.isEditable ) return;
    const results = await CompendiumBrowser.select({
      filters: {
        locked: {
          documentClass: "Actor",
          types: new Set(["npc"])
        }
      },
      selection: {
        min: 1
      }
    });
    if ( results ) {
      const actors = await Promise.all(results.map(fromUuid));
      this.actor.system.addMember(...actors);
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onChangeForm(formConfig, event) {
    const { name } = event.target.dataset;
    const index = Number(event.target.closest("[data-index]")?.dataset.index);
    if ( Number.isNaN(index) || !name ) return super._onChangeForm(formConfig, event);
    const members = this.actor.system.toObject().members;
    foundry.utils.setProperty(members[index], name, event.target.value);
    this.actor.update({ "system.members": members });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onChangeInputDelta(event) {
    super._onChangeInputDelta(event);
    const { name } = event.target.dataset;
    const index = Number(event.target.closest("[data-index]")?.dataset.index);
    if ( Number.isNaN(index) || !name ) return;
    const members = this.actor.system.toObject().members;
    const member = members[index];
    const result = event.target.value === "" ? null : parseInputDelta(event.target, member);
    if ( result !== undefined ) {
      foundry.utils.setProperty(member, name, result);
      this.actor.update({ "system.members": members });
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle the decrementing a member's quantity.
   * @this {EncounterActorSheet}
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   */
  static #onDecrease(event, target) {
    if ( !target.matches(".member .adjustment-button") ) return;
    this._onAdjustInput(target.parentElement.querySelector("input"), -1);
  }

  /* -------------------------------------------- */

  /**
   * Handle the incrementing a member's quantity.
   * @this {EncounterActorSheet}
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   */
  static #onIncrease(event, target) {
    if ( !target.matches(".member .adjustment-button") ) return;
    this._onAdjustInput(target.parentElement.querySelector("input"), 1);
  }

  /* -------------------------------------------- */

  /**
   * Handle rolling for all member quantities.
   */
  static async #onRollQuantities() {
    this.actor.system.rollQuantities();
  }

  /* -------------------------------------------- */

  /**
   * Handle rolling the quantity of a member.
   * @this {EncounterActorSheet}
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   */
  static async #onRollQuantity(event, target) {
    const index = Number(target.closest("[data-index]")?.dataset.index);
    if ( Number.isNaN(index) ) return;
    this.actor.system.rollQuantities({ index });
  }
}

/**
 * Extension of the base actor sheet for group actors.
 */
class GroupActorSheet extends MultiActorSheet {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["group", "vertical-tabs"],
    position: {
      width: 700,
      height: 700
    },
    actions: {
      award: GroupActorSheet.#onAward,
      changePace: GroupActorSheet.#onChangePace,
      roll: GroupActorSheet.#onRoll,
      toggleInventory: GroupActorSheet.#onToggleInventory
    },
    tab: "members"
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    header: {
      template: "systems/dnd5e/templates/actors/group/header.hbs"
    },
    members: {
      container: { classes: ["tab-body"], id: "tabs" },
      template: "systems/dnd5e/templates/actors/group/members.hbs",
      templates: ["systems/dnd5e/templates/actors/group/member.hbs"],
      scrollable: [""]
    },
    inventory: {
      container: { classes: ["tab-body"], id: "tabs" },
      template: "systems/dnd5e/templates/actors/group/inventory.hbs",
      templates: [
        "systems/dnd5e/templates/inventory/inventory.hbs", "systems/dnd5e/templates/inventory/activity.hbs",
        "systems/dnd5e/templates/inventory/containers.hbs", "systems/dnd5e/templates/inventory/encumbrance.hbs"
      ],
      scrollable: [".sidebar", ".body"]
    },
    biography: {
      container: { classes: ["tab-body"], id: "tabs" },
      template: "systems/dnd5e/templates/actors/group/biography.hbs",
      scrollable: [""]
    },
    tabs: {
      id: "tabs",
      classes: ["tabs-right"],
      template: "systems/dnd5e/templates/shared/sidebar-tabs.hbs"
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static TABS = [
    { tab: "members", label: "DND5E.Group.Member.other", icon: "fa-solid fa-users"},
    { tab: "inventory", label: "DND5E.Inventory", svg: "systems/dnd5e/icons/svg/backpack.svg" },
    { tab: "biography", label: "DND5E.Biography", icon: "fa-solid fa-feather" }
  ];

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  get inventorySource() {
    const inventorySource = this.actor.getFlag("dnd5e", "inventorySource") ?? "group";
    const { primaryVehicle } = this.actor.system;
    if ( (inventorySource === "vehicle") && primaryVehicle?.isOwner ) return primaryVehicle;
    return super.inventorySource;
  }

  /* -------------------------------------------- */

  /** @override */
  tabGroups = {
    primary: "members"
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @override */
  async _configureInventorySections(sections) {
    sections.forEach(s => s.minWidth = 200);
  }

  /* -------------------------------------------- */

  /**
   * Prepare the header context.
   * @param {ApplicationRenderContext} context     Shared context provided by _prepareContext.
   * @param {HandlebarsRenderOptions} options      Options which configure application rendering behavior.
   * @returns {ApplicationRenderContext}
   * @protected
   */
  async _prepareHeaderContext(context, options) {
    context.showXP = game.settings.get("dnd5e", "levelingMode") !== "noxp";
    context.travelPace = this.actor.system.getTravelPace();
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareInventoryContext(context, options) {
    context = await super._prepareInventoryContext(context, options);
    context.members = [];
    for ( const { actor } of this.document.system.members ) {
      if ( !actor || (actor === this.inventorySource) ) continue;
      const { id, type, img, name, system, uuid } = actor;
      const member = { id, type, img, name, system, uuid };
      member.hiddenStats = !actor.testUserPermission(game.user, "OBSERVER");
      await this._prepareMemberEncumbrance(actor, member);
      context.members.push(member);
    }
    context.members.sort((a, b) => a.type.compare(b.type) || a.name.localeCompare(b.name, game.i18n.lang));
    if ( this.inventorySource.type === "vehicle" ) {
      context.encumbrance = await this.inventorySource.system.getEncumbrance();
    }
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare members context.
   * @param {ApplicationRenderContext} context     Shared context provided by _prepareContext.
   * @param {HandlebarsRenderOptions} options      Options which configure application rendering behavior.
   * @returns {Promise<ApplicationRenderContext>}
   * @protected
   */
  async _prepareMembersContext(context, options) {
    context.sections = {
      character: { members: [], hasStats: true },
      npc: { members: [], label: "TYPES.Actor.npcPl", hasStats: true },
      vehicle: { members: [], label: "TYPES.Actor.vehiclePl" }
    };
    for ( const { actor } of this.document.system.members ) {
      if ( !actor ) continue;
      const { id, type, img, name, system, uuid } = actor;
      const section = context.sections[type];
      if ( !section ) continue;
      const member = { id, type, img, name, system, uuid };
      member.canView = actor.testUserPermission(game.user, "LIMITED");
      member.hiddenStats = !actor.testUserPermission(game.user, "OBSERVER");
      member.classes = member.hiddenStats ? [] : actor.itemTypes.class;
      await this._prepareMemberPortrait(actor, member);
      this._prepareMemberEncumbrance(actor, member);
      this._prepareMemberSkills(actor, member);
      switch ( type ) {
        case "character": await this._prepareCharacterContext(actor, member, options); break;
        case "npc": await this._prepareNPCContext(actor, member, options); break;
        case "vehicle": await this._prepareVehicleContext(actor, member, options); break;
      }
      section.members.push(member);
    }
    Object.values(context.sections).forEach(s => {
      s.members.sort((a, b) => a.name.localeCompare(b.name, game.i18n.lang));
    });
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    switch ( partId ) {
      case "biography": return this._prepareDescriptionContext(context, options);
      case "header": return this._prepareHeaderContext(context, options);
      case "inventory": return this._prepareInventoryContext(context, options);
      case "members": return this._prepareMembersContext(context, options);
      case "tabs": return this._prepareTabsContext(context, options);
    }
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Render a toggle for switching between inventories.
   * @protected
   */
  _renderInventoryToggle() {
    const groupInventory = this.inventorySource === this.actor;
    this.element.classList.toggle("vehicle-inventory", !groupInventory);
    const currency = this.element.querySelector(".inventory-element .currency");
    if ( !currency || !this.actor.isOwner || !this.actor.system.primaryVehicle?.isOwner ) return;
    currency.querySelectorAll("[name]").forEach(el => {
      el.dataset.name = el.name;
      el.name = "";
    });
    const button = this.element.ownerDocument.createElement("div");
    button.classList.add("split-button");
    button.innerHTML = `
      <button type="button" class="always-interactive split-control" data-action="toggleInventory"
              data-inventory="group" aria-pressed="${groupInventory}" data-tooltip
              aria-label="${game.i18n.localize("DND5E.Group.Action.Inventory.Group")}">
        <i class="fa-solid fa-list-ul fa-fw" inert></i>
      </button>
      <button type="button" class="always-interactive split-control" data-action="toggleInventory"
              data-inventory="vehicle" aria-pressed="${!groupInventory}" data-tooltip
              aria-label="${game.i18n.localize("DND5E.Group.Action.Inventory.Vehicle")}">
        <i class="fa-solid fa-wagon-covered fa-fw" inert></i>
      </button>
    `;
    currency.append(button);
    if ( !groupInventory ) this._renderCreateInventory();
  }

  /* -------------------------------------------- */
  /*  Member Preparation Helpers                  */
  /* -------------------------------------------- */

  /**
   * Prepare render context for player characters.
   * @param {Actor5e} actor                    The player character actor.
   * @param {object} context                   The render context.
   * @param {HandlebarsRenderOptions} options  Options which configure application rendering behavior.
   * @returns {Promise<void>}
   * @protected
   */
  async _prepareCharacterContext(actor, context, options) {
    const { originalClass } = context.system.details;
    const cls = actor.items.get(originalClass);
    if ( cls ) context.underlay = `var(--underlay-${cls.identifier})`;
  }

  /* -------------------------------------------- */

  /**
   * Prepare encumbrance context for members.
   * @param {Actor5e} actor   The actor instance.
   * @param {object} context  The render context.
   * @protected
   */
  async _prepareMemberEncumbrance(actor, context) {
    const encumbrance = actor.type === "vehicle"
      ? await actor.system.getEncumbrance()
      : actor.system.attributes.encumbrance;
    const { pct, max, value } = encumbrance;
    const defaultUnits = CONFIG.DND5E.encumbrance.baseUnits.default;
    const baseUnits = CONFIG.DND5E.encumbrance.baseUnits[actor.type] ?? defaultUnits;
    const systemUnits = game.settings.get("dnd5e", "metricWeightUnits") ? "metric" : "imperial";
    context.encumbrance = {
      pct,
      max: convertWeight(max, baseUnits[systemUnits], defaultUnits[systemUnits]),
      value: convertWeight(value, baseUnits[systemUnits], defaultUnits[systemUnits])
    };
  }

  /* -------------------------------------------- */

  /**
   * Prepare skills context for members.
   * @param {Actor5e} actor   The actor instance.
   * @param {object} context  The render context.
   * @protected
   */
  _prepareMemberSkills(actor, context) {
    context.skills = Object.fromEntries(Object.entries(actor.system.skills ?? {}).map(([key, skill]) => {
      const { ability, passive, total } = skill;
      const css = [actor.isOwner ? "rollable" : "", "skill"].filterJoin(" ");
      const label = game.i18n.format(actor.isOwner ? "DND5E.SkillRoll" : "DND5E.SkillTitle", {
        ability: CONFIG.DND5E.abilities[ability]?.label,
        skill: CONFIG.DND5E.skills[key]?.label
      });
      return [key, { css, label, passive, total }];
    }));
  }

  /* -------------------------------------------- */

  /**
   * Prepare render context for NPCs.
   * @param {Actor5e} actor                    The NPC actor.
   * @param {object} context                   The render context.
   * @param {HandlebarsRenderOptions} options  Options which configure application rendering behavior.
   * @returns {Promise<void>}
   * @protected
   */
  async _prepareNPCContext(actor, context, options) {
    context.underlay = `var(--underlay-npc-${actor.system.details.type.value})`;
  }

  /* -------------------------------------------- */

  /**
   * Prepare render context for vehicles.
   * @param {Actor5e} actor                    The vehicle actor.
   * @param {object} context                   The render context.
   * @param {HandlebarsRenderOptions} options  Options which configure application rendering behavior.
   * @returns {Promise<void>}
   * @protected
   */
  async _prepareVehicleContext(actor, context, options) {
    context.underlay = `var(--underlay-vehicle-${actor.system.details.type})`;
    context.isPrimaryVehicle = this.actor.system.primaryVehicle === actor;
    const { attributes } = actor.system;
    context.properties = [];
    if ( attributes.ac.value ) context.properties.push({ label: "DND5E.AC", value: attributes.ac.value });
    if ( attributes.hp.dt ) context.properties.push({ label: "DND5E.HITPOINTS.DT.abbr", value: attributes.hp.dt });
    const speed = attributes.travel.paces.max;
    if ( Number.isFinite(speed) ) context.properties.push({ label: "DND5E.Speed", value: speed });
  }

  /* -------------------------------------------- */
  /*  Life-Cycle Handlers                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onClose(options) {
    super._onClose(options);
    this.actor.system.members.forEach(({ actor }) => {
      if ( actor ) delete actor.apps[this.id];
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onFirstRender(context, options) {
    await super._onFirstRender(context, options);
    this.actor.system.members.forEach(({ actor }) => {
      if ( actor ) actor.apps[this.id] = this;
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    this._renderInventoryToggle();
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /**
   * Handle distributing XP & currency.
   * @this {GroupActorSheet}
   */
  static #onAward() {
    new Award({
      award: { savedDestinations: this.actor.getFlag("dnd5e", "awardDestinations") },
      origin: this.actor
    }).render({ force: true });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onChangeForm(formConfig, event) {
    if ( event.target.dataset.name?.startsWith("system.currency.") && (this.inventorySource.type === "vehicle") ) {
      return this.inventorySource.update({ [event.target.dataset.name]: event.target.value });
    }
    return super._onChangeForm(formConfig, event);
  }

  /* -------------------------------------------- */

  /**
   * Handle cycling travel pace.
   * @this {GroupActorSheet}
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   */
  static #onChangePace(event, target) {
    const increment = Number(target.dataset.increment);
    if ( Number.isNaN(increment) ) return;
    const paces = Object.keys(CONFIG.DND5E.travelPace);
    const current = paces.indexOf(this.actor.system._source.attributes.travel.pace);
    const next = (((current + increment) % paces.length) + paces.length) % paces.length;
    this.actor.update({ "system.attributes.travel.pace": paces[next] });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onDropActor(event, actor) {
    await super._onDropActor(event, actor);
    if ( actor ) actor.apps[this.id] = this;
    return actor;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onDropItem(event, item) {
    const { uuid } = event.target.closest("[data-uuid]")?.dataset ?? {};
    const target = await fromUuid(uuid);
    if ( target instanceof foundry.documents.Actor ) return target.sheet._onDropCreateItems(event, [item]);
    return super._onDropItem(event, item);
  }

  /* -------------------------------------------- */

  /**
   * Handle a roll for an individual group member.
   * @this {GroupActorSheet}
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   */
  static async #onRoll(event, target) {
    const { type, key } = target.dataset;
    if ( type !== "skill" ) return;
    const { uuid } = target.closest("[data-uuid]")?.dataset ?? {};
    const { pace } = this.actor.system.getTravelPace();
    const actor = await fromUuid(uuid);
    if ( actor.isOwner ) actor?.rollSkill({ event, pace, skill: key });
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling the inventory view.
   * @this {GroupActorSheet}
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   */
  static #onToggleInventory(event, target) {
    const { inventory } = target.dataset;
    this.actor.setFlag("dnd5e", "inventorySource", inventory);
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _getEntryContextOptions() {
    return super._getEntryContextOptions().concat([{
      name: "DND5E.Group.Action.SetPrimaryVehicle",
      icon: '<i class="fa-solid fa-star"></i>',
      group: "state",
      condition: li => {
        return (foundry.utils.fromUuidSync(li.dataset.uuid)?.type === "vehicle")
          && (this.actor.system.primaryVehicle?.uuid !== li.dataset.uuid);
      },
      callback: async li => this.actor.update({ "system.primaryVehicle": (await fromUuid(li.dataset.uuid))?.id })
    }, {
      name: "DND5E.Group.Action.UnsetPrimaryVehicle",
      icon: '<i class="fa-regular fa-star"></i>',
      group: "state",
      condition: li => this.actor.system.primaryVehicle?.uuid === li.dataset.uuid,
      callback: () => this.actor.update({ "system.primaryVehicle": null })
    }]);
  }
}

class HabitatConfig extends BaseConfigSheet {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["habitat-config"],
    position: {
      width: 420
    }
  };

  /** @override */
  static PARTS = {
    config: {
      template: "systems/dnd5e/templates/actors/config/habitat-config.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  get title() {
    return game.i18n.localize("DND5E.Habitat.Configuration.Title");
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    const config = CONFIG.DND5E.habitats;
    const { details } = this.document.system._source;
    const value = Object.fromEntries(details.habitat.value.map(({ type, subtype }) => [type, { type, subtype }]));
    const any = "any" in value;
    context.custom = {
      field: this.document.system.schema.fields.details.fields.habitat.fields.custom,
      value: details.habitat.custom,
      name: "custom"
    };
    context.habitats = [
      { label: config.any.label, id: "any", checked: any },
      ...Object.entries(config).reduce((arr, [key, { label, subtypes }]) => {
        if ( key === "any" ) return arr;
        const checked = any || (key in value);
        arr.push({
          checked, label,
          id: key,
          disabled: any,
          subtype: value[key]?.subtype,
          subtypes: !any && subtypes && checked
        });
        return arr;
      }, []).sort((a, b) => a.label.localeCompare(b.label, game.i18n.lang))
    ];
    context.rows = Math.ceil(context.habitats.reduce((n, { subtypes }) => n + (subtypes ? 2 : 1), 0) / 2);
    return context;
  }

  /* -------------------------------------------- */
  /*  Form Submission                             */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _processFormData(event, form, formData) {
    const submitData = super._processFormData(event, form, formData);
    const value = Object.entries(submitData).reduce((arr, [id, data]) => {
      if ( id === "custom" ) return arr;
      const entry = { type: id };
      if ( data.subtype ) entry.subtype = data.subtype;
      if ( data.type ) arr.push(entry);
      return arr;
    }, []);
    return foundry.utils.expandObject({
      "system.details.habitat": { value, custom: submitData.custom }
    });
  }
}

/**
 * Configuration application for an NPC's treasure categories.
 */
class TreasureConfig extends BaseConfigSheet {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["treasure-config"],
    position: {
      width: 400
    }
  };

  /** @override */
  static PARTS = {
    config: {
      template: "systems/dnd5e/templates/actors/config/treasure-config.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  get title() {
    return game.i18n.localize("DND5E.Treasure.Configuration.Title");
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    const config = CONFIG.DND5E.treasure;
    const { details } = this.document.system._source;
    const any = details.treasure.value.includes("any");
    context.treasure = [
      { label: config.any.label, name: "any", checked: any },
      ...Object.entries(config).reduce((arr, [key, { label }]) => {
        if ( key === "any" ) return arr;
        arr.push({ label, name: key, checked: any || details.treasure.value.includes(key), disabled: any });
        return arr;
      }, []).sort((a, b) => a.label.localeCompare(b.label, game.i18n.lang))
    ];
    return context;
  }

  /* -------------------------------------------- */
  /*  Form Submission                             */
  /* -------------------------------------------- */

  /** @override */
  _processFormData(event, form, formData) {
    return foundry.utils.expandObject({
      "system.details.treasure.value": Object.entries(formData.object).filter(([, v]) => v).map(([k]) => k)
    });
  }
}

const TextEditor$7 = foundry.applications.ux.TextEditor.implementation;

/**
 * Extension of base actor sheet for NPCs.
 */
class NPCActorSheet extends BaseActorSheet {
  /** @override */
  static DEFAULT_OPTIONS = {
    actions: {
      editDescription: NPCActorSheet.#editDescription
    },
    classes: ["npc", "vertical-tabs"],
    position: {
      width: 700,
      height: 700
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    header: {
      template: "systems/dnd5e/templates/actors/npc-header.hbs"
    },
    sidebarCollapser: {
      container: { classes: ["main-content"], id: "main" },
      template: "systems/dnd5e/templates/actors/parts/sidebar-collapser.hbs"
    },
    sidebar: {
      container: { classes: ["main-content"], id: "main" },
      template: "systems/dnd5e/templates/actors/npc-sidebar.hbs"
    },
    features: {
      container: { classes: ["tab-body"], id: "tabs" },
      template: "systems/dnd5e/templates/actors/tabs/actor-features.hbs",
      templates: ["systems/dnd5e/templates/inventory/inventory.hbs", "systems/dnd5e/templates/inventory/activity.hbs"],
      scrollable: [""]
    },
    inventory: {
      container: { classes: ["tab-body"], id: "tabs" },
      template: "systems/dnd5e/templates/actors/tabs/actor-inventory.hbs",
      templates: [
        "systems/dnd5e/templates/inventory/inventory.hbs", "systems/dnd5e/templates/inventory/activity.hbs",
        "systems/dnd5e/templates/inventory/encumbrance.hbs"
      ],
      scrollable: [""]
    },
    spells: {
      container: { classes: ["tab-body"], id: "tabs" },
      template: "systems/dnd5e/templates/actors/tabs/creature-spells.hbs",
      scrollable: [""]
    },
    effects: {
      container: { classes: ["tab-body"], id: "tabs" },
      template: "systems/dnd5e/templates/actors/tabs/actor-effects.hbs",
      scrollable: [""]
    },
    biography: {
      container: { classes: ["tab-body"], id: "tabs" },
      template: "systems/dnd5e/templates/actors/tabs/npc-biography.hbs",
      scrollable: [""]
    },
    specialTraits: {
      classes: ["flexcol"],
      container: { classes: ["tab-body"], id: "tabs" },
      template: "systems/dnd5e/templates/actors/tabs/creature-special-traits.hbs",
      scrollable: [""]
    },
    warnings: {
      template: "systems/dnd5e/templates/actors/parts/actor-warnings-dialog.hbs"
    },
    tabs: {
      id: "tabs",
      classes: ["tabs-right"],
      template: "systems/dnd5e/templates/shared/sidebar-tabs.hbs"
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static TABS = [
    { tab: "features", label: "DND5E.Features", icon: "fas fa-list" },
    { tab: "inventory", label: "DND5E.Inventory", svg: "systems/dnd5e/icons/svg/backpack.svg" },
    { tab: "spells", label: "TYPES.Item.spellPl", icon: "fas fa-book" },
    { tab: "effects", label: "DND5E.Effects", icon: "fas fa-bolt" },
    { tab: "biography", label: "DND5E.Biography", icon: "fas fa-feather" },
    { tab: "specialTraits", label: "DND5E.SpecialTraits", icon: "fas fa-star" }
  ];

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Description currently being edited.
   * @type {string|null}
   */
  editingDescriptionTarget = null;

  /* -------------------------------------------- */

  /** @override */
  tabGroups = {
    primary: "features"
  };

  /* -------------------------------------------- */

  /** @override */
  _filters = {
    features: { name: "", properties: new Set() },
    effects: { name: "", properties: new Set() },
    inventory: { name: "", properties: new Set() },
    spells: { name: "", properties: new Set() }
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @override */
  async _configureInventorySections(sections) {
    sections.forEach(s => s.minWidth = 200);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = {
      ...await super._prepareContext(options),
      important: !foundry.utils.isEmpty(this.actor.classes) || this.actor.system.traits.important,
      isNPC: true
    };
    context.hasClasses = context.itemCategories.classes?.length;
    context.spellbook = this._prepareSpellbook(context);
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    switch ( partId ) {
      case "biography": return this._prepareBiographyContext(context, options);
      case "effects": return this._prepareEffectsContext(context, options);
      case "features": return this._prepareFeaturesContext(context, options);
      case "header": return this._prepareHeaderContext(context, options);
      case "inventory": return this._prepareInventoryContext(context, options);
      case "sidebar": return this._prepareSidebarContext(context, options);
      case "specialTraits": return this._prepareSpecialTraitsContext(context, options);
      case "spells": return this._prepareSpellsContext(context, options);
      case "tabs": return this._prepareTabsContext(context, options);
      default: return context;
    }
  }

  /* -------------------------------------------- */

  /**
   * Prepare rendering context for the biography tab.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {ApplicationRenderContext}
   * @protected
   */
  async _prepareBiographyContext(context, options) {
    if ( this.actor.limited ) return context;

    const enrichmentOptions = {
      secrets: this.actor.isOwner, relativeTo: this.actor, rollData: context.rollData
    };
    context.enriched = {
      public: await TextEditor$7.enrichHTML(this.actor.system.details.biography.public, enrichmentOptions),
      value: await TextEditor$7.enrichHTML(this.actor.system.details.biography.value, enrichmentOptions)
    };
    if ( this.editingDescriptionTarget ) context.editingDescription = {
      target: this.editingDescriptionTarget,
      value: foundry.utils.getProperty(this.actor._source, this.editingDescriptionTarget)
    };

    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareEffectsContext(context, options) {
    context = await super._prepareEffectsContext(context, options);
    context.hasConditions = true;
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare rendering context for the features tab.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {ApplicationRenderContext}
   * @protected
   */
  async _prepareFeaturesContext(context, options) {
    const sections = Object.entries(CONFIG.DND5E.activityActivationTypes).reduce((obj, [id, config], i) => {
      const { header: label, passive } = config;
      if ( passive ) return obj;
      obj[id] ??= {
        id, label, order: (i + 1) * 100, items: [], minWidth: 210,
        columns: ["recovery", "uses", "roll", "formula", "controls"]
      };
      return obj;
    }, {});
    sections.passive = {
      id: "passive", label: "DND5E.Features", order: 0, items: [], minWidth: 210,
      columns: ["recovery", "uses", "roll", "formula", "controls"]
    };
    context.itemCategories.features?.forEach(i => {
      const ctx = context.itemContext[i.id];
      sections[ctx.group]?.items.push(i);
    });
    context.sections = customElements.get(this.options.elements.inventory).prepareSections(Object.values(sections));
    context.listControls = {
      label: "DND5E.FeatureSearch",
      list: "features",
      filters: [
        { key: "action", label: "DND5E.ACTIVATION.Type.Action.Label" },
        { key: "bonus", label: "DND5E.ACTIVATION.Type.BonusAction.Label" },
        { key: "reaction", label: "DND5E.ACTIVATION.Type.Reaction.Label" },
        { key: "legendary", label: "DND5E.ACTIVATION.Type.Legendary.Label" },
        { key: "lair", label: "DND5E.ACTIVATION.Type.Lair.Label" }
      ],
      sorting: [
        { key: "m", label: "SIDEBAR.SortModeManual", dataset: { icon: "fa-solid fa-arrow-down-short-wide" } },
        { key: "a", label: "SIDEBAR.SortModeAlpha", dataset: { icon: "fa-solid fa-arrow-down-a-z" } }
      ]
    };
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare rendering context for the header.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {ApplicationRenderContext}
   * @protected
   */
  async _prepareHeaderContext(context, options) {
    context.portrait = await this._preparePortrait(context);

    if ( this.actor.limited ) {
      const enrichmentOptions = { relativeTo: this.actor, rollData: context.rollData };
      context.enriched = {
        public: await TextEditor$7.enrichHTML(this.actor.system.details.biography.public, enrichmentOptions)
      };
      return context;
    }

    context.abilities = this._prepareAbilities(context);
    context.classes = context.itemCategories.classes;

    // Legendary Actions & Resistances
    const plurals = getPluralRules({ type: "ordinal" });
    const resources = context.source.resources;
    for ( const res of ["legact", "legres"] ) {
      const { max, value } = resources[res];
      context[res] = Array.fromRange(max, 1).map(n => {
        const i18n = res === "legact" ? "LegendaryAction" : "LegendaryResistance";
        const filled = value >= n;
        const classes = ["pip"];
        if ( filled ) classes.push("filled");
        return {
          n: max - n, filled,
          tooltip: `DND5E.${i18n}.Label`,
          label: game.i18n.format(`DND5E.${i18n}.Ordinal.${plurals.select(n)}`, { n }),
          classes: classes.join(" ")
        };
      });
    }
    context.hasLegendaries = resources.legact.max || resources.legres.max
      || (context.modernRules && resources.lair.value) || (!context.modernRules && resources.lair.initiative);

    // Visibility
    if ( this._mode === this.constructor.MODES.PLAY ) {
      context.showDeathSaves = context.important && !context.system.attributes.hp.value;
      context.showInitiativeScore = game.settings.get("dnd5e", "rulesVersion") === "modern";
    }
    context.showLoyalty = context.important && game.settings.get("dnd5e", "loyaltyScore") && game.user.isGM;
    context.showRests = game.user.isGM || (this.actor.isOwner && game.settings.get("dnd5e", "allowRests"));

    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareInventoryContext(context, options) {
    context = await super._prepareInventoryContext(context, options);
    context.encumbrance = context.system.attributes.encumbrance;
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare rendering context for the sidebar.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {ApplicationRenderContext}
   * @protected
   */
  async _prepareSidebarContext(context, options) {
    const { attributes, details } = context.system;

    // Habitat
    if ( details.habitat.value.length || details.habitat.custom ) {
      const { habitat } = details;
      const any = details.habitat.value.find(({ type }) => type === "any");
      context.habitat = [
        ...habitat.value.map(({ type, subtype }) => {
          let { label } = CONFIG.DND5E.habitats[type] ?? {};
          if ( label && (!any || (type === "any")) ) {
            if ( subtype ) label = game.i18n.format("DND5E.Habitat.Subtype", { type: label, subtype });
            return { label };
          }
          return null;
        }, []).filter(_ => _),
        ...splitSemicolons(habitat.custom).map(label => ({ label }))
      ].sort((a, b) => a.label.localeCompare(b.label, game.i18n.lang));
    }

    // Senses
    context.senses = this._prepareSenses(context);
    if ( this.actor.system.skills.prc ) context.senses.push({
      key: "passivePerception",
      label: game.i18n.localize("DND5E.PassivePerception"),
      value: this.actor.system.skills.prc.passive
    });

    // Skills & Tools
    const skillSetting = game.settings.get("dnd5e", "defaultSkills");
    context.skills = this._prepareSkillsTools(context, "skills")
      .filter(v => v.value || skillSetting.has(v.key) || v.bonuses.check || v.bonuses.passive);
    context.tools = this._prepareSkillsTools(context, "tools");

    // Speed
    context.speed = [
      ...Object.entries(CONFIG.DND5E.movementTypes).map(([k, { label }]) => {
        const value = attributes.movement[k];
        if ( !value ) return null;
        const data = { label, value };
        if ( (k === "fly") && attributes.movement.hover ) data.icons = [{
          icon: "fas fa-cloud", label: game.i18n.localize("DND5E.MOVEMENT.Hover")
        }];
        return data;
      }),
      ...splitSemicolons(attributes.movement.special).map(label => ({ label }))
    ].filter(_ => _);

    // Traits
    context.traits = this._prepareTraits(context);

    // Treasure
    if ( details?.treasure?.value.size ) {
      const any = details.treasure.value.has("any");
      context.treasure = Array.from(details.treasure.value)
        .map(id => {
          const { label } = CONFIG.DND5E.treasure[id] ?? {};
          if ( label && (!any || (id === "any")) ) return { label };
          return null;
        }, [])
        .filter(_ => _)
        .sort((a, b) => a.label.localeCompare(b.label, game.i18n.lang));
    }

    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareSpecialTraitsContext(context, options) {
    context = await super._prepareSpecialTraitsContext(context, options);

    const { fields } = this.document.system.schema;
    context.flags.sections.unshift({
      label: game.i18n.localize("DND5E.NPC.Label"),
      fields: [{
        field: fields.traits.fields.important,
        input: createCheckboxInput,
        name: "system.traits.important",
        value: context.source.traits.important
      }, {
        label: "DND5E.NPC.FIELDS.attributes.price.label",
        hint: "DND5E.NPC.FIELDS.attributes.price.hint",
        fields: [{
          field: fields.attributes.fields.price.fields.value,
          name: "system.attributes.price.value",
          value: context.source.attributes.price.value
        }, {
          choices: CONFIG.DND5E.currencies,
          field: fields.attributes.fields.price.fields.denomination,
          name: "system.attributes.price.denomination",
          value: context.source.attributes.price.denomination
        }]
      }]
    });

    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareSpellsContext(context, options) {
    context = await super._prepareSpellsContext(context, options);
    context.classSpellcasting = Object.values(this.actor.classes).some(c => c.spellcasting?.levels);

    const { abilities, attributes, bonuses } = this.actor.system;
    context.spellcasting = [];
    const msak = simplifyBonus(bonuses.msak.attack, context.rollData);
    const rsak = simplifyBonus(bonuses.rsak.attack, context.rollData);
    const spellcaster = Object.values(this.actor.spellcastingClasses)[0];
    const ability = spellcaster?.spellcasting.ability ?? attributes.spellcasting;
    const spellAbility = abilities[ability];
    const mod = spellAbility?.mod ?? 0;
    const attackBonus = msak === rsak ? msak : 0;
    context.spellcasting.push({
      label: game.i18n.format("DND5E.SpellcastingClass", {
        class: spellcaster?.name ?? game.i18n.format("DND5E.NPC.Label")
      }),
      level: spellcaster?.system.levels ?? attributes.spell.level,
      ability: {
        ability, mod,
        label: CONFIG.DND5E.abilities[ability]?.label
      },
      attack: mod + attributes.prof + attackBonus,
      save: spellAbility?.dc ?? 0,
      noSpellcaster: !spellcaster,
      concentration: {
        mod: attributes.concentration.save,
        tooltip: game.i18n.format("DND5E.AbilityConfigure", { ability: game.i18n.localize("DND5E.Concentration") })
      }
    });

    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _renderFrame(options) {
    const html = await super._renderFrame(options);
    this._renderSourceFrame(html);
    html.querySelector(".header-elements")?.insertAdjacentHTML("beforeend", '<div class="cr-xp"></div>');
    return html;
  }

  /* -------------------------------------------- */
  /*  Item Preparation Helpers                    */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _assignItemCategories(item) {
    if ( ["class", "subclass"].includes(item.type) ) return new Set(["classes"]);
    const categories = super._assignItemCategories(item);
    if ( item.type === "weapon" ) categories.add("features");
    return categories;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareItem(item, ctx) {
    await super._prepareItem(item, ctx);
    const isPassive = item.system.properties?.has("trait")
      || CONFIG.DND5E.activityActivationTypes[item.system.activities?.contents[0]?.activation.type]?.passive;
    ctx.group = isPassive ? "passive" : item.system.activities?.contents[0]?.activation.type || "passive";
  }

  /* -------------------------------------------- */
  /*  Life-Cycle Handlers                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);

    if ( !this.actor.limited ) {
      this._renderCreateInventory();
      this._renderAttunement(context, options);
      this._renderSpellbook(context, options);
    }

    const elements = this.element.querySelector(".header-elements .cr-xp");
    if ( !elements || this.actor.limited ) return;
    const xp = this.actor.system.details.xp.value;
    elements.innerText = xp === null ? "" : game.i18n.format("DND5E.ExperiencePoints.Format", {
      value: formatNumber(xp)
    });

    if ( this.editingDescriptionTarget ) {
      this.element.querySelectorAll("prose-mirror").forEach(editor => editor.addEventListener("save", () => {
        this.editingDescriptionTarget = null;
        this.render();
      }));
    }
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _addDocumentItemTypes(tab) {
    const types = super._addDocumentItemTypes(tab);
    if ( tab === "features" ) types.push("weapon");
    return types;
  }

  /* -------------------------------------------- */

  /**
   * Handle expanding the description editor.
   * @this {NPCActorSheet}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static #editDescription(event, target) {
    if ( target.ariaDisabled ) return;
    this.editingDescriptionTarget = target.dataset.target;
    this.render();
  }

  /* -------------------------------------------- */

  /** @override */
  _showConfiguration(event, target) {
    let app;
    const config = { document: this.actor };
    switch ( target.dataset.config ) {
      case "habitat":
        app = new HabitatConfig(config);
        break;
      case "treasure":
        app = new TreasureConfig(config);
        break;
    }
    if ( app ) {
      app.render({ force: true });
      return false;
    }
  }

  /* -------------------------------------------- */
  /*  Form Handling                               */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _processFormData(event, form, formData) {
    const submitData = super._processFormData(event, form, formData);

    // Convert CR
    let cr = submitData.system?.details?.cr;
    if ( (cr === "") || (cr === "—") ) foundry.utils.setProperty(submitData, "system.details.cr", null);
    else {
      cr = { "1/8": 0.125, "⅛": 0.125, "1/4": 0.25, "¼": 0.25, "1/2": 0.5, "½": 0.5 }[cr] || parseFloat(cr);
      if ( Number.isNaN(cr) ) cr = null;
      else foundry.utils.setProperty(submitData, "system.details.cr", cr < 1 ? cr : parseInt(cr));
    }

    return submitData;
  }
}

const TextEditor$6 = foundry.applications.ux.TextEditor.implementation;

/**
 * @import { CrewArea5e } from "./_types.mjs";
 */

/**
 * Extension of base actor sheet for vehicles.
 */
class VehicleActorSheet extends BaseActorSheet {
  /** @override */
  static DEFAULT_OPTIONS = {
    actions: {
      adjustCrew: VehicleActorSheet.#onAdjustCrew,
      browseActors: VehicleActorSheet.#onBrowseActors,
      createItem: VehicleActorSheet.#onCreateItem,
      removeMember: VehicleActorSheet.#onRemoveMember,
      toggleEditInline: VehicleActorSheet.#onToggleEditInline,
      uncrew: VehicleActorSheet.#onUncrew,
      useItem: VehicleActorSheet.#onUseItem
    },
    classes: ["vehicle"],
    position: {
      width: 700,
      height: 800
    },
    tab: "inventory"
  };

  /* -------------------------------------------- */

  /**
   * FIXME: Wart
   * @type {Record<number, string>}
   */
  static COVER = {
    .5: "EFFECT.DND5E.StatusHalfCover",
    .75: "EFFECT.DND5E.StatusThreeQuartersCover",
    1: "EFFECT.DND5E.StatusTotalCover"
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    sidebarCollapser: {
      template: "systems/dnd5e/templates/actors/parts/sidebar-collapser.hbs"
    },
    sidebar: {
      template: "systems/dnd5e/templates/actors/vehicle/sidebar.hbs",
      templates: [
        "systems/dnd5e/templates/actors/parts/actor-trait-line.hbs",
        "systems/dnd5e/templates/actors/parts/actor-trait-pills.hbs"
      ],
      scrollable: [""]
    },
    stations: {
      template: "systems/dnd5e/templates/actors/vehicle/stations.hbs",
      templates: ["systems/dnd5e/templates/inventory/inventory.hbs", "systems/dnd5e/templates/inventory/activity.hbs"],
      scrollable: [""]
    },
    tabs: {
      template: "systems/dnd5e/templates/shared/horizontal-tabs.hbs",
      templates: ["templates/generic/tab-navigation.hbs"]
    },
    inventory: {
      container: { classes: ["tab-body"], id: "tabs" },
      template: "systems/dnd5e/templates/actors/tabs/actor-inventory.hbs",
      templates: [
        "systems/dnd5e/templates/inventory/inventory.hbs", "systems/dnd5e/templates/inventory/activity.hbs",
        "systems/dnd5e/templates/inventory/containers.hbs", "systems/dnd5e/templates/inventory/encumbrance.hbs"
      ],
      scrollable: [""]
    },
    crew: {
      container: { classes: ["tab-body"], id: "tabs" },
      template: "systems/dnd5e/templates/actors/vehicle/crew.hbs",
      scrollable: [""]
    },
    effects: {
      container: { classes: ["tab-body"], id: "tabs" },
      template: "systems/dnd5e/templates/actors/tabs/actor-effects.hbs",
      scrollable: [""]
    },
    description: {
      container: { classes: ["tab-body"], id: "tabs" },
      template: "systems/dnd5e/templates/actors/vehicle/description.hbs",
      scrollable: [""]
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static TABS = [
    { tab: "inventory", label: "DND5E.VEHICLE.Tabs.Cargo" },
    { tab: "crew", label: "DND5E.VEHICLE.Tabs.CrewPassengers", condition: this.vehicleHasCrew },
    { tab: "effects", label: "DND5E.Effects" },
    { tab: "description", label: "DND5E.Description" }
  ];

  /* -------------------------------------------- */

  /** @override */
  static unsupportedItemTypes = new Set(["background", "class", "facility", "race", "spell", "subclass"]);

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  tabGroups = {
    primary: "inventory"
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @override */
  async _configureInventorySections(sections) {
    sections.forEach(s => s.minWidth = 180);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    context.options = {
      showAbilities: this.actor.getFlag("dnd5e", "showVehicleAbilities"),
      showInitiative: this.actor.getFlag("dnd5e", "showVehicleInitiative"),
      showQuality: this.actor.getFlag("dnd5e", "showVehicleQuality")
    };
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare crew context.
   * @param {object} context  Shared context provided by _prepareContext.
   * @param {object} options  Application render options.
   * @returns {Promise<object>}
   * @protected
   */
  async _prepareCrewContext(context, options) {
    const { groupCrew, resolveCrew } = this.constructor;
    if ( context.system.crew.value.length ) {
      const crew = groupCrew(context.system.crew.value);
      const unassigned = { ...crew };
      const { stations=[] } = context.itemCategories;
      const assigned = stations.flatMap(i => i.system.crew.value).reduce((obj, uuid) => {
        const max = crew[uuid];
        if ( !max ) return obj;
        obj[uuid] ??= 0;
        obj[uuid]++;
        unassigned[uuid]--;
        return obj;
      }, {});
      context.crew = { assigned: await resolveCrew(assigned), unassigned: await resolveCrew(unassigned, crew) };
    }

    if ( context.system.passengers.value.length ) {
      const passengers = groupCrew(context.system.passengers.value);
      context.passengers = await resolveCrew(passengers);
    }

    context.tab.cssClass += ` sheet-crew ${context.crew?.assigned.value.length ? "has-assigned" : ""}`;
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare description context.
   * @param {object} context  Shared context provided by _prepareContext.
   * @param {object} options  Application render options.
   * @returns {Promise<object>}
   * @protected
   */
  async _prepareDescriptionContext(context, options) {
    context.tab.cssClass += ` sheet-description ${context.editable ? "editing" : ""}`;
    context.description = await TextEditor$6.enrichHTML(this.actor.system.details.biography.value, {
      secrets: this.actor.isOwner, relativeTo: this.actor, rollData: context.rollData
    });
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareEffectsContext(context, options) {
    context = await super._prepareEffectsContext(context, options);
    context.hasConditions = true;
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareInventoryContext(context, options) {
    context = await super._prepareInventoryContext(context, options);
    context.encumbrance = await this.actor.system.getEncumbrance();
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    switch ( partId ) {
      case "crew": return this._prepareCrewContext(context, options);
      case "description": return this._prepareDescriptionContext(context, options);
      case "effects": return this._prepareEffectsContext(context, options);
      case "inventory": return this._prepareInventoryContext(context, options);
      case "sidebar": return this._prepareSidebarContext(context, options);
      case "stations": return this._prepareStationsContext(context, options);
      default: return context;
    }
  }

  /* -------------------------------------------- */

  /**
   * Prepare sidebar context.
   * @param {object} context  Shared context provided by _prepareContext.
   * @param {object} options  Application render options.
   * @returns {object}
   * @protected
   */
  _prepareSidebarContext(context, options) {
    const { attributes } = this.actor.system;
    const { actions } = attributes;
    context.traits = this._prepareTraits(context);
    context.properties ??= {};
    context.properties.hp = [];
    if ( attributes.hp.dt ) context.properties.hp.push({ label: "DND5E.HITPOINTS.DT.abbr", value: attributes.hp.dt });
    if ( attributes.hp.mt ) context.properties.hp.push({
      label: "DND5E.VEHICLE.Mishap.label", value: attributes.hp.mt
    });
    if ( !actions.stations && actions.max ) {
      const plurals = getPluralRules({ type: "ordinal" });
      context.actions = Array.fromRange(actions.max, 1).map(n => {
        const filled = actions.value >= n;
        const classes = ["pip"];
        if ( filled ) classes.push("filled");
        return {
          filled,
          n: actions.max - n,
          label: game.i18n.format(`DND5E.VEHICLE.Actions.Ordinal.${plurals.select(n)}`, { n }),
          classes: classes.join(" ")
        };
      });
    }
    context.showTravelPace = !!context.system.attributes.travel.paces.max;
    context.showTravelSpeed = !!context.system.attributes.travel.speeds.max;
    context.showCombatSpeed = !!context.system.attributes.movement.max
      || (context.editable && !context.showTravelPace && !context.showTravelSpeed);
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare stations context.
   * @param {object} context  Shared context provided by _prepareContext.
   * @param {object} options  Application render options.
   * @returns {Promise<object>}
   * @protected
   */
  async _prepareStationsContext(context, options) {
    const Inventory = customElements.get(this.options.elements.inventory);
    const columns = Inventory.mapColumns(["uses", "controls"]);
    const sections = Object.fromEntries(["features", "bonus", "reaction"].map((id, i) => {
      const { header } = CONFIG.DND5E.activityActivationTypes[id] ?? {};
      const label = id === "features" ? "DND5E.Features" : header;
      return [id, { columns, id, label, order: (i + 1) * 100, items: [], minWidth: 170 }];
    }));
    context.itemCategories.features?.forEach(i => {
      let group = i.system.activities?.contents[0]?.activation.type;
      if ( (group !== "bonus") && (group !== "reaction") ) group = "features";
      sections[group].items.push(i);
    });
    context.itemCategories.stations?.sort((a, b) => a.sort - b.sort);
    context.itemCategories.features?.sort((a, b) => a.sort - b.sort);
    if ( context.itemCategories.features?.length ) {
      context.features = Inventory.prepareSections(Object.values(sections));
    }
    if ( context.system.draft.value.length ) context.drafted = await this._prepareDraftAnimals();
    context.abilities = this._prepareAbilities(context);
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _renderFrame(options) {
    const html = await super._renderFrame(options);
    this._renderSourceFrame(html);
    return html;
  }

  /* -------------------------------------------- */
  /*  Actor Preparation Helpers                   */
  /* -------------------------------------------- */

  /**
   * Prepare render context for draft animals.
   * @returns {Promise<object>}
   * @protected
   */
  async _prepareDraftAnimals() {
    const { baseUnits, draftMultiplier } = CONFIG.DND5E.encumbrance;
    const unitSystem = game.settings.get("dnd5e", "metricWeightUnits") ? "metric" : "imperial";
    const units = baseUnits.default[unitSystem];
    return Promise.all(this.actor.system.draft.value.map(async uuid => {
      const actor = await fromUuid(uuid);
      const { system } = actor;
      const capacity = (actor.system.attributes?.encumbrance?.max || 0) * draftMultiplier;
      const subtitle = [
        CONFIG.DND5E.actorSizes[system.traits?.size]?.label,
        system.details?.type?.label,
        capacity ? formatWeight(capacity, units) : null
      ].filterJoin(" • ");
      return { actor, capacity, subtitle, uuid };
    }));
  }

  /* -------------------------------------------- */
  /*  Item Preparation Helpers                    */
  /* -------------------------------------------- */

  /** @override */
  _assignItemCategories(item) {
    if ( item.type === "container" ) return new Set(["containers", "inventory"]);
    if ( item.type === "facility" ) return new Set(["facilities"]);
    if ( item.system.isMountable ) return new Set(["stations"]);
    if ( "inventorySection" in item.system.constructor ) return new Set(["inventory"]);
    return new Set(["features"]);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareItemPhysical(item, ctx) {
    await super._prepareItemPhysical(item, ctx);
    if ( !item.system.isMountable ) return;
    const { COVER } = this.constructor;
    const { armor, cover, crew, hp } = item.system;
    const subtitles = [];
    if ( COVER[cover] ) subtitles.push(game.i18n.localize(COVER[cover]));
    if ( armor?.value ) subtitles.push(`${game.i18n.localize("DND5E.AC")} ${armor.value}`);
    if ( hp?.dt ) subtitles.push(`${game.i18n.localize("DND5E.HITPOINTS.DT.abbr")} ${hp.dt}`);
    if ( subtitles.length ) ctx.subtitle = subtitles.join(" • ");
    if ( item.type === "weapon" ) {
      const enrichmentOptions = { secrets: item.isOwner, relativeTo: item, rollData: item.getRollData() };
      const damage = Array.from(item.system.activities.values()).flatMap(a => a.damage?.parts ?? []);
      ctx.enriched = {
        attack: await TextEditor$6.enrichHTML("[[/attack extended]]", enrichmentOptions)
      };
      if ( damage.length ) {
        ctx.enriched.damage = await TextEditor$6.enrichHTML("[[/damage extended]]", enrichmentOptions);
      }
    }
    ctx.crew = await Promise.all(Array.fromRange(Math.max(crew.max ?? -1, crew.value.length)).map(async index => {
      const uuid = crew.value[index];
      if ( uuid ) return { index, actor: await fromUuid(uuid) };
      return { empty: true };
    }));
  }

  /* -------------------------------------------- */
  /*  Life-Cycle Handlers                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onFirstRender(context, options) {
    await super._onFirstRender(context, options);

    // Apply special context menus for items outside inventory elements.
    const cargoElement = this.element.querySelector('[data-tab="inventory"] .inventory-element');
    if ( cargoElement ) new ContextMenu5e(this.element, ".station[data-item-id]", [], {
      onOpen: (...args) => cargoElement._onOpenContextMenu(...args), jQuery: false
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    this._renderContainers(context, options);
    await super._onRender(context, options);
    if ( !this.actor.limited ) this._renderCreateInventory();
    for ( const input of this.element.querySelectorAll('[data-action="toggleEditInline"] input[hidden]') ) {
      input.addEventListener("blur", event => VehicleActorSheet.#onToggleEditInline.call(this, event, input));
    }
    const hasStations = context.editable
      || context.itemCategories.features?.length
      || context.itemCategories.stations?.length
      || context.system.draft.value.length
      || this.actor.getFlag("dnd5e", "showVehicleAbilities");
    this.element.classList.toggle("has-stations", !!hasStations);
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /**
   * Handle adjustments to the crew roster.
   * @this {VehicleActorSheet}
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   */
  static #onAdjustCrew(event, target) {
    const input = target.parentElement.querySelector("input");
    const value = Number(input.value) + Number(target.dataset.delta);
    if ( Number.isNaN(value) ) return;
    input.value = value;
    input._debouncedChange ??= foundry.utils.debounce(() => {
      input.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
    }, 250);
    input._debouncedChange();
  }

  /* -------------------------------------------- */

  /**
   * Spawn a compendium browser for browsing Actors.
   * @this {VehicleActorSheet}
   */
  static #onBrowseActors() {
    new CompendiumBrowser({
      filters: {
        locked: {
          documentClass: "Actor",
          types: new Set(["npc"])
        }
      }
    }).render({ force: true });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onChangeInputDelta(event) {
    const input = event.target;
    const { area } = input.closest("[data-area]")?.dataset ?? {};
    const { uuid } = input.closest("[data-uuid]")?.dataset ?? {};
    if ( !area || !uuid || !input.closest(".crew-quantity") ) return super._onChangeInputDelta(event);
    let value = input.value;
    if ( /^\d+$/.test(value) || (value[0] === "=") ) {
      value = parseDelta(value) + Number(input.nextElementSibling.value);
    }
    return this.actor.system.adjustCrew(area, uuid, value);
  }

  /* -------------------------------------------- */

  /**
   * Handle creating an item.
   * @this {VehicleActorSheet}
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   * @returns {Promise<Item5e>}
   */
  static #onCreateItem(event, target) {
    const Item = getDocumentClass("Item");
    const { type } = target.closest("[data-type]")?.dataset ?? {};
    const name = game.i18n.format("DOCUMENT.New", { type: game.i18n.localize(Item.metadata.label) });
    const createData = { name, type };
    if ( type === "equipment" ) createData["system.type.value"] = "vehicle";
    else if ( type === "weapon" ) createData["system.type.value"] = "siege";
    return Item.create(createData, { parent: this.actor, renderSheet: true });
  }

  /* -------------------------------------------- */

  /**
   * Handle removing a draft animal.
   * @this {VehicleActorSheet}
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   */
  static #onRemoveMember(event, target) {
    const { uuid } = target.closest("[data-uuid]")?.dataset ?? {};
    const draft = [...this.actor.system.draft.value];
    const removed = draft.findSplice(u => u === uuid);
    if ( removed ) this.actor.update({ "system.draft.value": draft });
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling an inline value editor.
   * @this {VehicleActorSheet}
   * @param {Event} event         The triggering event.
   * @param {HTMLElement} target  The action target.
   */
  static #onToggleEditInline(event, target) {
    target = target.closest('[data-action="toggleEditInline"]');
    const edit = event.type !== "blur";
    const display = target.querySelectorAll(".display");
    const input = target.querySelector("input");
    if ( !display.length || !input ) return;
    target.classList.toggle("rollable", !edit);
    display.forEach(el => el.hidden = edit);
    input.hidden = !edit;
    if ( edit ) input.focus();
  }

  /* -------------------------------------------- */

  /**
   * Handle uncrewing a station.
   * @this {VehicleActorSheet}
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   */
  static #onUncrew(event, target) {
    const { uuid } = target.closest("[data-uuid]")?.dataset ?? {};
    const { itemId } = target.closest("[data-item-id]")?.dataset ?? {};
    const item = this.actor.items.get(itemId);
    if ( !item ) return;
    const crew = [...item.system.crew.value];
    crew.findSplice(u => u === uuid);
    item.update({ "system.crew.value": crew });
  }

  /* -------------------------------------------- */

  /**
   * Handle using an action station.
   * @this {VehicleActorSheet}
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   */
  static #onUseItem(event, target) {
    this._onUseItem(event, target);
  }

  /* -------------------------------------------- */

  /**
   * Handle using an action station.
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   * @protected
   */
  _onUseItem(event, target) {
    const { itemId } = target.closest("[data-item-id]")?.dataset ?? {};
    const item = this.actor.items.get(itemId);
    item?.use({ event });
  }

  /* -------------------------------------------- */
  /*  Drag & Drop                                 */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onDragStart(event) {
    const { area } = event.target.closest("[data-area]")?.dataset ?? {};
    const { uuid } = event.target.closest("[data-uuid]")?.dataset ?? {};
    const { itemId } = event.target.closest("[data-item-id]")?.dataset ?? {};
    const { type } = foundry.utils.parseUuid(uuid) ?? {};
    if ( !area || (type !== "Actor") ) return super._onDragStart(event);
    event.dataTransfer.setData("text/plain", JSON.stringify({ area, itemId, type, uuid }));
  }

  /* -------------------------------------------- */

  /** @override */
  async _onDropActor(event, actor) {
    if ( !actor.system.isCreature ) return;
    let { area: src, itemId } = TextEditor$6.getDragEventData(event);
    const { area: dest="crew" } = event.target?.closest("[data-area]")?.dataset ?? {};
    if ( !itemId ) itemId = event.target?.closest("[data-item-id]")?.dataset.itemId;
    if ( src === dest ) return;
    const item = this.actor.items.get(itemId);
    if ( item && ((src === "draft") || (dest === "draft")) ) return this._onAssignCrew(actor, item, dest, { src });
    return this._onAdjustCrew(actor, dest, { src });
  }

  /* -------------------------------------------- */
  /*  Crew Management                             */
  /* -------------------------------------------- */

  /**
   * Handle adjusting a crew member's area or adding a new one.
   * @param {Actor5e} actor             The actor.
   * @param {CrewArea5e} dest           The destination area.
   * @param {object} [options]
   * @param {CrewArea5e} [options.src]  An optional area the crew member came from.
   * @protected
   */
  _onAdjustCrew(actor, dest, { src }={}) {
    const updates = {};
    if ( src ) Object.assign(updates, this.actor.system.getCrewUpdates(src, actor.uuid, "-1"));
    Object.assign(updates, this.actor.system.getCrewUpdates(dest, actor.uuid, "+1"));
    if ( !foundry.utils.isEmpty(updates) ) this.actor.update(updates);
  }

  /* -------------------------------------------- */

  /**
   * Handle assigning or unassigned a crew member to a station.
   * @param {Actor5e} actor            The actor.
   * @param {Item5e} item              The station.
   * @param {CrewArea5e} dest          The destination area.
   * @param {object} [options]
   * @param {CrewArea5e} [options.src] An optional area the crew member came from.
   * @protected
   */
  _onAssignCrew(actor, item, dest, { src }={}) {
    const itemUpdates = { _id: item.id };
    const actorUpdates = { items: [itemUpdates] };
    let crew = foundry.utils.getProperty(item, "system.crew.value");

    // Case 1 - Assigning to a station.
    if ( dest === "draft" ) {
      // Prevent assigning a non-crew-member.
      if ( src && (src !== "crew") ) return;
      // The actor may not be a crew member yet. If so, add them to the crew.
      if ( !src ) Object.assign(actorUpdates, this.actor.system.getCrewUpdates("crew", actor.uuid, "+1"));
      foundry.utils.setProperty(itemUpdates, "system.crew.value", crew.concat(actor.uuid));
      this.actor.update(actorUpdates);
    }

    // Case 2 - Unassigning from a station.
    else {
      crew = [...crew];
      if ( !crew.findSplice(u => u === actor.uuid) ) return;
      item.update({ "system.crew.value": crew });
    }
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Group crew by UUID.
   * @param {string[]} crew  The crew UUIDs.
   * @returns {Record<string, number>}
   */
  static groupCrew(crew) {
    return crew.reduce((obj, uuid) => {
      obj[uuid] ??= 0;
      obj[uuid]++;
      return obj;
    }, {});
  }

  /* -------------------------------------------- */

  /**
   * Resolve crew UUIDs.
   * @param {Record<string, number>} group     The counts for this group.
   * @param {Record<string, number>} [counts]  The true counts.
   * @returns {Promise<{ total: number, value: Array<{ actor: object, quantity: number, uuid: string }> }>}
   */
  static async resolveCrew(group, counts) {
    let total = 0;
    const value = Object.entries(group).filter(([, quantity]) => quantity).map(async ([uuid, quantity]) => {
      total += quantity;
      const actor = await fromUuid(uuid);
      const { img, name, system } = actor;
      const cr = system.details?.cr ?? system.details?.level;
      const subtitle = [
        CONFIG.DND5E.actorSizes[system.traits?.size]?.label,
        system.details?.type?.label,
        system.details?.cr ? game.i18n.format("DND5E.CRLabel", { cr: formatCR(system.details.cr) }) : null,
        system.details?.level ? game.i18n.format("DND5E.LevelNumber", { level: system.details.level }) : null
      ].filterJoin(" • ");
      return { uuid, quantity, actor: { cr, img, name, subtitle }, diff: (counts?.[uuid] ?? 0) - quantity };
    });
    return {
      total,
      value: (await Promise.all(value)).sort((a, b) => {
        return ((b.actor.cr ?? 0) - (a.actor.cr ?? 0))
          || (a.quantity - b.quantity)
          || a.actor.name.localeCompare(b.actor.name, game.i18n.lang);
      })
    };
  }

  /* -------------------------------------------- */

  /**
   * Determine if the sheet should show a Crew & Passengers tab.
   * @param {Actor5e} vehicle  The vehicle.
   * @returns {boolean}
   */
  static vehicleHasCrew(vehicle) {
    const { crew, passengers } = vehicle.system;
    return !!(crew.max || passengers.max);
  }
}

var _module$u = /*#__PURE__*/Object.freeze({
  __proto__: null,
  AbilityConfig: AbilityConfig,
  ArmorClassConfig: ArmorClassConfig,
  BaseActorSheet: BaseActorSheet,
  BaseConfigSheetV2: BaseConfigSheet,
  BaseProficiencyConfig: BaseProficiencyConfig,
  BaseRestDialog: BaseRestDialog,
  CharacterActorSheet: CharacterActorSheet,
  ConcentrationConfig: ConcentrationConfig,
  DamagesConfig: DamagesConfig,
  DeathConfig: DeathConfig,
  EncounterActorSheet: EncounterActorSheet,
  GroupActorSheet: GroupActorSheet,
  HabitatConfig: HabitatConfig,
  HitDiceConfig: HitDiceConfig,
  HitPointsConfig: HitPointsConfig,
  InitiativeConfig: InitiativeConfig,
  LanguagesConfig: LanguagesConfig,
  LongRestDialog: LongRestDialog,
  MultiActorSheet: MultiActorSheet,
  NPCActorSheet: NPCActorSheet,
  ShortRestDialog: ShortRestDialog,
  SkillToolConfig: SkillToolConfig,
  SkillsConfig: SkillsConfig,
  SpellSlotsConfig: SpellSlotsConfig,
  ToolsConfig: ToolsConfig,
  TraitsConfig: TraitsConfig,
  TransformDialog: TransformDialog,
  TreasureConfig: TreasureConfig,
  VehicleActorSheet: VehicleActorSheet,
  WeaponsConfig: WeaponsConfig
});

/**
 * 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 = {
      appId: this.id,
      CONFIG: CONFIG.DND5E,
      ...this.advancement.toObject(false),
      src: this.advancement._source,
      source: this.advancement._source,
      default: {
        title: this.advancement.constructor.metadata.title,
        icon: this.advancement.constructor.metadata.icon,
        hint: ""
      },
      levels,
      classRestrictionOptions: [
        { value: "", label: game.i18n.localize("DND5E.AdvancementClassRestrictionNone") },
        { value: "primary", label: game.i18n.localize("DND5E.AdvancementClassRestrictionPrimary") },
        { value: "secondary", label: game.i18n.localize("DND5E.AdvancementClassRestrictionSecondary") }
      ],
      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));

    for ( const element of html[0].querySelectorAll("multi-select") ) {
      element.addEventListener("change", this._onChangeInput.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]) => {
      let keep = false;
      if ( foundry.utils.getType(value) === "Object" ) {
        keep = Object.values(value).some(v => v);
      } else if ( value ) keep = true;
      if ( keep ) 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(i => i.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 = foundry.applications.ux.TextEditor.implementation.getDragEventData(event);

    if ( data?.type !== "Item" ) return false;
    const item = await Item.implementation.fromDropData(data);

    try {
      this._validateDroppedItem(event, item);
    } catch(err) {
      ui.notifications.error(err.message);
      return null;
    }

    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 ) {
      ui.notifications.error("DND5E.ADVANCEMENT.ItemGrant.Warning.Recursive", {localize: true});
      return null;
    }

    // Abort if this uuid exists already
    if ( existingItems.find(i => i.uuid === item.uuid) ) {
      ui.notifications.warn("DND5E.ADVANCEMENT.ItemGrant.Warning.Duplicate", {localize: true});
      return null;
    }

    await this.advancement.update({[`configuration.${this.options.dropKeyPath}`]: [
      ...existingItems, { uuid: 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) {}

}

var _module$t = /*#__PURE__*/Object.freeze({
  __proto__: null,
  AbilityScoreImprovementConfig: AbilityScoreImprovementConfig,
  AbilityScoreImprovementFlow: AbilityScoreImprovementFlow,
  AdvancementConfig: AdvancementConfig,
  AdvancementConfigV2: AdvancementConfig$1,
  AdvancementConfirmationDialog: AdvancementConfirmationDialog,
  AdvancementFlow: AdvancementFlow,
  AdvancementManager: AdvancementManager,
  AdvancementMigrationDialog: AdvancementMigrationDialog,
  HitPointsConfig: HitPointsConfig$1,
  HitPointsFlow: HitPointsFlow,
  ItemChoiceConfig: ItemChoiceConfig,
  ItemChoiceFlow: ItemChoiceFlow,
  ItemGrantConfig: ItemGrantConfig,
  ItemGrantFlow: ItemGrantFlow,
  ScaleValueConfig: ScaleValueConfig,
  ScaleValueFlow: ScaleValueFlow,
  SizeConfig: SizeConfig,
  SizeFlow: SizeFlow,
  SubclassFlow: SubclassFlow,
  TraitConfig: TraitConfig,
  TraitFlow: TraitFlow
});

var _module$s = /*#__PURE__*/Object.freeze({
  __proto__: null,
  Application5e: Application5e,
  ApplicationV2Mixin: ApplicationV2Mixin,
  Dialog5e: Dialog5e,
  DocumentSheet5e: DocumentSheet5e,
  DragDropApplicationMixin: DragDropApplicationMixin,
  PrimarySheetMixin: PrimarySheetMixin,
  PseudoDocumentSheet: PseudoDocumentSheet
});

var _module$r = /*#__PURE__*/Object.freeze({
  __proto__: null,
  BaseCalendarHUD: BaseCalendarHUD,
  CalendarHUD: CalendarHUD,
  SetDateDialog: SetDateDialog
});

/**
 * @import { CombatGroupData } from "./_types.mjs";
 */

/**
 * An extension of the base CombatTracker class to provide some 5e-specific functionality.
 * @extends {CombatTracker}
 */
class CombatTracker5e extends foundry.applications.sidebar.tabs.CombatTracker {

  /** @inheritDoc */
  async _prepareTrackerContext(context, options) {
    await super._prepareTrackerContext(context, options);
    context.turns?.forEach(turn => {
      if ( Number.isFinite(turn.initiative) ) {
        turn.initiative = formatNumber(Number(turn.initiative), { maximumFractionDigits: 0 });
      }
    });
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onCombatantControl(event, target) {
    const btn = target || event.currentTarget;
    const combatantId = btn.closest(".combatant").dataset.combatantId;
    const combatant = this.viewed.combatants.get(combatantId);
    const action = btn.dataset.control || btn.dataset.action;
    if ( (action === "rollInitiative") && combatant?.actor ) return combatant.actor.rollInitiativeDialog();
    return super._onCombatantControl(event, target);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _getEntryContextOptions() {
    const options = super._getEntryContextOptions();
    options.forEach(o => {
      const condition = o.condition ?? (function() { return true; });
      o.condition = li => condition(li) && !li.matches(".combatant-group");
    });
    return options;
  }

  /* -------------------------------------------- */

  /**
   * Adjust initiative tracker to group combatants.
   * @param {HTMLElement} html  The combat tracker being rendered.
   */
  renderGroups(html) {
    if ( !this.viewed ) return;
    const groups = this.viewed.createGroups();
    const list = html.querySelector(".directory-list, .combat-tracker");
    for ( const [key, { combatants, expanded }] of groups.entries() ) {
      const children = list.querySelectorAll(Array.from(combatants).map(c => `[data-combatant-id="${c.id}"]`).join(", "));
      if ( !children.length ) continue;
      const groupContainer = document.createElement("li");
      groupContainer.classList.add("combatant", "combatant-group", "collapsible", "dnd5e2-collapsible");
      if ( !expanded ) groupContainer.classList.add("collapsed");

      // Determine the count
      let activeEntry;
      for ( const [index, element] of children.entries() ) {
        if ( element.classList.contains("active") ) activeEntry = index;
      }
      let count = game.i18n.format(`DND5E.COMBATANT.Counted.${getPluralRules().select(children.length)}`, {
        number: formatNumber(children.length)
      });
      if ( activeEntry !== undefined ) {
        groupContainer.classList.add("active");
        count = game.i18n.format("DND5E.COMBAT.Group.ActiveCount", {
          combatants: count, current: formatNumber(activeEntry + 1)
        });
      }

      const name = this.constructor.getGroupName(combatants);
      const img = children[0].querySelector("img");
      groupContainer.innerHTML = `
        <div class="group-header flexrow">
          <img class="token-image" alt="${img.alt}" src="${img.src || img.dataset.src}">
          <div class="token-name flexcol">
            <strong class="name"></strong>
            <div class="group-numbers">${count}</div>
          </div>
          <div class="token-initiative">
            <i class="fa-solid fa-chevron-down fa-fw" inert></i>
          </div>
        </div>
        <div class="collapsible-content">
          <div class="wrapper">
            <ol class="group-children"></ol>
          </div>
        </div>
      `;
      groupContainer.dataset.groupKey = key;
      groupContainer.querySelector(".name").innerText = game.i18n.format("DND5E.COMBAT.Group.Title", { name });
      children[0].before(groupContainer);
      groupContainer.querySelector(".group-children").replaceChildren(...children);
      groupContainer.addEventListener("click", event => {
        if ( event.target.closest(".collapsible-content") ) return;
        if ( groupContainer.classList.contains("collapsed") ) this.viewed.expandedGroups.add(key);
        else this.viewed.expandedGroups.delete(key);
        groupContainer.classList.toggle("collapsed");
      });
    }
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Retrieve an appropriate group name for a list of combatants.
   * @param {Combatant[]} combatants  The combatants.
   * @returns {string}
   */
  static getGroupName(combatants) {
    if ( !combatants.length ) return "";
    const tokenNames = combatants.map(c => c.token?.name ?? c.name);
    const actorName = combatants[0].token?.baseActor.prototypeToken.name ?? combatants[0].name;
    if ( tokenNames.every(name => name === tokenNames[0]) ) return tokenNames[0];
    if ( tokenNames.every(name => name.includes(actorName)) ) return actorName;
    return tokenNames[0];
  }
}

var _module$q = /*#__PURE__*/Object.freeze({
  __proto__: null,
  CombatTracker5e: CombatTracker5e
});

/**
 * Custom element that handles displaying activities lists.
 */
class ActivitiesElement extends (foundry.applications.elements.AdoptableHTMLElement ?? HTMLElement) {
  connectedCallback() {
    if ( this.#app ) return;
    this.#app = foundry.applications.instances.get(this.closest(".application")?.id);

    for ( const control of this.querySelectorAll("[data-context-menu]") ) {
      control.addEventListener("click", ContextMenu5e.triggerEvent);
    }

    new ContextMenu5e(this, "[data-activity-id]", [], {
      onOpen: target => dnd5e.documents.activity.UtilityActivity.onContextMenu(this.document, target), jQuery: false
    });
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The HTML tag named used by this element.
   * @type {string}
   */
  static tagName = "dnd5e-activities";

  /* -------------------------------------------- */

  /**
   * The application that contains this component.
   * @type {ApplicationV2}
   */
  get app() {
    return this.#app;
  }

  #app;

  /* -------------------------------------------- */

  /**
   * The Document containing the activities.
   * @type {Document}
   */
  get document() {
    return this.app.document;
  }
}

/**
 * Bit of text with a button after it for copying it.
 */
class CopyableTextElement extends (foundry.applications.elements.AdoptableHTMLElement ?? HTMLElement) {
  /** @override */
  connectedCallback() {
    this.#controller = new AbortController();
    const button = document.createElement("button");
    button.ariaLabel = this.getAttribute("label") ?? game.i18n.localize("DND5E.Copy");
    button.classList.add("copy-button");
    button.dataset.tooltip = button.ariaLabel;
    button.innerHTML = '<i class="fa-regular fa-clipboard" inert></i>';
    this.addEventListener("click", this._onClick.bind(this), { signal: this.#controller.signal });
    this.append(button);
  }

  /* -------------------------------------------- */

  /**
   * The HTML tag named used by this element.
   * @type {string}
   */
  static tagName = "copyable-text";

  /* -------------------------------------------- */

  /**
   * Controller for removing listeners automatically.
   * @type {AbortController}
   */
  #controller;

  /* -------------------------------------------- */

  /** @override */
  disconnectedCallback() {
    this.#controller.abort();
    this.querySelector("button")?.remove();
  }

  /* -------------------------------------------- */

  /**
   * Handle copying the contents.
   * @param {PointerEvent} event  Triggering click event.
   */
  _onClick(event) {
    event.preventDefault();
    event.stopPropagation();
    const value = this.getAttribute("value") ?? this.innerText;
    game.clipboard.copyPlainText(value);
    game.tooltip.activate(event.target, { text: game.i18n.format("DND5E.Copied", { value }), direction: "UP" });
  }
}

/**
 * Custom element designed to display as a collapsible tray in chat.
 */
class ChatTrayElement extends (foundry.applications.elements.AdoptableHTMLElement ?? HTMLElement) {

  static observedAttributes = ["open"];

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Is the tray expanded or collapsed?
   * @type {boolean}
   */
  get open() {
    return this.hasAttribute("open");
  }

  set open(open) {
    this.toggleAttribute("open", open);
  }

  /* -------------------------------------------- */

  /**
   * Whether the tray is visible in the chat log.
   * @returns {boolean}
   */
  get visible() {
    return this.hasAttribute("visible") || this.matches("#chat-notifications :scope");
  }

  set visible(visible) {
    this.toggleAttribute("visible", visible);
    if ( visible ) this._onVisible();
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /** @override */
  attributeChangedCallback(name, oldValue, newValue) {
    if ( name === "open" ) this._handleToggleOpen(newValue !== null);
  }

  /* -------------------------------------------- */

  /**
   * Handle clicks to the collapsible header.
   * @param {PointerEvent} event  Triggering click event.
   */
  _handleClickHeader(event) {
    event.preventDefault();
    event.stopImmediatePropagation();
    if ( !event.target.closest(".collapsible-content") ) this.toggleAttribute("open");
  }

  /* -------------------------------------------- */

  /**
   * Handle changing the collapsed state of this element.
   * @param {boolean} open  Is the element open?
   */
  _handleToggleOpen(open) {
    this.dispatchEvent(new Event("toggle"));
    if ( open ) this._onOpen();

    this.querySelector(".collapsible")?.classList.toggle("collapsed", !open);

    // Clear the height from the chat popout container so that it appropriately resizes.
    const popout = this.closest(".chat-popout");
    if ( popout ) popout.style.height = "";
  }

  /* -------------------------------------------- */

  /**
   * Optionally perform some action when this element is toggled open.
   * @protected
   */
  _onOpen() {}

  /* -------------------------------------------- */

  /**
   * Optionally perform some action when this element becomes visible.
   * @protected
   */
  _onVisible() {}
}

/**
 * Adds functionality to a custom HTML element for displaying a target selector and displaying targets.
 * @param {typeof HTMLElement} Base  The base class being mixed.
 * @returns {typeof TargetedApplicationElement}
 */
function TargetedApplicationMixin(Base) {
  return class TargetedApplicationElement extends Base {
    /* -------------------------------------------- */
    /*  Properties                                  */
    /* -------------------------------------------- */

    /**
     * Currently registered hook for monitoring for changes to selected tokens.
     * @type {number|null}
     */
    selectedTokensHook = null;

    /* -------------------------------------------- */

    /**
     * Whether to rebuild the target list.
     * @type {boolean|void}
     */
    get shouldBuildTargetList() {
      return !!this.targetList;
    }

    /* -------------------------------------------- */

    /**
     * Currently target selection mode.
     * @type {"targeted"|"selected"}
     */
    get targetingMode() {
      if ( this.targetSourceControl.hidden ) return "selected";
      return this.targetSourceControl.querySelector('[aria-pressed="true"]')?.dataset.mode ?? "targeted";
    }

    set targetingMode(mode) {
      if ( this.targetSourceControl.hidden ) mode = "selected";
      const toPress = this.targetSourceControl.querySelector(`[data-mode="${mode}"]`);
      const currentlyPressed = this.targetSourceControl.querySelector('[aria-pressed="true"]');
      if ( currentlyPressed ) currentlyPressed.ariaPressed = false;
      toPress.ariaPressed = true;

      this.buildTargetsList();
      if ( (mode === "targeted") && (this.selectedTokensHook !== null) ) {
        Hooks.off("controlToken", this.selectedTokensHook);
        this.selectedTokensHook = null;
      } else if ( (mode === "selected") && (this.selectedTokensHook === null) ) {
        this.selectedTokensHook = Hooks.on("controlToken", foundry.utils.debounce(() => this.buildTargetsList(), 50));
      }
    }

    /* -------------------------------------------- */

    /**
     * The list of application targets.
     * @type {HTMLUListElement}
     */
    targetList;

    /* -------------------------------------------- */

    /**
     * The controls for selecting target source mode.
     * @type {HTMLElement}
     */
    targetSourceControl;

    /* -------------------------------------------- */
    /*  Life-Cycle                                  */
    /* -------------------------------------------- */

    /** @inheritDoc */
    disconnectedCallback() {
      super.disconnectedCallback?.();
      if ( this.selectedTokensHook ) Hooks.off("controlToken", this.selectedTokensHook);
    }

    /* -------------------------------------------- */
    /*  Rendering                                   */
    /* -------------------------------------------- */

    /**
     * Return the HTML elements needed to build the target source control and target list.
     * @returns {HTMLElement[]}
     */
    buildTargetContainer() {
      this.targetSourceControl = document.createElement("div");
      this.targetSourceControl.classList.add("target-source-control");
      this.targetSourceControl.innerHTML = `
        <button type="button" class="unbutton" data-mode="targeted" aria-pressed="false">
          <i class="fa-solid fa-bullseye" inert></i> ${game.i18n.localize("DND5E.Tokens.Targeted")}
        </button>
        <button type="button" class="unbutton" data-mode="selected" aria-pressed="false">
          <i class="fa-solid fa-expand" inert></i> ${game.i18n.localize("DND5E.Tokens.Selected")}
        </button>
      `;
      this.targetSourceControl.querySelectorAll("button").forEach(b =>
        b.addEventListener("click", this._onChangeTargetMode.bind(this))
      );
      if ( !this.chatMessage?.getFlag("dnd5e", "targets")?.length ) this.targetSourceControl.hidden = true;

      this.targetList = document.createElement("ul");
      this.targetList.classList.add("targets", "unlist");

      return [this.targetSourceControl, this.targetList];
    }

    /* -------------------------------------------- */

    /**
     * Build a list of targeted tokens based on current mode & replace any existing targets.
     */
    buildTargetsList() {
      if ( this.shouldBuildTargetList === false ) return;
      const targetedTokens = new Map();
      switch ( this.targetingMode ) {
        case "targeted":
          this.chatMessage?.getFlag("dnd5e", "targets")?.forEach(t => targetedTokens.set(t.uuid, t.name));
          break;
        case "selected":
          canvas.tokens?.controlled?.forEach(t => {
            if ( t.actor ) targetedTokens.set(t.actor.uuid, t.name);
          });
          break;
      }
      const targets = Array.from(targetedTokens.entries())
        .map(([uuid, name]) => this.buildTargetListEntry({ uuid, name }))
        .filter(t => t);
      if ( targets.length ) this.targetList.replaceChildren(...targets);
      else {
        const li = document.createElement("li");
        li.classList.add("none");
        li.innerText = game.i18n.localize(`DND5E.Tokens.None${this.targetingMode.capitalize()}`);
        this.targetList.replaceChildren(li);
      }
    }

    /* -------------------------------------------- */

    /**
     * Create a list entry for a single target.
     * @param {object} data
     * @param {string} data.uuid  UUID of the targeted actor.
     * @param {string} data.name  Name of the targeted token.
     * @returns {HTMLLIElement|void}
     * @abstract
     */
    buildTargetListEntry({ uuid, name }) {}

    /* -------------------------------------------- */
    /*  Event Handlers                              */
    /* -------------------------------------------- */

    /**
     * Handle clicking on the target mode buttons.
     * @param {PointerEvent} event  Triggering click event.
     */
    async _onChangeTargetMode(event) {
      event.preventDefault();
      this.targetingMode = event.currentTarget.dataset.mode;
    }
  };
}

/**
 * @import { DamageApplicationOptions, DamageDescription } from "../../documents/_types.mjs";
 */

/**
 * List of multiplier options as tuples containing their numeric value and rendered text.
 * @type {[number, string][]}
 */
const MULTIPLIERS = [[-1, "-1"], [0, "0"], [.25, "¼"], [.5, "½"], [1, "1"], [2, "2"]];

/**
 * Application to handle applying damage from a chat card.
 */
class DamageApplicationElement extends TargetedApplicationMixin(ChatTrayElement) {

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The HTML tag named used by this element.
   * @type {string}
   */
  static tagName = "damage-application";

  /* -------------------------------------------- */

  /**
   * The apply damage button within the element.
   * @type {HTMLButtonElement}
   */
  applyButton;

  /* -------------------------------------------- */

  /**
   * The chat message with which this damage is associated.
   * @type {ChatMessage5e}
   */
  chatMessage;

  /* -------------------------------------------- */

  /**
   * Damage descriptions that will be applied by this application.
   * @type {DamageDescription[]}
   */
  damages = [];

  /* -------------------------------------------- */

  /** @override */
  get shouldBuildTargetList() {
    return super.shouldBuildTargetList && this.open && this.visible;
  }

  /* -------------------------------------------- */

  /**
   * Options for each application target.
   * @type {Map<string, DamageApplicationOptions>}
   */
  #targetOptions = new Map();

  /**
   * Options for a specific target.
   * @param {string} uuid  UUID of the targeted token.
   * @returns {DamageApplicationOptions}
   */
  getTargetOptions(uuid) {
    if ( !this.#targetOptions.has(uuid) ) this.#targetOptions.set(uuid, { multiplier: 1 });
    return this.#targetOptions.get(uuid);
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  connectedCallback() {
    // Fetch the associated chat message
    const messageId = this.closest("[data-message-id]")?.dataset.messageId;
    this.chatMessage = game.messages.get(messageId);
    if ( !this.chatMessage ) return;

    // Build the frame HTML only once
    if ( !this.targetList ) {
      const div = document.createElement("div");
      div.classList.add("card-tray", "damage-tray", "collapsible");
      if ( !this.open ) div.classList.add("collapsed");
      div.innerHTML = `
        <label class="roboto-upper">
          <i class="fa-solid fa-heart-crack"></i>
          <span>${game.i18n.localize("DND5E.Apply")}</span>
          <i class="fa-solid fa-caret-down"></i>
        </label>
        <div class="collapsible-content">
          <div class="wrapper">
            <button class="apply-damage" type="button" data-action="applyDamage">
              <i class="fa-solid fa-reply-all fa-flip-horizontal" inert></i>
              ${game.i18n.localize("DND5E.Apply")}
            </button>
          </div>
        </div>
      `;
      this.replaceChildren(div);
      this.applyButton = div.querySelector(".apply-damage");
      this.applyButton.addEventListener("click", this._onApplyDamage.bind(this));
      div.querySelector(".wrapper").prepend(...this.buildTargetContainer());
      div.addEventListener("click", this._handleClickHeader.bind(this));
    }

    this.targetingMode = this.targetSourceControl.hidden ? "selected" : "targeted";
  }

  /* -------------------------------------------- */

  /** @override */
  buildTargetListEntry({ uuid, name }) {
    const actor = fromUuidSync(uuid);
    if ( !actor?.isOwner ) return;

    // Calculate damage to apply
    const targetOptions = this.getTargetOptions(uuid);
    const { temp, total, active } = this.calculateDamage(actor, targetOptions);

    const types = [];
    for ( const [change, values] of Object.entries(active) ) {
      if ( foundry.utils.getType(values) !== "Set" ) continue;
      for ( const type of values ) {
        const config = CONFIG.DND5E.damageTypes[type] ?? CONFIG.DND5E.healingTypes[type];
        if ( !config ) continue;
        const data = { type, change, icon: config.icon };
        types.push(data);
      }
    }
    const changeSources = types.reduce((acc, config) => acc + this.getChangeSourceButton(config, targetOptions), "");

    const li = document.createElement("li");
    li.classList.add("target");
    li.dataset.targetUuid = uuid;
    li.innerHTML = `
      <img class="gold-icon">
      <div class="name-stacked">
        <span class="title"></span>
        <span class="subtitle">${changeSources}</span>
      </div>
      <div class="calculated damage">
        ${total}
      </div>
      <div class="calculated temp" data-tooltip="DND5E.HitPointsTemp">
        ${temp}
      </div>
      <menu class="damage-multipliers unlist"></menu>
    `;
    Object.assign(li.querySelector(".gold-icon"), { alt: name, src: actor.img });
    li.querySelector(".name-stacked .title").append(name);
    const menu = li.querySelector("menu");
    for ( const [value, display] of MULTIPLIERS ) {
      const entry = document.createElement("li");
      entry.innerHTML = `
        <button class="multiplier-button" type="button" value="${value}">
          <span>${display}</span>
        </button>
      `;
      menu.append(entry);
    }

    this.refreshListEntry(actor, li, targetOptions);
    li.addEventListener("click", this._onChangeOptions.bind(this));

    return li;
  }

  /* -------------------------------------------- */

  /**
   * Calculate the total damage that will be applied to an actor.
   * @param {Actor5e} actor
   * @param {DamageApplicationOptions} options
   * @returns {{temp: number, total: number, active: Record<string, Set<string>>}}
   */
  calculateDamage(actor, options) {
    const damages = actor.calculateDamage(this.damages, options);
    let { amount, temp } = damages;

    let active = {
      modification: new Set(), resistance: new Set(), vulnerability: new Set(), immunity: new Set(), threshold: false
    };
    for ( const damage of damages ) {
      if ( damage.active.modification ) active.modification.add(damage.type);
      if ( damage.active.resistance ) active.resistance.add(damage.type);
      if ( damage.active.vulnerability ) active.vulnerability.add(damage.type);
      if ( damage.active.immunity ) active.immunity.add(damage.type);
      if ( damage.active.threshold ) active.threshold = true;
    }
    temp = Math.floor(Math.max(0, temp));

    // Add values from options to prevent active changes from being lost when re-rendering target list
    const union = t => {
      if ( foundry.utils.getType(options.ignore?.[t]) === "Set" ) active[t] = active[t].union(options.ignore[t]);
    };
    union("modification");
    union("resistance");
    union("vulnerability");
    union("immunity");
    if ( foundry.utils.getType(options.downgrade) === "Set" ) {
      active.immunity = active.immunity.union(options.downgrade);
    }
    active.threshold ||= options.ignore?.threshold;

    return { temp, total: amount, active };
  }

  /* -------------------------------------------- */

  /**
   * Create the HTML for a new change source button.
   * @param {{ change: string, icon: string, type: string }} config
   * @param {DamageApplicationOptions} targetOptions
   * @returns {string}
   */
  getChangeSourceButton({ change, icon, type }, targetOptions) {
    const { label, pressed } = this.getChangeSourceOptions(type, change, targetOptions);
    return `
      <button class="change-source unbutton" type="button" data-type="${type}" data-change="${change}"
              data-tooltip aria-label="${label}" aria-pressed="${pressed}">
        <dnd5e-icon src="${icon}" inert></dnd5e-icon>
        <i class="fa-solid fa-slash" inert></i>
        <i class="fa-solid fa-arrow-turn-down" inert></i>
      </button>
    `;
  }

  /* -------------------------------------------- */

  /**
   * Get the label and pressed value for a specific change source.
   * @param {string} type                       Damage type represented by this source.
   * @param {string} change                     Change type (e.g. resistance, immunity, etc.).
   * @param {DamageApplicationOptions} options  Options object from which to determine final values.
   * @returns {{label: string, pressed: string}}
   */
  getChangeSourceOptions(type, change, options) {
    let mode = "active";
    if ( (options.ignore?.[change] === true) || options.ignore?.[change]?.has?.(type) ) mode = "ignore";
    else if ( (change === "immunity") && options.downgrade?.has(type) ) mode = "downgrade";

    let label = game.i18n.format(`DND5E.DamageApplication.Change.${change.capitalize()}`, {
      type: CONFIG.DND5E.damageTypes[type]?.label ?? CONFIG.DND5E.healingTypes[type]?.label
    });
    if ( mode === "ignore" ) label = game.i18n.format("DND5E.DamageApplication.Ignoring", { source: label });
    if ( mode === "downgrade" ) label = game.i18n.format("DND5E.DamageApplication.Downgrading", { source: label });

    return { label, pressed: mode === "active" ? "false" : mode === "ignore" ? "true" : "mixed" };
  }

  /* -------------------------------------------- */

  /**
   * Refresh the damage total on a list entry based on modified options.
   * @param {Actor5e} token
   * @param {HTMLLiElement} entry
   * @param {DamageApplicationOptions} options
   */
  refreshListEntry(token, entry, options) {
    const { active, temp, total } = this.calculateDamage(token, options);
    const calculatedDamage = entry.querySelector(".calculated.damage");
    calculatedDamage.innerText = formatNumber(-total, { signDisplay: "exceptZero" });
    calculatedDamage.classList.toggle("healing", total < 0);
    calculatedDamage.dataset.tooltip = `DND5E.${total < 0 ? "Healing" : "Damage"}`;
    calculatedDamage.hidden = !total && !!temp;
    const calculatedTemp = entry.querySelector(".calculated.temp");
    calculatedTemp.innerText = temp;
    calculatedTemp.hidden = !temp;

    const pressedMultiplier = entry.querySelector('.multiplier-button[aria-pressed="true"]');
    if ( Number(pressedMultiplier?.dataset.multiplier) !== options.multiplier ) {
      if ( pressedMultiplier ) pressedMultiplier.ariaPressed = false;
      const toPress = entry.querySelector(`[value="${options.multiplier}"]`);
      if ( toPress ) toPress.ariaPressed = true;
    }

    const thresholdButton = entry.querySelector('[data-change="threshold"]');
    if ( thresholdButton && !active.threshold ) thresholdButton.remove();
    else if ( !thresholdButton && active.threshold ) {
      const button = this.getChangeSourceButton({
        change: "threshold", icon: "systems/dnd5e/icons/svg/damage/threshold.svg", type: "threshold"
      }, this.getTargetOptions(entry.dataset.uuid));
      entry.querySelector(".subtitle").insertAdjacentHTML("beforeend", button);
    }

    for ( const element of entry.querySelectorAll(".change-source") ) {
      const { type, change } = element.dataset;
      const { label, pressed } = this.getChangeSourceOptions(type, change, options);
      element.dataset.tooltip = label;
      element.ariaLabel = label;
      element.ariaPressed = pressed;
    }
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /**
   * Handle clicking the apply damage button.
   * @param {PointerEvent} event  Triggering click event.
   */
  async _onApplyDamage(event) {
    event.preventDefault();
    for ( const target of this.targetList.querySelectorAll("[data-target-uuid]") ) {
      const token = fromUuidSync(target.dataset.targetUuid);
      const options = this.getTargetOptions(target.dataset.targetUuid);
      await token?.applyDamage(this.damages, { ...options, isDelta: true });
    }
    if ( game.settings.get("dnd5e", "autoCollapseChatTrays") !== "manual" ) {
      this.open = false;
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle clicking a multiplier button or resistance toggle.
   * @param {PointerEvent} event  Triggering click event.
   */
  async _onChangeOptions(event) {
    event.preventDefault();
    const button = event.target.closest("button");
    const uuid = event.target.closest("[data-target-uuid]")?.dataset.targetUuid;
    if ( !uuid || !button ) return;

    const options = this.getTargetOptions(uuid);

    // Set multiplier
    if ( button.classList.contains("multiplier-button") ) {
      options.multiplier = Number(button.value);
    }

    // Set imm/res/vul ignore & downgrade
    else if ( button.classList.contains("change-source") ) {
      const { type, change } = button.dataset;
      if ( change === "immunity" ) {
        if ( options.ignore?.immunity?.has(type) ) {
          options.ignore.immunity.delete(type);
          options.downgrade ??= new Set();
          options.downgrade.add(type);
        } else if ( options.downgrade?.has(type) ) {
          options.downgrade.delete(type);
        } else {
          options.ignore ??= {};
          options.ignore[change] ??= new Set();
          options.ignore[change].add(type);
        }
      }
      else if ( change === "threshold" ) {
        options.ignore ??= {};
        options.ignore.threshold = !options.ignore.threshold;
      }
      else if ( options.ignore?.[change]?.has(type) ) options.ignore[change].delete(type);
      else {
        options.ignore ??= {};
        options.ignore[change] ??= new Set();
        options.ignore[change].add(type);
      }
    }

    const token = fromUuidSync(uuid);
    const entry = this.targetList.querySelector(`[data-target-uuid="${token.uuid}"]`);
    this.refreshListEntry(token, entry, options);
  }

  /* -------------------------------------------- */

  /** @override */
  _onOpen() {
    this.buildTargetsList();
  }

  /* -------------------------------------------- */

  /** @override */
  _onVisible() {
    this.buildTargetsList();
  }
}

/**
 * Application to handle applying active effects from a chat card.
 */
class EffectApplicationElement extends TargetedApplicationMixin(ChatTrayElement) {

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The HTML tag named used by this element.
   * @type {string}
   */
  static tagName = "effect-application";

  /* -------------------------------------------- */

  /**
   * The chat message with which this application is associated.
   * @type {ChatMessage5e}
   */
  chatMessage;

  /* -------------------------------------------- */

  /**
   * Active effects that will be applied by this application.
   * @type {ActiveEffect5e[]}
   */
  effects = [];

  /* -------------------------------------------- */

  /**
   * The list of active effects.
   * @type {HTMLUListElement}
   */
  effectsList;

  /* -------------------------------------------- */

  /** @override */
  get shouldBuildTargetList() {
    return super.shouldBuildTargetList && this.open && this.visible;
  }

  /* -------------------------------------------- */

  /**
   * Checked status for application targets.
   * @type {Map<string, boolean>}
   */
  #targetOptions = new Map();

  /**
   * Options for a specific target.
   * @param {string} uuid  UUID of the target.
   * @returns {boolean}    Should this target be checked?
   */
  targetChecked(uuid) {
    if ( this.targetingMode === "selected" ) return true;
    return this.#targetOptions.get(uuid) ?? true;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  connectedCallback() {
    // Fetch the associated chat message
    const messageId = this.closest("[data-message-id]")?.dataset.messageId;
    this.chatMessage = game.messages.get(messageId);
    if ( !this.chatMessage ) return;

    // Build the frame HTML only once
    if ( !this.effectsList || !this.targetList ) {
      const div = document.createElement("div");
      div.classList.add("card-tray", "effects-tray", "collapsible");
      if ( !this.open ) div.classList.add("collapsed");
      div.innerHTML = `
        <label class="roboto-upper">
          <i class="fa-solid fa-bolt"></i>
          <span>${game.i18n.localize("DND5E.Effects")}</span>
          <i class="fa-solid fa-caret-down"></i>
        </label>
        <div class="collapsible-content">
          <div class="wrapper">
            <hr>
            <menu class="effects unlist"></menu>
          </div>
        </div>
      `;
      this.replaceChildren(div);
      this.effectsList = div.querySelector(".effects");
      this.buildEffectsList();
      div.querySelector(".wrapper").prepend(...this.buildTargetContainer());
      this.targetList.addEventListener("change", this._onCheckTarget.bind(this));
      div.addEventListener("click", this._handleClickHeader.bind(this));
    }

    this.targetingMode = this.targetSourceControl.hidden ? "selected" : "targeted";
  }

  /* -------------------------------------------- */

  /**
   * Build a list of active effects.
   */
  buildEffectsList() {
    for ( const effect of this.effects ) {
      effect.updateDuration();
      const li = document.createElement("li");
      li.classList.add("effect");
      li.dataset.id = effect.id;
      li.innerHTML = `
        <img class="gold-icon">
        <div class="name-stacked">
          <span class="title"></span>
          <span class="subtitle">${effect.duration.label}</span>
        </div>
        <button class="apply-effect" type="button" data-action="applyEffect"
                data-tooltip aria-label="${game.i18n.localize("DND5E.EffectsApplyTokens")}">
          <i class="fas fa-reply-all fa-flip-horizontal" inert></i>
        </button>
      `;
      Object.assign(li.querySelector(".gold-icon"), { alt: effect.name, src: effect.img });
      li.querySelector(".name-stacked .title").append(effect.name);
      this.effectsList.append(li);
      li.addEventListener("click", this._onApplyEffect.bind(this));
    }
  }

  /* -------------------------------------------- */

  /** @override */
  buildTargetListEntry({ uuid, name }) {
    const actor = fromUuidSync(uuid);
    if ( !actor?.isOwner ) return;

    const disabled = this.targetingMode === "selected" ? " disabled" : "";
    const checked = this.targetChecked(uuid) ? " checked" : "";

    const li = document.createElement("li");
    li.classList.add("target");
    li.dataset.targetUuid = uuid;
    li.innerHTML = `
      <img class="gold-icon">
      <div class="name-stacked">
        <span class="title"></span>
      </div>
      <div class="checkbox">
        <dnd5e-checkbox name="${uuid}"${checked}${disabled}></dnd5e-checkbox>
      </div>
    `;
    Object.assign(li.querySelector(".gold-icon"), { alt: name, src: actor.img });
    li.querySelector(".name-stacked .title").append(name);

    return li;
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /**
   * Handle applying an Active Effect to a Token.
   * @param {ActiveEffect5e} effect      The effect to apply.
   * @param {Actor5e} actor              The actor.
   * @returns {Promise<ActiveEffect5e>}  The created effect.
   * @throws {Error}                     If the effect could not be applied.
   * @protected
   */
  async _applyEffectToActor(effect, actor) {
    const concentration = this.chatMessage.getAssociatedActor()?.effects
      .get(this.chatMessage.getFlag("dnd5e", "use.concentrationId"));
    const origin = concentration ?? effect;
    if ( !game.user.isGM && !actor.isOwner ) {
      throw new Error(game.i18n.localize("DND5E.EffectApplyWarningOwnership"));
    }

    const effectFlags = {
      flags: {
        dnd5e: {
          dependentOn: origin.uuid,
          scaling: this.chatMessage.getFlag("dnd5e", "scaling"),
          spellLevel: this.chatMessage.getFlag("dnd5e", "use.spellLevel")
        }
      }
    };

    // Enable an existing effect on the target if it originated from this effect
    const existingEffect = actor.effects.find(e => e.origin === origin.uuid);
    if ( existingEffect ) {
      return existingEffect.update(foundry.utils.mergeObject({
        ...effect.constructor.getInitialDuration(),
        disabled: false
      }, effectFlags));
    }

    if ( !game.user.isGM && concentration && !concentration.isOwner ) {
      throw new Error(game.i18n.localize("DND5E.EffectApplyWarningConcentration"));
    }

    // Otherwise, create a new effect on the target
    const effectData = foundry.utils.mergeObject({
      ...effect.toObject(),
      disabled: false,
      transfer: false,
      origin: origin.uuid
    }, effectFlags);
    return await ActiveEffect.implementation.create(effectData, { parent: actor });
  }

  /* -------------------------------------------- */

  /**
   * Handle clicking the apply effect button.
   * @param {PointerEvent} event  Triggering click event.
   */
  async _onApplyEffect(event) {
    event.preventDefault();
    const effect = this.chatMessage.getAssociatedItem()?.effects.get(event.target.closest("[data-id]")?.dataset.id);
    if ( !effect ) return;
    for ( const target of this.targetList.querySelectorAll("[data-target-uuid]") ) {
      const actor = fromUuidSync(target.dataset.targetUuid);
      if ( !actor || !target.querySelector("dnd5e-checkbox")?.checked ) continue;
      try {
        await this._applyEffectToActor(effect, actor);
      } catch(err) {
        Hooks.onError("EffectApplicationElement._applyEffectToToken", err, { notify: "warn", log: "warn" });
      }
    }
    if ( game.settings.get("dnd5e", "autoCollapseChatTrays") !== "manual" ) {
      this.querySelector(".collapsible").dispatchEvent(new PointerEvent("click", { bubbles: true, cancelable: true }));
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle checking or unchecking a target.
   * @param {Event} event  Triggering change event.
   */
  _onCheckTarget(event) {
    const uuid = event.target.closest("[data-target-uuid]")?.dataset.targetUuid;
    if ( !uuid ) return;
    this.#targetOptions.set(uuid, event.target.checked);
  }

  /* -------------------------------------------- */

  /** @override */
  _onOpen() {
    this.buildTargetsList();
  }

  /* -------------------------------------------- */

  /** @override */
  _onVisible() {
    this.buildTargetsList();
  }
}

const MaybeAdoptable$2 = foundry.applications.elements.AdoptableHTMLElement ?? HTMLElement;

/**
 * Application to handle applying enchantments to items from a chat card.
 */
class EnchantmentApplicationElement extends MaybeAdoptable$2 {

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The HTML tag named used by this element.
   * @type {string}
   */
  static tagName = "enchantment-application";

  /* -------------------------------------------- */

  /**
   * The chat message with which this enchantment is associated.
   * @type {ChatMessage5e}
   */
  chatMessage;

  /* -------------------------------------------- */

  /**
   * Area where the enchantment limit & current count is displayed.
   * @type {HTMLElement}
   */
  countArea;

  /* -------------------------------------------- */

  /**
   * Area where items can be dropped to enchant.
   * @type {HTMLElement}
   */
  dropArea;

  /* -------------------------------------------- */

  /**
   * Activity providing the enchantment that will be applied.
   * @type {Item5e}
   */
  get enchantmentActivity() {
    return this.chatMessage.getAssociatedActivity();
  }

  /* -------------------------------------------- */

  /**
   * Item providing the enchantment that will be applied.
   * @type {Item5e}
   */
  get enchantmentItem() {
    return this.chatMessage.getAssociatedItem();
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  connectedCallback() {
    const messageId = this.closest("[data-message-id]")?.dataset.messageId;
    this.chatMessage = game.messages.get(messageId);
    if ( !this.chatMessage ) return;

    // Build the frame HTML only once
    if ( !this.dropArea ) {
      const div = document.createElement("div");
      div.classList.add("enchantment-control");
      div.innerHTML = '<div class="drop-area"></div>';
      this.replaceChildren(div);
      this.dropArea = div.querySelector(".drop-area");
      this.addEventListener("drop", this._onDrop.bind(this));
      this.addEventListener("click", this._onRemoveEnchantment.bind(this));
    }

    // Calculate the maximum targets
    let item = this.enchantmentItem;
    const scaling = this.chatMessage.getFlag("dnd5e", "scaling");
    if ( scaling ) item = item.clone({ "flags.dnd5e.scaling": scaling });
    const activity = item.system.activities.get(this.enchantmentActivity.id);
    const maxTargets = activity.target?.affects?.count;
    if ( maxTargets ) {
      if ( !this.countArea ) {
        const div = document.createElement("div");
        div.classList.add("count-area");
        this.querySelector(".enchantment-control").append(div);
        this.countArea = this.querySelector(".count-area");
      }
      this.countArea.innerHTML = game.i18n.format("DND5E.ENCHANT.Enchanted", {
        current: '<span class="current">0</span>',
        max: `<span class="max">${maxTargets}<span>`
      });
    } else if ( this.countArea ) {
      this.countArea.remove();
    }

    this.buildItemList();
  }

  /* -------------------------------------------- */

  /**
   * Build a list of enchanted items. Will be called whenever the enchanted items are changed in order to update
   * the card list.
   */
  buildItemList() {
    const enchantedItems = dnd5e.registry.enchantments.applied(this.enchantmentActivity.uuid).map(enchantment => {
      const item = enchantment.parent;
      const div = document.createElement("div");
      div.classList.add("preview");
      div.dataset.enchantmentUuid = enchantment.uuid;
      div.innerHTML = `
        <img class="gold-icon">
        <span class="name"></span>
      `;
      Object.assign(div.querySelector("img"), { alt: item.name, src: item.img });
      div.querySelector(".name").append(item.name);
      if ( item.isOwner ) {
        const control = document.createElement("a");
        control.ariaLabel = game.i18n.localize("DND5E.ENCHANTMENT.Action.Remove");
        control.dataset.action = "removeEnchantment";
        control.dataset.tooltip = "DND5E.ENCHANTMENT.Action.Remove";
        control.innerHTML = '<i class="fa-solid fa-rotate-left" inert></i>';
        div.append(control);
      }
      return div;
    });
    if ( enchantedItems.length ) this.dropArea.replaceChildren(...enchantedItems);
    else this.dropArea.innerHTML = `<p>${game.i18n.localize("DND5E.ENCHANT.DropArea")}</p>`;
    if ( this.countArea ) this.countArea.querySelector(".current").innerText = enchantedItems.length;
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /**
   * Handle dropping an item onto the control.
   * @param {Event} event  Triggering drop event.
   */
  async _onDrop(event) {
    event.preventDefault();
    const data = foundry.applications.ux.TextEditor.implementation.getDragEventData(event);
    const droppedItem = await Item.implementation.fromDropData(data);
    if ( !droppedItem ) return;

    // If concentration is required, ensure it is still being maintained & GM is present
    const concentrationId = this.chatMessage.getFlag("dnd5e", "use.concentrationId");
    const concentration = this.enchantmentActivity.actor.effects.get(concentrationId);
    if ( concentrationId && !concentration ) {
      ui.notifications.error("DND5E.ENCHANT.Warning.ConcentrationEnded", { console: false, localize: true });
      return;
    }

    this.enchantmentActivity.applyEnchantment(
      this.chatMessage.getFlag("dnd5e", "use.enchantmentProfile"),
      droppedItem,
      { chatMessage: this.chatMessage, concentration }
    );
  }

  /* -------------------------------------------- */

  /**
   * Handle removing an enchantment.
   * @param {Event} event  Triggering drop event.
   */
  async _onRemoveEnchantment(event) {
    if ( event.target.dataset.action !== "removeEnchantment" ) return;
    const enchantmentUuid = event.target.closest("[data-enchantment-uuid]")?.dataset.enchantmentUuid;
    const enchantment = await fromUuid(enchantmentUuid);
    enchantment?.delete({ chatMessageOrigin: this.chatMessage.id });
  }
}

const MaybeAdoptable$1 = foundry.applications.elements.AdoptableHTMLElement ?? HTMLElement;

/**
 * Custom element that adds a filigree border that can be colored.
 */
class FiligreeBoxElement extends AdoptedStyleSheetMixin(MaybeAdoptable$1) {
  constructor() {
    super();
    this.#shadowRoot = this.attachShadow({ mode: "closed" });
    this._adoptStyleSheet(this._getStyleSheet());
    const backdrop = document.createElement("div");
    backdrop.classList.add("backdrop");
    this.#shadowRoot.appendChild(backdrop);
    this.#buildSVG("corner", "top", "left");
    this.#buildSVG("corner", "top", "right");
    this.#buildSVG("corner", "bottom", "left");
    this.#buildSVG("corner", "bottom", "right");
    this.#buildSVG("block", "top");
    this.#buildSVG("block", "bottom");
    this.#buildSVG("inline", "left");
    this.#buildSVG("inline", "right");
    const slot = document.createElement("slot");
    this.#shadowRoot.appendChild(slot);
  }

  /* -------------------------------------------- */

  /**
   * The HTML tag named used by this element.
   * @type {string}
   */
  static tagName = "filigree-box";

  /* -------------------------------------------- */

  /** @inheritDoc */
  static CSS = `
    :host {
      position: relative;
      isolation: isolate;
      min-height: 56px;
      filter: var(--filigree-drop-shadow, drop-shadow(0 0 12px var(--dnd5e-shadow-15)));
    }
    .backdrop {
      --chamfer: 12px;
      position: absolute;
      inset: 0;
      background: var(--filigree-background-color, var(--dnd5e-color-card));
      z-index: -2;
      clip-path: polygon(
        var(--chamfer) 0,
        calc(100% - var(--chamfer)) 0,
        100% var(--chamfer),
        100% calc(100% - var(--chamfer)),
        calc(100% - var(--chamfer)) 100%,
        var(--chamfer) 100%,
        0 calc(100% - var(--chamfer)),
        0 var(--chamfer)
      );
    }
    .filigree {
      position: absolute;
      fill: var(--filigree-border-color, var(--dnd5e-color-gold));
      z-index: -1;

      &.top, &.bottom { height: 30px; }
      &.top { top: 0; }
      &.bottom { bottom: 0; scale: 1 -1; }

      &.left, &.right { width: 25px; }
      &.left { left: 0; }
      &.right { right: 0; scale: -1 1; }

      &.bottom.right { scale: -1 -1; }
    }
    .filigree.block {
      inline-size: calc(100% - 50px);
      inset-inline: 25px;
    }
    .filigree.inline {
      block-size: calc(100% - 60px);
      inset-block: 30px;
    }
  `;

  /* -------------------------------------------- */

  /**
   * Path definitions for the various box corners and edges.
   * @type {object}
   */
  static svgPaths = Object.freeze({
    corner: "M 3 21.7 C 5.383 14.227 9.646 7.066 18.1 3.2 L 12.2 3.2 L 3 12.8 Z M 6.9 15.7 C 5.088 19.235 3.776 23.004 3 26.9 L 2.999 30 L 0 30 L 0 11.5 L 11 0 L 25 0 L 25 3.1 L 22.4 3.1 C 16.737 4.586 11.822 8.112 8.6 13 L 8.6 30 L 6.9 30 Z",
    block: "M 0 0 L 10 0 L 10 3.1 L 0 3.1 L 0 0 Z",
    inline: "M 0 10 L 0 0 L 2.99 0 L 2.989 10 L 0 10 Z M 6.9 10 L 6.9 0 L 8.6 0 L 8.6 10 L 6.9 10 Z"
  });

  /* -------------------------------------------- */

  /**
   * Shadow root that contains the box shapes.
   * @type {ShadowRoot}
   */
  #shadowRoot;

  /* -------------------------------------------- */

  /** @inheritDoc */
  _adoptStyleSheet(sheet) {
    this.#shadowRoot.adoptedStyleSheets = [sheet];
  }

  /* -------------------------------------------- */

  /**
   * Build an SVG element.
   * @param {string} path          SVG path to use.
   * @param {...string} positions  Additional position CSS classes to add.
   */
  #buildSVG(path, ...positions) {
    const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
    svg.classList.add("filigree", path, ...positions);
    svg.innerHTML = `<path d="${FiligreeBoxElement.svgPaths[path]}" />`;
    svg.setAttribute("viewBox", `0 0 ${path === "block" ? 10 : 25} ${path === "inline" ? 10 : 30}`);
    svg.setAttribute("preserveAspectRatio", "none");
    this.#shadowRoot.appendChild(svg);
  }
}

/**
 * Input element that represents a three-state filter (include, exclude, or ignore). This is used for filters in
 * the compendium browser and in the inventory element. Returns a number with `1` indicating this filter should be
 * positively applied (show items that match the filter), `-1` indicating it should be negatively applied (hide
 * items that match the filter), and `0` indicating this filter should be ignored.
 */
class FilterStateElement extends foundry.applications.elements.AbstractFormInputElement {
  constructor(...args) {
    super(...args);
    this._value = this.getAttribute("value") ?? 0;
  }

  /* -------------------------------------------- */

  /** @override */
  static tagName = "filter-state";

  /* -------------------------------------------- */

  /**
   * Controller for removing listeners automatically.
   * @type {AbortController}
   */
  _controller;

  /* -------------------------------------------- */

  /**
   * Internal indicator used to render the input.
   * @type {HTMLElement}
   */
  #indicator;

  /* -------------------------------------------- */

  /**
   * Get any labels describing this element.
   * @type {HTMLElement[]}
   */
  get labels() {
    const labels = this.id ? Array.from(document.querySelectorAll(`[for="${this.id}"]`)) : [];
    const containing = this.closest("label:not([for])");
    if ( containing && !labels.includes(containing) ) labels.push(containing);
    return labels;
  }

  /* -------------------------------------------- */
  /*  Element Properties                          */
  /* -------------------------------------------- */

  /** @override */
  _getValue() {
    return Number(this._value);
  }

  /* -------------------------------------------- */
  /*  Element Lifecycle                           */
  /* -------------------------------------------- */

  /** @override */
  connectedCallback() {
    this._controller = new AbortController();
    const elements = this._buildElements();
    this.replaceChildren(...elements);
    this._refresh();
    this._toggleDisabled(!this.editable);
    this._activateListeners();
    if ( !this.hasAttribute("tabindex") ) this.tabIndex = 0;
  }

  /* -------------------------------------------- */

  /** @override */
  disconnectedCallback() {
    this._controller.abort();
  }

  /* -------------------------------------------- */

  /** @override */
  _buildElements() {
    this.#indicator = document.createElement("div");
    this.#indicator.classList.add("indicator");
    return [this.#indicator];
  }

  /* -------------------------------------------- */

  /** @override */
  _refresh() {
    this.#indicator.dataset.value = this.value ?? 0;
  }

  /* -------------------------------------------- */

  /** @override */
  _activateListeners() {
    const { signal } = this._controller;
    this.addEventListener("click", this._onClick.bind(this), { signal });
    this.addEventListener("contextmenu", this._onClick.bind(this), { signal });
    this.addEventListener("keydown", event => event.key === " " ? this.#handleValueChange() : null, { signal });
    this.labels.forEach(label => label.addEventListener("contextmenu", this._onClick.bind(this), { signal }));
  }

  /* -------------------------------------------- */

  /** @override */
  _onClick(event) {
    this.#handleValueChange(event.button === 2);
  }

  /* -------------------------------------------- */

  /**
   * Handle changing the value based on a click or keyboard trigger.
   * @param {boolean} [backwards=false]  Should the value be decreased rather than increased?
   */
  #handleValueChange(backwards=false) {
    if ( this.disabled ) return;
    let newValue = (this.value ?? 0) + (backwards ? -1 : 1);
    if ( newValue > 1 ) newValue = -1;
    else if ( newValue < -1 ) newValue = 1;
    this.value = newValue;
  }
}

const MaybeAdoptable = foundry.applications.elements.AdoptableHTMLElement ?? HTMLElement;

/**
 * Custom element for displaying SVG icons that are cached and can be styled.
 */
class IconElement extends AdoptedStyleSheetMixin(MaybeAdoptable) {
  constructor() {
    super();
    this.#internals = this.attachInternals();
    this.#internals.role = "img";
    this.#shadowRoot = this.attachShadow({ mode: "closed" });
  }

  /* -------------------------------------------- */

  /**
   * The HTML tag named used by this element.
   * @type {string}
   */
  static tagName = "dnd5e-icon";

  /* -------------------------------------------- */

  /** @inheritDoc */
  static CSS = `
    :host {
      display: contents;
    }
    svg {
      fill: var(--icon-fill, #000);
      width: var(--icon-width, var(--icon-size, 1em));
      height: var(--icon-height, var(--icon-size, 1em));
    }
  `;

  /* -------------------------------------------- */

  /**
   * Cached SVG files by SRC.
   * @type {Map<string, SVGElement|Promise<SVGElement>>}
   */
  static #svgCache = new Map();

  /* -------------------------------------------- */

  /**
   * The custom element's form and accessibility internals.
   * @type {ElementInternals}
   */
  #internals;

  /* -------------------------------------------- */

  /**
   * Shadow root that contains the icon.
   * @type {ShadowRoot}
   */
  #shadowRoot;

  /* -------------------------------------------- */

  /**
   * Path to the SVG source file.
   * @type {string}
   */
  get src() {
    return this.getAttribute("src");
  }

  set src(src) {
    this.setAttribute("src", src);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _adoptStyleSheet(sheet) {
    this.#shadowRoot.adoptedStyleSheets = [sheet];
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  connectedCallback() {
    this._adoptStyleSheet(this._getStyleSheet());
    const insertElement = element => {
      if ( !element ) return;
      const clone = element.cloneNode(true);
      this.#shadowRoot.replaceChildren(clone);
    };

    // Insert element immediately if already available, otherwise wait for fetch
    const element = this.constructor.fetch(this.src);
    if ( element instanceof Promise ) element.then(insertElement);
    else insertElement(element);
  }

  /* -------------------------------------------- */

  /**
   * Fetch an SVG element from a source.
   * @param {string} src                        Path of the SVG file to retrieve.
   * @returns {SVGElement|Promise<SVGElement>}  Promise if the element is not cached, otherwise the element directly.
   */
  static fetch(src) {
    if ( !this.#svgCache.has(src) ) this.#svgCache.set(src, fetch(src)
      .then(b => b.text())
      .then(t => {
        const temp = document.createElement("div");
        temp.innerHTML = t;
        const svg = temp.querySelector("svg");
        if ( svg ) this.#cleanSVG(svg);
        this.#svgCache.set(src, svg);
        return svg;
      }));
    return this.#svgCache.get(src);
  }

  /* -------------------------------------------- */

  /**
   * Clean non-image content from the SVG.
   * @param {SVGElement} svg  The SVG.
   */
  static #cleanSVG(svg) {
    svg.querySelectorAll("script, foreignObject").forEach(el => el.remove());
    svg.querySelectorAll("*").forEach(el => Array.from(el.attributes).forEach(attr => {
      if ( attr.name.startsWith("on") ) el.removeAttribute(attr.name);
      if ( attr.value && attr.value.trim().toLowerCase().startsWith("javascript:") ) el.removeAttribute(attr.name);
    }));
  }
}

/**
 * @import { ActivityUsageResults } from "../../documents/activity/_types.mjs";
 * @import { InventoryColumnDescriptor, InventorySectionDescriptor } from "./_types.mjs";
 */

/**
 * A custom element that handles displaying a collection of items.
 */
class InventoryElement extends (foundry.applications.elements.AdoptableHTMLElement ?? HTMLElement) {
  /* -------------------------------------------- */
  /*  Configuration                               */
  /* -------------------------------------------- */

  /**
   * Well-known inventory columns.
   * @type {Record<string, InventoryColumnDescriptor>}
   */
  static COLUMNS = {
    capacity: {
      id: "capacity",
      width: 200,
      order: 800,
      priority: 1000,
      template: "systems/dnd5e/templates/inventory/columns/capacity.hbs"
    },
    charges: {
      id: "charges",
      width: 70,
      order: 800,
      priority: 600,
      label: "DND5E.Charges",
      template: "systems/dnd5e/templates/inventory/columns/uses.hbs"
    },
    controls: {
      id: "controls",
      width: 70,
      order: 1000,
      priority: 1000,
      template: "systems/dnd5e/templates/inventory/columns/controls.hbs"
    },
    formula: {
      id: "formula",
      width: 80,
      order: 700,
      priority: 700,
      label: "DND5E.SpellHeader.Formula",
      template: "systems/dnd5e/templates/inventory/columns/formula.hbs"
    },
    price: {
      id: "price",
      width: 80,
      order: 300,
      priority: 300,
      label: "DND5E.Price",
      template: "systems/dnd5e/templates/inventory/columns/price.hbs"
    },
    range: {
      id: "range",
      width: 50,
      order: 300,
      priority: 800,
      label: "DND5E.SpellHeader.Range",
      template: "systems/dnd5e/templates/inventory/columns/range.hbs"
    },
    recovery: {
      id: "recovery",
      width: 60,
      order: 400,
      priority: 500,
      label: "DND5E.Recovery",
      template: "systems/dnd5e/templates/inventory/columns/recovery.hbs"
    },
    roll: {
      id: "roll",
      width: 40,
      order: 600,
      priority: 800,
      label: "DND5E.SpellHeader.Roll",
      template: "systems/dnd5e/templates/inventory/columns/roll.hbs"
    },
    school: {
      id: "school",
      width: 40,
      order: 100,
      priority: 100,
      label: "DND5E.SpellHeader.School",
      template: "systems/dnd5e/templates/inventory/columns/school.hbs"
    },
    target: {
      id: "target",
      width: 80,
      order: 400,
      priority: 800,
      label: "DND5E.SpellHeader.Target",
      template: "systems/dnd5e/templates/inventory/columns/target.hbs"
    },
    time: {
      id: "time",
      width: 40,
      order: 200,
      priority: 800,
      label: "DND5E.SpellHeader.Time",
      template: "systems/dnd5e/templates/inventory/columns/time.hbs"
    },
    quantity: {
      id: "quantity",
      width: 70,
      order: 500,
      priority: 500,
      label: "DND5E.Quantity",
      template: "systems/dnd5e/templates/inventory/columns/quantity.hbs"
    },
    uses: {
      id: "uses",
      width: 70,
      order: 500,
      priority: 600,
      label: "DND5E.Uses",
      template: "systems/dnd5e/templates/inventory/columns/uses.hbs"
    },
    weight: {
      id: "weight",
      width: 60,
      order: 400,
      priority: 400,
      label: "DND5E.Weight",
      template: "systems/dnd5e/templates/inventory/columns/weight.hbs"
    }
  };

  /* -------------------------------------------- */

  /**
   * Well-known inventory sections.
   * @type {Record<string, InventorySectionDescriptor>}
   */
  static SECTIONS = {
    contents: {
      id: "contents",
      order: 100,
      groups: { contents: "contents" },
      label: "DND5E.Contents",
      columns: ["price", "weight", "quantity", "charges", "controls"]
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The HTML tag named used by this element.
   * @type {string}
   */
  static tagName = "dnd5e-inventory";

  /* -------------------------------------------- */

  /**
   * The actor that manages these items.
   * @returns {Actor5e|null}
   */
  get actor() {
    if ( this.document instanceof Actor ) return this.document;
    return this.document.actor ?? null;
  }

  /* -------------------------------------------- */

  /**
   * Reference to the Application that contains this component.
   * @type {ApplicationV2}
   */
  get app() {
    return this.#app;
  }

  #app;

  /* -------------------------------------------- */

  /**
   * Can items be used from this inventory list.
   * @type {boolean}
   */
  get canUse() {
    return this.actor && this.actor.isOwner && !this.actor.pack;
  }

  /* -------------------------------------------- */

  /**
   * The document that holds these items.
   * @returns {Actor5e|Item5e}
   */
  get document() {
    return this.app.inventorySource ?? this.app.document;
  }

  /* -------------------------------------------- */

  /**
   * Cached section data to avoid expensive lookups during resize events.
   * @type {{
   *  columns: Partial<InventoryColumnDescriptor>[],
   *  elements: Record<string, HTMLElement[]>,
   *  element: HTMLElement
   * }[]}
   */
  #sections;

  /* -------------------------------------------- */

  /**
   * Retrieve the templates needed to render the inventory.
   * @type {string[]}
   */
  static get templates() {
    return Object.values(this.COLUMNS).map(c => c.template);
  }

  /* -------------------------------------------- */
  /*  Lifecycle                                   */
  /* -------------------------------------------- */

  /** @override */
  connectedCallback() {
    if ( this.#app ) return;
    this.#app = foundry.applications.instances.get(this.closest(".application")?.id);

    if ( !this.canUse ) {
      for ( const element of this.querySelectorAll('[data-action="use"]') ) {
        delete element.dataset.action;
        element.closest(".rollable")?.classList.remove("rollable");
      }
    }

    for ( const input of this.querySelectorAll('input[type="number"]') ) {
      input.addEventListener("change", this._onChangeInput.bind(this));
    }

    for ( const input of this.querySelectorAll('input[inputmode="numeric"]') ) {
      input.addEventListener("change", this._onChangeInputDelta.bind(this));
    }

    for ( const button of this.querySelectorAll(".adjustment-button") ) {
      button.addEventListener("click", this._onAdjustInput.bind(this));
    }

    for ( const control of this.querySelectorAll(".item-action[data-action]") ) {
      control.addEventListener("click", event => {
        if ( event.currentTarget.ariaDisabled === "true" ) return;
        void this._onAction(event.currentTarget, event.currentTarget.dataset.action, { event });
      });
    }

    for ( const control of this.querySelectorAll("[data-context-menu]") ) {
      control.addEventListener("click", ContextMenu5e.triggerEvent);
    }

    this.querySelectorAll("input").forEach(e => e.addEventListener("focus", () => e.select()));

    // Bind activity menu to child to work around lack of stopImmediatePropagation in ContextMenu#bind
    new ContextMenu5e(this.querySelector(".items-list"), ".activity-row[data-activity-id]", [], {
      onOpen: this._onOpenContextMenu.bind(this), jQuery: false
    });

    new ContextMenu5e(this, "[data-item-id]", [], { onOpen: this._onOpenContextMenu.bind(this), jQuery: false });

    this._cacheSections();
    const observer = new ResizeObserver(this._onResize.bind(this));
    observer.observe(this);
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Cache section data to avoid expensive lookups during resize events.
   * @internal
   */
  _cacheSections() {
    this.#sections = Array.from(this.querySelectorAll(".items-section")).map(section => {
      const minWidth = Number(section.dataset.columnMinWidth);
      const descriptor = { element: section };
      if ( Number.isFinite(minWidth) ) descriptor.minWidth = minWidth;
      const columns = Array.from(section.querySelectorAll(".items-header [data-column-id]")).reduce((obj, el) => {
        const { columnId: id, columnWidth: width, columnPriority: priority } = el.dataset;
        obj[id] = { id, width: Number(width), priority: Number(priority) };
        return obj;
      }, {});
      // Descending order of priority.
      descriptor.columns = Object.values(columns).sort((a, b) => b.priority - a.priority);
      const elements = section.querySelectorAll(":is(.item-row, .items-header) [data-column-id]");
      descriptor.elements = Array.from(elements).reduce((obj, el) => {
        const { columnId: id } = el.dataset;
        const els = obj[id] ??= [];
        els.push(el);
        return obj;
      }, {});
      return descriptor;
    });
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /**
   * Prepare an array of context menu options which are available for inventory items.
   * @param {Item5e} item          The item.
   * @param {HTMLElement} element  The item's rendered element.
   * @returns {ContextMenuEntry[]}
   * @protected
   */
  _getContextOptions(item, element) {
    const compendiumLocked = game.packs.get(item.pack)?.locked;

    // Standard options.
    const options = [{
      name: "DND5E.ItemView",
      icon: '<i class="fa-solid fa-eye fa-fw"></i>',
      callback: li => this._onAction(li, "view")
    }, {
      name: "DND5E.ContextMenuActionEdit",
      icon: '<i class="fa-solid fa-edit fa-fw"></i>',
      condition: () => item.isOwner && !compendiumLocked,
      callback: li => this._onAction(li, "edit")
    }, {
      name: "DND5E.ContextMenuActionDuplicate",
      icon: '<i class="fa-solid fa-copy fa-fw"></i>',
      condition: () => item.canDuplicate && item.isOwner && !compendiumLocked,
      callback: li => this._onAction(li, "duplicate")
    }, {
      id: "delete",
      name: "DND5E.ContextMenuActionDelete",
      icon: '<i class="fa-solid fa-trash fa-fw"></i>',
      condition: () => item.canDelete && item.isOwner && !compendiumLocked,
      callback: li => this._onAction(li, "delete")
    }, {
      name: "DND5E.DisplayCard",
      icon: '<i class="fa-solid fa-message"></i>',
      callback: () => item.displayCard()
    }];

    if ( !this.actor || this.actor.system.isGroup ) return options;
    const favorited = this.actor.system.hasFavorite?.(item.getRelativeUUID(this.actor));
    const expanded = this.app.expandedSections ? this.app.expandedSections.get(item.id)
      : this.app._expanded.has(item.id); // TODO: Remove when V1 sheets are gone

    // Owned item options.
    options.push({
      name: "DND5E.Scroll.CreateScroll",
      icon: '<i class="fa-solid fa-scroll"></i>',
      condition: () => {
        const isSpell = (item.type === "spell") && !item.getFlag("dnd5e", "cachedFor");
        const canEdit = this.actor.isOwner && !this.actor.collection.locked;
        return isSpell && canEdit;
      },
      callback: async () => {
        const scroll = await Item.implementation.createScrollFromSpell(item);
        if ( scroll ) void Item.implementation.create(scroll, { parent: this.actor });
      },
      group: "action"
    }, {
      name: "DND5E.ConcentrationBreak",
      icon: '<dnd5e-icon src="systems/dnd5e/icons/svg/break-concentration.svg"></dnd5e-icon>',
      condition: () => this.actor?.concentration?.items.has(item),
      callback: () => this.actor?.endConcentration(item),
      group: "state"
    }, {
      name: `DND5E.ContextMenuAction${item.system.attuned ? "Unattune" : "Attune"}`,
      icon: '<i class="fa-solid fa-sun fa-fw"></i>',
      condition: () => item.system.attunement && item.isOwner && !compendiumLocked,
      callback: li => this._onAction(li, "attune"),
      group: "state"
    }, {
      name: `DND5E.ContextMenuAction${item.system.equipped ? "Unequip" : "Equip"}`,
      icon: '<i class="fa-solid fa-shield-alt fa-fw"></i>',
      condition: () => ("equipped" in item.system) && item.isOwner && !compendiumLocked,
      callback: li => this._onAction(li, "equip"),
      group: "state"
    }, {
      name: `DND5E.ContextMenuAction${item.isOnCooldown ? "Charge" : "ExpendCharge"}`,
      icon: '<i class="fa-solid fa-bolt"></i>',
      condition: () => item.hasRecharge && item.isOwner && !compendiumLocked,
      callback: li => this._onAction(li, "toggleCharge"),
      group: "state"
    }, {
      name: `DND5E.ContextMenuAction${item.system.prepared ? "Unprepare" : "Prepare"}`,
      icon: '<i class="fa-solid fa-sun fa-fw"></i>',
      condition: () => {
        const isPrepared = CONFIG.DND5E.spellcasting[item.system.method]?.prepares;
        const isAlways = item.system.prepared === CONFIG.DND5E.spellPreparationStates.always.value;
        const canEdit = item.isOwner && !compendiumLocked;
        return !item.hasRecharge && isPrepared && !isAlways && canEdit && !item.getFlag("dnd5e", "cachedFor");
      },
      callback: li => this._onAction(li, "prepare"),
      group: "state"
    }, {
      name: "DND5E.Identify",
      icon: '<i class="fa-solid fa-magnifying-glass"></i>',
      condition: () => {
        const canIdentify = ("identified" in item.system) && !item.system.identified;
        const canEdit = item.isOwner && !compendiumLocked;
        return canIdentify && canEdit;
      },
      callback: li => this._onAction(li, "identify"),
      group: "state"
    }, {
      name: favorited ? "DND5E.FavoriteRemove" : "DND5E.Favorite",
      icon: '<i class="fa-solid fa-bookmark fa-fw"></i>',
      condition: () => ("favorites" in this.actor.system) && item.isOwner && !compendiumLocked,
      callback: li => this._onAction(li, "toggleFavorite"),
      group: "state"
    }, {
      name: expanded ? "Collapse" : "Expand",
      icon: `<i class="fa-solid fa-${expanded ? "compress" : "expand"}"></i>`,
      condition: () => "canExpand" in this.app ? this.app.canExpand(item) : true,
      callback: li => this._onAction(li, "toggleExpand"),
      group: "collapsible"
    });

    return options;
  }

  /* -------------------------------------------- */

  /**
   * Handle item actions.
   * @param {HTMLElement} target            The action target.
   * @param {string} action                 The action to invoke.
   * @param {object} [options]
   * @param {PointerEvent} [options.event]  The triggering event.
   * @returns {Promise}
   * @private
   */
  async _onAction(target, action, { event }={}) {
    const inventoryEvent = new CustomEvent("inventory", {
      bubbles: true,
      cancelable: true,
      detail: action
    });
    target.dispatchEvent(inventoryEvent);
    if ( inventoryEvent.defaultPrevented ) return;

    if ( action === "currency" ) return this._onManageCurrency();
    if ( action === "create" ) return this._onCreateItem(event); // TODO: Remove once legacy sheets are removed.

    const { itemId } = target.closest("[data-item-id]")?.dataset ?? {};
    const { activityId } = target.closest("[data-activity-id]")?.dataset ?? {};
    const item = await this.getItem(itemId);
    if ( !item || (target.ariaDisabled === "true") ) return;
    const activity = item.system.activities?.get(activityId);

    switch ( action ) {
      case "activity-use": return this._onUseActivity(activity, { event });
      case "attune": return this._onToggleAttunement(item);
      case "delete": return this._onDeleteItem(item);
      case "duplicate": return this._onDuplicateItem(item);
      case "edit": return this._onEditItem(item);
      case "equip": return this._onToggleEquipped(item);
      case "identify": return this._onToggleIdentify(item);
      case "prepare": return this._onTogglePrepared(item);
      case "recharge": return this._onRollRecharge(activity ?? item, { event });
      case "toggleCharge": return this._onToggleCharge(item);
      case "toggleExpand": return this._onToggleExpand(target, { item });
      case "toggleFavorite": return this._onToggleFavorite(item);
      case "use": return this._onUseItem(item, { event });
      case "view": return this._onViewItem(item);
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle incrementing or decrementing a numeric input.
   * @param {PointerEvent} event  The triggering event.
   * @protected
   */
  _onAdjustInput(event) {
    const button = event.currentTarget;
    const { action } = button.dataset;
    const input = button.parentElement.querySelector("input");
    const min = input.min ? Number(input.min) : -Infinity;
    const max = input.max ? Number(input.max) : Infinity;
    let value = Number(input.value);
    if ( isNaN(value) ) return;
    value += action === "increase" ? 1 : -1;
    input.value = Math.clamp(value, min, max);
    input._debouncedChange ??= foundry.utils.debounce(() => input.dispatchEvent(new Event("change")), 250);
    input._debouncedChange();
  }

  /* -------------------------------------------- */

  /**
   * Handle changing the quantity or charges fields.
   * @param {Event} event  Triggering change event.
   * @returns {Promise}
   * @protected
   */
  async _onChangeInput(event) {
    const itemId = event.target.closest("[data-item-id]")?.dataset.itemId;
    if ( !itemId ) return;

    event.stopImmediatePropagation();
    const item = await this.getItem(itemId);
    const min = event.target.min !== "" ? Number(event.target.min) : -Infinity;
    const max = event.target.max !== "" ? Number(event.target.max) : Infinity;
    const value = Math.clamp(event.target.valueAsNumber, min, max);
    if ( !item || Number.isNaN(value) ) return;

    event.target.value = value;
    item.update({ [event.target.dataset.name]: value });
  }

  /* -------------------------------------------- */

  /**
   * Handle input changes to numeric form fields, allowing them to accept delta-typed inputs.
   * @param {Event} event  Triggering event.
   * @protected
   */
  async _onChangeInputDelta(event) {
    // If this is already handled by the parent sheet, skip.
    if ( this.#app?._onChangeInputDelta ) return;
    const input = event.target;
    const { itemId } = input.closest("[data-item-id]")?.dataset ?? {};
    const { activityId } = input.closest("[data-activity-id]")?.dataset ?? {};
    const item = await this.getItem(itemId);
    if ( !item ) return;
    const activity = item.system.activities?.get(activityId);
    const result = parseInputDelta(input, activity ?? item);
    if ( (result !== undefined) && input.dataset.name ) {
      event.stopPropagation();
      // Special case handling for Item uses.
      if ( input.dataset.name === "system.uses.value" ) {
        item.update({ "system.uses.spent": item.system.uses.max - result });
      } else if ( activity && (input.dataset.name === "uses.value") ) {
        item.updateActivity(activityId, { "uses.spent": activity.uses.max - result });
      }
      else item.update({ [input.dataset.name]: result });
    }
  }

  /* -------------------------------------------- */

  /**
   * Create an item on a legacy sheet.
   * TODO: Remove once legacy sheets are removed.
   * @param {Event} event
   * @returns {Promise<Item5e>|void}
   * @protected
   */
  _onCreateItem(event) {
    const { type } = event?.target?.dataset ?? {};
    if ( !type || !this.actor ) return;
    return foundry.documents.Item.implementation.create({
      type, name: game.i18n.format("DOCUMENT.New", { type: game.i18n.localize(CONFIG.Item.typeLabels[type]) })
    }, { parent: this.actor });
  }

  /* -------------------------------------------- */

  /**
   * Handle deleting an item.
   * @param {Item5e} item  The item.
   * @returns {Promise}
   * @protected
   */
  _onDeleteItem(item) {
    return item.deleteDialog();
  }

  /* -------------------------------------------- */

  /**
   * Handle duplicating an item.
   * @param {Item5e} item        The item.
   * @returns {Promise<Item5e>}  The duplicated item.
   * @protected
   */
  _onDuplicateItem(item) {
    return item.clone({
      name: game.i18n.format("DOCUMENT.CopyOf", { name: item.name })
    }, { save: true, addSource: true });
  }

  /* -------------------------------------------- */

  /**
   * Handle editing an item.
   * @param {Item5e} item  The item.
   * @returns {Promise<ItemSheet5e2>}
   * @protected
   */
  _onEditItem(item) {
    return item.sheet.render(true, { mode: ItemSheet5e.MODES.EDIT });
  }

  /* -------------------------------------------- */

  /**
   * Handle spawning the currency management dialog.
   * @returns {Promise<CurrencyManager>}
   * @protected
   */
  _onManageCurrency() {
    return new CurrencyManager({ document: this.document }).render({ force: true });
  }

  /* -------------------------------------------- */

  /**
   * Handle opening the context menu.
   * @param {HTMLElement} element  The element the context menu was triggered for.
   * @protected
   */
  _onOpenContextMenu(element) {
    const { itemId } = element.closest("[data-item-id]")?.dataset ?? {};
    const item = this.getItem(itemId);
    if ( !item || (item instanceof Promise) ) return;
    if ( element.closest("[data-activity-id]") ) UtilityActivity.onContextMenu(item, element);
    else {
      ui.context.menuItems = this._getContextOptions(item, element);
      Hooks.callAll("dnd5e.getItemContextOptions", item, ui.context.menuItems);
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle recharging an item.
   * @param {Item5e|Activity} entry         The entity being recharged.
   * @param {object} [options]
   * @param {PointerEvent} [options.event]  The triggering event.
   * @returns {Promise<Roll|void>}
   * @protected
   */
  _onRollRecharge(entry, { event }={}) {
    if ( entry instanceof Item5e ) return entry.system.uses?.rollRecharge({ apply: true, event });
    return entry.uses?.rollRecharge({ apply: true, event });
  }

  /* -------------------------------------------- */

  /**
   * Manage columns when the inventory element's inline size changes.
   * @param {ResizeObserverEntry[]} entries
   * @protected
   */
  _onResize([entry]) {
    // TODO: Should accommodate uiScale here, but probably don't want to call game.settings.get every frame.
    for ( const { columns, elements, minWidth=200 } of this.#sections ) {
      let available = entry.borderBoxSize[0].inlineSize;
      for ( const { id, width } of columns ) {
        available -= width;
        if ( !(id in elements) ) continue;
        for ( const el of elements[id] ) el.classList.toggle("hidden-width", available < minWidth);
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling an item's attunement status.
   * @param {Item5e} item  The item.
   * @returns {Promise<Item5e>}
   * @protected
   */
  _onToggleAttunement(item) {
    return item.update({ "system.attuned": !item.system.attuned });
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling an item's charged state.
   * @param {Item5e} item  The item.
   * @returns {Promise<Item5e>}
   * @protected
   */
  _onToggleCharge(item) {
    return item.update({ "system.uses.spent": 1 - item.system.uses.spent });
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling an item's equipped state.
   * @param {Item5e} item  The item.
   * @returns {Promise<Item5e>}
   * @protected
   */
  _onToggleEquipped(item) {
    return item.update({ "system.equipped": !item.system.equipped });
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling an item's identified state.
   * @param {Item5e} item  The item.
   * @returns {Promise<Item5e>}
   * @protected
   */
  _onToggleIdentify(item) {
    return item.update({ "system.identified": !item.system.identified });
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling an item's in-line description.
   * @param {HTMLElement} target     The action target.
   * @param {object} [options]
   * @param {Item5e} [options.item]  The item instance, otherwise it will be inferred from the target.
   * @protected
   */
  async _onToggleExpand(target, { item }={}) {
    // TODO: Remove when V1 sheets are gone
    if ( !this.app.expandedSections ) {
      const li = target.closest("[data-item-id]");
      if ( this.app._expanded.has(item.id) ) {
        const summary = $(li.querySelector(".item-summary"));
        summary.slideUp(200, () => summary.remove());
        this.app._expanded.delete(item.id);
      } else {
        const chatData = await item.getChatData({secrets: item.isOwner});
        const summary = $(await foundry.applications.handlebars.renderTemplate(
          "systems/dnd5e/templates/items/parts/item-summary.hbs", chatData
        ));
        $(li).append(summary.hide());
        summary.slideDown(200);
        this.app._expanded.add(item.id);
      }
      return;
    }

    const icon = target.querySelector(":scope > i");
    const row = target.closest("[data-uuid]");
    const summary = row.querySelector(":scope > .item-description > .wrapper");
    const { uuid } = row.dataset;
    item ??= await fromUuid(uuid);
    if ( !item ) return;

    const expanded = this.app.expandedSections.get(item.id);
    if ( expanded ) {
      summary.parentElement.addEventListener("transitionend", () => {
        if ( row.classList.contains("collapsed") ) summary.querySelector(".item-summary")?.remove();
      }, { once: true });
      this.app.expandedSections.set(item.id, false);
    } else {
      const context = await item.getChatData({ secrets: item.isOwner });
      const template = "systems/dnd5e/templates/items/parts/item-summary.hbs";
      const content = await foundry.applications.handlebars.renderTemplate(template, context);
      summary.querySelectorAll(".item-summary").forEach(el => el.remove());
      summary.insertAdjacentHTML("beforeend", content);
      await new Promise(resolve => requestAnimationFrame(resolve));
      this.app.expandedSections.set(item.id, true);
    }

    row.classList.toggle("collapsed", expanded);
    icon.classList.toggle("fa-compress", !expanded);
    icon.classList.toggle("fa-expand", expanded);
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling an item's favorited status.
   * @param {Item5e} item  The item.
   * @returns {Promise<Actor5e|void>}
   * @protected
   */
  async _onToggleFavorite(item) {
    if ( !this.actor ) return;
    const uuid = item.getRelativeUUID(this.actor);
    if ( this.actor.system.hasFavorite(uuid) ) return this.actor.system.removeFavorite(uuid);
    return this.actor.system.addFavorite({ type: "item", id: uuid });
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling a spell's prepared state.
   * @param {Item5e} item  The spell.
   * @returns {Promise<Item5e>}
   * @protected
   */
  _onTogglePrepared(item) {
    return item.update({ "system.prepared": Number(!item.system.prepared) });
  }

  /* -------------------------------------------- */

  /**
   * Handle using an activity.
   * @param {Activity} activity             The activity.
   * @param {object} [options]
   * @param {PointerEvent} [options.event]  The triggering event.
   * @returns {Promise<ActivityUsageResults|void>}
   * @protected
   */
  _onUseActivity(activity, { event }={}) {
    return activity?.use({ event });
  }

  /* -------------------------------------------- */

  /**
   * Handle activating an item.
   * @param {Item5e} item                   The item.
   * @param {object} [options]
   * @param {PointerEvent} [options.event]  The triggering event.
   * @returns {Promise<ActivityUsageResults|ChatMessage|object|void>}
   * @protected
   */
  _onUseItem(item, { event }={}) {
    return item.use({ event });
  }

  /* -------------------------------------------- */

  /**
   * Handle viewing an item.
   * @param {Item5e} item  The item.
   * @returns {Promise<ItemSheet5e2>}
   * @protected
   */
  _onViewItem(item) {
    return item.sheet.render(true, { mode: ItemSheet5e.MODES.PLAY });
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Retrieve an item by its ID.
   * @param {string} id  The Item ID.
   * @returns {Item5e|Promise<Item5e>}
   */
  getItem(id) {
    if ( (this.document instanceof Item) && (this.document.type === "container") ) {
      return this.document.system.getContainedItem(id);
    }
    return this.actor?.items.get(id);
  }

  /* -------------------------------------------- */

  /**
   * Map column descriptors to their renderable form.
   * @param {(string|InventoryColumnDescriptor)[]} cols
   * @returns {InventoryColumnDescriptor[]}
   */
  static mapColumns(cols) {
    return cols.flatMap(c => {
      if ( typeof c === "string" ) return foundry.utils.deepClone(this.COLUMNS[c]) ?? [];
      return foundry.utils.mergeObject(this.COLUMNS[c.id] ?? {}, c, { inplace: false });
    });
  }

  /* -------------------------------------------- */

  /**
   * Prepare section descriptors for rendering.
   * @param {Partial<InventorySectionDescriptor>[]} sections
   * @returns {InventorySectionDescriptor[]}
   */
  static prepareSections(sections) {
    for ( const section of sections ) {
      section.items ??= [];
      section.order ??= 0;
      section.dataset ??= {};
      Object.assign(section.dataset, Object.fromEntries(Object.entries(section.groups ?? {}).map(([k, v]) => {
        return [`group-${k}`, v];
      })));
      section.columns = this.mapColumns(section.columns).sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
      if ( Number.isFinite(section.minWidth) ) section.dataset.columnMinWidth = `${section.minWidth}`;
    }
    return sections.sort((a, b) => a.order - b.order);
  }

  /* -------------------------------------------- */

  /**
   * Return the union of all columns that need to be rendered in order to satisfy every rendered section.
   * @param {Partial<InventorySectionDescriptor>[]} sections
   * @returns {(string|InventoryColumnDescriptor)[]}
   */
  static unionColumns(sections) {
    const ids = new Set();
    const cols = [];
    sections.flatMap(s => s.columns).forEach(col => {
      const id = typeof col === "string" ? col : col.id;
      if ( !ids.has(id) ) {
        ids.add(id);
        cols.push(col);
      }
    });
    return cols;
  }
}

/**
 * A custom HTML element that displays proficiency status and allows cycling through values.
 * @fires change
 */
class ProficiencyCycleElement extends AdoptedStyleSheetMixin(
  foundry.applications.elements.AbstractFormInputElement
) {
  /** @inheritDoc */
  constructor() {
    super();
    this._internals.role = "spinbutton";
    this.#shadowRoot = this.attachShadow({ mode: "open" });
    this._adoptStyleSheet(this._getStyleSheet());
    this._value = Number(this.getAttribute("value") ?? 0);
  }

  /* -------------------------------------------- */

  /**
   * The HTML tag named used by this element.
   * @type {string}
   */
  static tagName = "proficiency-cycle";

  /* -------------------------------------------- */

  /** @inheritDoc */
  static CSS = `
    :host { display: inline-block; }
    div { --_fill: var(--proficiency-cycle-enabled-color, var(--dnd5e-color-blue)); }
    div:has(:disabled, :focus-visible) { --_fill: var(--proficiency-cycle-disabled-color, var(--dnd5e-color-gold)); }
    div:not(:has(:disabled)) { cursor: var(--cursor-pointer); }

    div {
      position: relative;
      overflow: clip;
      width: 100%;
      aspect-ratio: 1;

      &::before {
        content: "";
        position: absolute;
        display: block;
        inset: 3px;
        border: 1px solid var(--_fill);
        border-radius: 100%;
      }

      &:has([value="1"])::before { background: var(--_fill); }

      &:has([value="0.5"], [value="2"])::after {
        content: "";
        position: absolute;
        background: var(--_fill);
      }

      &:has([value="0.5"])::after {
        inset: 4px;
        width: 4px;
        aspect-ratio: 1 / 2;
        border-radius: 100% 0 0 100%;
      }

      &:has([value="2"]) {
        &::before {
          inset: 1px;
          border-width: 2px;
        }

        &::after {
          inset: 5px;
          border-radius: 100%;
        }
      }
    }

    input {
      position: absolute;
      inset-block-start: -100px;
      width: 1px;
      height: 1px;
      opacity: 0;
    }
  `;

  /* -------------------------------------------- */

  /**
   * Controller for removing listeners automatically.
   * @type {AbortController}
   */
  #controller;

  /* -------------------------------------------- */

  /**
   * Shadow root of the element.
   * @type {ShadowRoot}
   */
  #shadowRoot;

  /* -------------------------------------------- */

  /** @override */
  _toggleDisabled(value) {
    this.#shadowRoot.querySelector("input")?.toggleAttribute("disabled", value);
  }

  /* -------------------------------------------- */

  /**
   * Type of proficiency represented by this control (e.g. "ability" or "skill").
   * @type {"ability"|"skill"|"tool"}
   */
  get type() { return this.getAttribute("type") ?? "ability"; }

  set type(value) {
    if ( !["ability", "skill", "tool"].includes(value) ) throw new Error("Type must be 'ability', 'skill', or 'tool'.");
    this.setAttribute("type", value);
    this._internals.ariaValueMin = 0;
    this._internals.ariaValueMax = value === "ability" ? 1 : 2;
    this._internals.ariaValueStep = value === "ability" ? 1 : 0.5;
  }

  /* -------------------------------------------- */

  /**
   * Valid values for the current type.
   * @type {number[]}
   */
  get validValues() {
    return this.type === "ability" ? [0, 1] : [0, 1, .5, 2];
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _setValue(value) {
    if ( !this.validValues.includes(value) ) throw new Error("Value must be a valid proficiency multiplier.");
    return super._setValue(value);
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _adoptStyleSheet(sheet) {
    this.#shadowRoot.adoptedStyleSheets = [sheet];
  }

  /* -------------------------------------------- */

  /** @override */
  _buildElements() {
    const div = document.createElement("div");
    this.#shadowRoot.replaceChildren(div);

    const input = document.createElement("input");
    input.setAttribute("type", "number");
    if ( this.disabled ) input.setAttribute("disabled", "");
    div.appendChild(input);

    return [];
  }

  /* -------------------------------------------- */

  /** @override */
  _refresh() {
    const input = this.#shadowRoot.querySelector("input");
    input.setAttribute("value", this._value);
    this._internals.ariaValueNow = this._value;
    this._internals.ariaValueText = CONFIG.DND5E.proficiencyLevels[this._value];
    this._internals.setFormValue(this._value);
    this._primaryInput = this.#shadowRoot.querySelector("input");
  }

  /* -------------------------------------------- */

  /** @override */
  _activateListeners() {
    const { signal } = this.#controller = new AbortController();
    this.addEventListener("click", this.#onClick.bind(this), { signal });
    this.addEventListener("contextmenu", this.#onClick.bind(this), { signal });
    this.#shadowRoot.querySelector("div").addEventListener("contextmenu", e => e.preventDefault(), { signal });
    this.#shadowRoot.querySelector("input").addEventListener("change", this.#onChangeInput.bind(this), { signal });
  }

  /* -------------------------------------------- */

  /** @override */
  disconnectedCallback() {
    this.#controller.abort();
  }

  /* -------------------------------------------- */

  /**
   * Redirect focus requests into the inner input.
   * @param {object} options  Focus options forwarded to inner input.
   */
  focus(options) {
    this.#shadowRoot.querySelector("input")?.focus(options);
  }

  /* -------------------------------------------- */

  /**
   * Change the value by one step, looping around if the limits have been reached.
   * @param {boolean} [up=true]  Should the value step up or down?
   */
  step(up=true) {
    const levels = this.validValues;
    const idx = levels.indexOf(this.value);
    this.value = levels[(idx + (up ? 1 : levels.length - 1)) % levels.length];
    this.dispatchEvent(new Event("change"));
  }

  /* -------------------------------------------- */

  /**
   * Handle changes to the input value directly.
   * @param {Event} event  Triggering change event.
   */
  #onChangeInput(event) {
    this.step(event.target.valueAsNumber > this.value);
  }

  /* -------------------------------------------- */

  /**
   * Handle a click event for modifying the value.
   * @param {PointerEvent} event  Triggering click event.
   */
  #onClick(event) {
    event.preventDefault();
    if ( this.disabled ) return;
    this.step((event.type === "click") && (event.button !== 2));
  }
}

/**
 * A custom HTML element that represents a checkbox-like input that is displayed as a slide toggle.
 * @fires change
 */
class SlideToggleElement extends CheckboxElement {
  /** @inheritDoc */
  constructor() {
    super();
    this._internals.role = "switch";
  }

  /* -------------------------------------------- */

  /** @override */
  static tagName = "slide-toggle";

  /* -------------------------------------------- */

  /** @override */
  static useShadowRoot = false;

  /* -------------------------------------------- */
  /*  Element Lifecycle                           */
  /* -------------------------------------------- */

  /**
   * Activate the element when it is attached to the DOM.
   * @inheritDoc
   */
  connectedCallback() {
    this.replaceChildren(...this._buildElements());
    this._refresh();
    this._activateListeners();
  }

  /* -------------------------------------------- */

  /**
   * Create the constituent components of this element.
   * @returns {HTMLElement[]}
   * @protected
   */
  _buildElements() {
    const track = document.createElement("div");
    track.classList.add("slide-toggle-track");
    const thumb = document.createElement("div");
    thumb.classList.add("slide-toggle-thumb");
    track.append(thumb);
    return [track];
  }
}

window.customElements.define(CopyableTextElement.tagName, CopyableTextElement);
window.customElements.define(DamageApplicationElement.tagName, DamageApplicationElement);
window.customElements.define(ActivitiesElement.tagName, ActivitiesElement);
window.customElements.define(CheckboxElement.tagName, CheckboxElement);
window.customElements.define(EffectsElement.tagName, EffectsElement);
window.customElements.define(IconElement.tagName, IconElement);
window.customElements.define(InventoryElement.tagName, InventoryElement);
window.customElements.define(EffectApplicationElement.tagName, EffectApplicationElement);
window.customElements.define(EnchantmentApplicationElement.tagName, EnchantmentApplicationElement);
window.customElements.define(FiligreeBoxElement.tagName, FiligreeBoxElement);
window.customElements.define(FilterStateElement.tagName, FilterStateElement);
window.customElements.define(ItemListControlsElement.tagName, ItemListControlsElement);
window.customElements.define(ProficiencyCycleElement.tagName, ProficiencyCycleElement);
window.customElements.define(SlideToggleElement.tagName, SlideToggleElement);

var _module$p = /*#__PURE__*/Object.freeze({
  __proto__: null,
  ActivitiesElement: ActivitiesElement,
  AdoptedStyleSheetMixin: AdoptedStyleSheetMixin,
  CheckboxElement: CheckboxElement,
  CopyableTextElement: CopyableTextElement,
  DamageApplicationElement: DamageApplicationElement,
  EffectApplicationElement: EffectApplicationElement,
  EffectsElement: EffectsElement,
  EnchantmentApplicationElement: EnchantmentApplicationElement,
  FiligreeBoxElement: FiligreeBoxElement,
  FilterStateElement: FilterStateElement,
  IconElement: IconElement,
  InventoryElement: InventoryElement,
  ItemListControlsElement: ItemListControlsElement,
  ProficiencyCycleElement: ProficiencyCycleElement,
  SlideToggleElement: SlideToggleElement
});

/**
 * @import {
 *   BasicRollConfigurationDialogOptions, BasicRollMessageConfiguration, DamageRollProcessConfiguration
 * } from "../../dice/_types.mjs";
 */

/**
 * Dialog for configuring damage rolls.
 * @extends {RollConfigurationDialog}
 *
 * @param {DamageRollProcessConfiguration} [config={}]        Initial roll configuration.
 * @param {BasicRollMessageConfiguration} [message={}]        Message configuration.
 * @param {BasicRollConfigurationDialogOptions} [options={}]  Dialog rendering options.
 */
class DamageRollConfigurationDialog extends RollConfigurationDialog {

  /** @inheritDoc */
  static PARTS = {
    ...super.PARTS,
    formulas: {
      template: "systems/dnd5e/templates/dice/damage-formulas.hbs"
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static get rollType() {
    return CONFIG.Dice.DamageRoll;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @override */
  async _prepareButtonsContext(context, options) {
    const allowCritical = this.config.critical?.allow !== false;
    const defaultCritical = allowCritical && (this.options.defaultButton === "critical");
    context.buttons = {
      critical: {
        default: defaultCritical,
        icon: '<i class="fa-solid fa-bomb" inert></i>',
        label: game.i18n.localize("DND5E.CriticalHit")
      },
      normal: {
        default: !defaultCritical,
        icon: '<i class="fa-solid fa-dice" inert></i>',
        label: game.i18n.localize(allowCritical ? "DND5E.Normal" : "DND5E.Roll")
      }
    };
    if ( !allowCritical ) delete context.buttons.critical;
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareFormulasContext(context, options) {
    context = await super._prepareFormulasContext(context, options);
    const allTypes = foundry.utils.mergeObject(CONFIG.DND5E.damageTypes, CONFIG.DND5E.healingTypes, { inplace: false });
    context.rolls = context.rolls.map(({ roll }) => ({
      roll,
      damageConfig: allTypes[roll.options.type] ?? allTypes[roll.options.types?.[0]],
      damageTypes: roll.options.types?.length > 1 ? Object.entries(allTypes).map(([key, config]) => {
        if ( !roll.options.types?.includes(key) ) return null;
        return { value: key, label: config.label };
      }).filter(_ => _) : null
    }));
    return context;
  }

  /* -------------------------------------------- */
  /*  Roll Handling                               */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _buildConfig(config, formData, index) {
    config = super._buildConfig(config, formData, index);
    const damageType = formData?.get(`roll.${index}.damageType`);
    if ( damageType ) config.options.type = damageType;
    return config;
  }

  /* -------------------------------------------- */

  /** @override */
  _finalizeRolls(action) {
    this.config.isCritical = action === "critical";
    return this.rolls.map(roll => {
      roll.options.isCritical = this.config.isCritical;
      roll.configureDamage({ critical: this.config.critical });
      return roll;
    });
  }
}

var _module$o = /*#__PURE__*/Object.freeze({
  __proto__: null,
  AttackRollConfigurationDialog: AttackRollConfigurationDialog,
  D20RollConfigurationDialog: D20RollConfigurationDialog,
  DamageRollConfigurationDialog: DamageRollConfigurationDialog,
  RollConfigurationDialog: RollConfigurationDialog,
  SkillToolRollConfigurationDialog: SkillToolRollConfigurationDialog
});

/**
 * Extended version of item sheet to handle containers.
 */
class ContainerSheet extends ItemSheet5e {
  /** @override */
  static DEFAULT_OPTIONS = {
    elements: {
      inventory: "dnd5e-inventory"
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    ...super.PARTS,
    contents: {
      template: "systems/dnd5e/templates/items/contents.hbs",
      templates: [
        "systems/dnd5e/templates/inventory/inventory.hbs", "systems/dnd5e/templates/inventory/encumbrance.hbs"
      ],
      scrollable: [""]
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static TABS = [
    { tab: "contents", label: "DND5E.ITEM.SECTIONS.Contents" },
    ...super.TABS
  ];

  /* -------------------------------------------- */

  /** @override */
  tabGroups = {
    primary: "contents"
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  _filters = {
    inventory: { name: "", properties: new Set() }
  };

  /* -------------------------------------------- */

  /**
   * The container's cached contents.
   * @type {Item5e[]}
   * @protected
   */
  _items;

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _configureRenderParts(options) {
    const parts = super._configureRenderParts(options);
    if ( "contents" in parts ) {
      parts.contents.templates ??= [];
      parts.contents.templates.push(...customElements.get(this.options.elements.inventory).templates);
    }
    return parts;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    switch ( partId ) {
      case "contents": context = await this._prepareContentsContext(context, options); break;
    }
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare rendering context for the contents tab.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {ApplicationRenderContext}
   * @protected
   */
  async _prepareContentsContext(context, options) {
    context.items = [];
    context.itemContext = {};
    context.isContainer = true;
    context.rollableClass = this.isEditable ? "rollable" : "";
    context.encumbrance = await this.item.system.computeCapacity();
    if ( !Number.isFinite(context.encumbrance.max) ) context.encumbrance.maxLabel = "&infin;";

    // Contents
    const Inventory = customElements.get(this.options.elements.inventory);
    for ( const item of await this.item.system.contents ) {
      const ctx = context.itemContext[item.id] ??= {};
      ctx.totalWeight = (await item.system.totalWeight).toNearest(0.1);
      ctx.isExpanded = this.expandedSections.get(item.id);
      ctx.isStack = item.system.quantity > 1;
      ctx.expanded = this.expandedSections.get(item.id) ? await item.getChatData({ secrets: this.item.isOwner }) : null;
      ctx.groups = { contents: "contents", type: item.type };
      ctx.dataset = { groupContents: "contents", groupType: item.type };
      context.items.push(item);

      if ( item.type === "container" ) {
        ctx.capacity = await item.system.computeCapacity();
        ctx.capacity.maxLabel = Number.isFinite(ctx.capacity.max) ? ctx.capacity.max : "&infin;";
        ctx.columns = Inventory.mapColumns(["capacity", "controls"]);
        ctx.clickAction = "view";
      }
    }

    const inventory = Object.values(CONFIG.Item.dataModels)
      .filter(model => "inventorySection" in model)
      .map(model => {
        const section = model.inventorySection;
        if ( foundry.utils.isSubclass(model, ContainerData) ) return section;
        return { ...section, columns: ["price", "weight", "quantity", "controls"] };
      });
    inventory.push(foundry.utils.deepClone(Inventory.SECTIONS.contents));
    inventory.at(-1).items = context.items;
    inventory.forEach(s => s.minWidth = 190);
    context.inventory = Inventory.prepareSections(inventory);
    context.listControls = foundry.utils.deepClone(ItemListControlsElement.CONFIG.inventory);
    context.currency = context.source.currency;
    context.showCurrency = true;
    this._items = context.items;

    // TODO: Remove this temporary path to `config` when actors have converted to AppV2
    context.config = context.CONFIG;

    return context;
  }

  /* -------------------------------------------- */
  /*  Drag & Drop                                 */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onDragStart(event) {
    const li = event.currentTarget;
    if ( "link" in event.target.dataset ) return;
    if ( !li.dataset.itemId ) return super._onDragStart(event);

    const item = await this.item.system.getContainedItem(li.dataset.itemId);
    const dragData = item?.toDragData();
    if ( !dragData ) return;

    // Set data transfer
    event.dataTransfer.setData("text/plain", JSON.stringify(dragData));
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDrop(event) {
    const data = foundry.applications.ux.TextEditor.implementation.getDragEventData(event);
    if ( !["Item", "Folder"].includes(data.type) ) return super._onDrop(event, data);

    if ( Hooks.call("dnd5e.dropItemSheetData", this.item, this, data) === false ) return;

    if ( data.type === "Folder" ) return this._onDropFolder(event, data);
    return this._onDropItem(event, data);
  }

  /* -------------------------------------------- */

  /**
   * Handle the dropping of Folder data onto the Container sheet.
   * @param {DragEvent} event              The concluding DragEvent which contains the drop data.
   * @param {object} data                  The data transfer extracted from the event.
   * @returns {Promise<Item5e[]>}          The created Item objects.
   */
  async _onDropFolder(event, data) {
    const folder = await Folder.implementation.fromDropData(data);
    if ( !this.item.isOwner || (folder.type !== "Item") ) return [];

    let recursiveWarning = false;
    const parentContainers = await this.item.system.allContainers();
    const containers = new Set();

    let items = await Promise.all(folder.contents.map(async item => {
      if ( !(item instanceof Item) ) item = await fromUuid(item.uuid);
      if ( item.system.container === this.item.id ) return;
      if ( (this.item.uuid === item.uuid) || parentContainers.includes(item) ) {
        recursiveWarning = true;
        return;
      }
      if ( item.type === "container" ) containers.add(item.id);
      return item;
    }));
    items = items.filter(i => i && !containers.has(i.system.container));

    // Display recursive warning, but continue with any remaining items
    if ( recursiveWarning ) ui.notifications.warn("DND5E.ContainerRecursiveError", { localize: true });
    if ( !items.length ) return [];

    // Create any remaining items
    const toCreate = await Item5e.createWithContents(items, {
      container: this.item,
      transformAll: (itemData, options) => this._onDropSingleItem(itemData, { ...options, event })
    });
    if ( this.item.folder ) toCreate.forEach(d => d.folder = this.item.folder.id);
    return Item5e.createDocuments(toCreate, {pack: this.item.pack, parent: this.item.parent, keepId: true});
  }

  /* -------------------------------------------- */

  /**
   * Handle the dropping of Item data onto an Item Sheet.
   * @param {DragEvent} event              The concluding DragEvent which contains the drop data.
   * @param {object} data                  The data transfer extracted from the event.
   * @returns {Promise<Item5e[]|boolean>}  The created Item objects or `false` if it couldn't be created.
   * @protected
   */
  async _onDropItem(event, data) {
    const behavior = this._dropBehavior(event, data);
    const item = await Item.implementation.fromDropData(data);
    if ( !this.item.isOwner || !item || (behavior === "none") ) return false;

    // If item already exists in this container, just adjust its sorting
    if ( (behavior === "move") && (item.system.container === this.item.id) ) {
      return this._onSortItem(event, item);
    }

    // Prevent dropping containers within themselves
    const parentContainers = await this.item.system.allContainers();
    if ( (this.item.uuid === item.uuid) || parentContainers.includes(item) ) {
      ui.notifications.error("DND5E.ContainerRecursiveError", { localize: true });
      return;
    }

    // If item already exists in same DocumentCollection, just adjust its container property
    if ( (behavior === "move") && (item.actor === this.item.actor) && (item.pack === this.item.pack) ) {
      return item.update({ folder: this.item.folder, "system.container": this.item.id });
    }

    // Otherwise, create a new item & contents in this context
    const toCreate = await Item5e.createWithContents([item], {
      container: this.item,
      transformAll: (itemData, options) => this._onDropSingleItem(itemData, { ...options, event })
    });
    if ( this.item.folder ) toCreate.forEach(d => d.folder = this.item.folder.id);
    const created = Item5e.createDocuments(toCreate, { pack: this.item.pack, parent: this.item.actor, keepId: true });
    if ( behavior === "move" ) item.delete({ deleteContents: true });
    return created;
  }

  /* -------------------------------------------- */

  /**
   * Process a single item when dropping into the container.
   * @param {object} itemData           The item data to create.
   * @param {object} options
   * @param {string} options.container  ID of the container to create the items.
   * @param {number} options.depth      Current depth of the item being created.
   * @param {DragEvent} options.event   The concluding DragEvent which provided the drop data.
   * @returns {Promise<object|false>}   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, { container, depth, event }) {
    if ( itemData.type === "spell" ) {
      const scroll = await Item5e.createScrollFromSpell(itemData);
      return scroll?.toObject?.() ?? false;
    }

    if ( this.item.actor && (container === this.item.id) ) {
      const result = await this.item.actor.sheet._onDropStackConsumables(event, itemData, { container });
      if ( result ) return false;
    }

    return itemData;
  }

  /* -------------------------------------------- */

  /**
   * Handle a drop event for an existing contained Item to sort it relative to its siblings.
   * @param {DragEvent} event  The concluding DragEvent.
   * @param {Item5e} item      The item that needs to be sorted.
   * @protected
   */
  async _onSortItem(event, item) {
    const dropTarget = event.target.closest("[data-item-id]");
    if ( !dropTarget ) return;
    const contents = await this.item.system.contents;
    const target = contents.get(dropTarget.dataset.itemId);

    // Don't sort on yourself
    if ( item.id === target.id ) return;

    // Identify sibling items based on adjacent HTML elements
    const siblings = [];
    for ( const el of dropTarget.parentElement.children ) {
      const siblingId = el.dataset.itemId;
      if ( siblingId && (siblingId !== item.id) ) siblings.push(contents.get(siblingId));
    }

    // Perform the sort
    const sortUpdates = foundry.utils.performIntegerSort(item, {target, siblings});
    const updateData = sortUpdates.map(u => {
      const update = u.update;
      update._id = u.target.id;
      return update;
    });

    // Perform the update
    Item.updateDocuments(updateData, {pack: this.item.pack, parent: this.item.actor});
  }

  /* -------------------------------------------- */
  /*  Life-Cycle                                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    const inventory = this.element.querySelector(this.options.elements.inventory);
    if ( inventory ) inventory.dataset.itemId = this.item.id;
  }

  /* -------------------------------------------- */
  /*  Filtering                                   */
  /* -------------------------------------------- */

  /** @override */
  _filterChildren(collection, filters) {
    if ( collection === "items" ) return this._filterItems(this._items, filters);
    return [];
  }

  /* -------------------------------------------- */

  /**
   * Filter the container's contents based on the current set of filters.
   * @param {Item5e[]} items       The Items to filter.
   * @param {Set<string>} filters  Filters applied to the Item list.
   * @returns {Item5e[]}
   * @protected
   */
  _filterItems(items, filters) {
    const actions = ["action", "bonus", "reaction"];
    return items.filter(item => {
      // Subclass-specific logic.
      const filtered = this._filterItem(item, filters);
      if ( filtered !== undefined ) return filtered;

      // Action usage.
      for ( const action of actions ) {
        if ( filters.has(action) && (item.system.activation?.type !== action) ) return false;
      }

      // Equipment-specific filters.
      if ( filters.has("equipped") && (item.system.equipped !== true) ) return false;
      if ( filters.has("mgc") && !item.system.properties?.has("mgc") ) return false;

      return true;
    });
  }

  /* -------------------------------------------- */

  /**
   * Determine whether an Item will be shown based on the current set of filters.
   * @param {Item5e} item          The Item.
   * @param {Set<string>} filters  Filters applied to the Item.
   * @returns {boolean|void}
   * @protected
   */
  _filterItem(item, filters) {
    /** @import BaseActorSheet from "../actor/api/base-actor-sheet.mjs" */

    /**
     * A hook event that fires when a sheet filters an item.
     * @function dnd5e.filterItem
     * @memberof hookEvents
     * @param {BaseActorSheet|ContainerSheet} sheet     The sheet the item is being rendered on.
     * @param {Item5e} item                             The item being filtered.
     * @param {Set<string>} filters                     Filters applied to the Item.
     * @returns {false|void} Return false to hide the item, otherwise other filters will continue to apply.
     */
    if ( Hooks.call("dnd5e.filterItem", this, item, filters) === false ) return false;
  }

  /* -------------------------------------------- */
  /*  Sorting                                     */
  /* -------------------------------------------- */

  /** @override */
  _sortChildren(collection, mode) {
    if ( collection === "items" ) return this._sortItems(this._items, mode);
    return [];
  }
}

/**
 * Compendium with added support for item containers.
 */
class ItemCompendium5e extends DragDropApplicationMixin(foundry.applications.sidebar.apps.Compendium) {

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    let items = this.collection;
    if ( this.collection.index ) {
      if ( !this.collection._reindexing ) this.collection._reindexing = this.collection.getIndex();
      await this.collection._reindexing;
      items = this.collection.index;
    }
    for ( const item of items ) {
      if ( items.has(item.system?.container) ) this.element?.querySelector(`[data-entry-id="${item._id}"]`)?.remove();
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _allowedDropBehaviors(event, data) {
    const allowed = new Set(["copy"]);
    if ( !data?.uuid ) return allowed;
    const s = foundry.utils.parseUuid(data.uuid);
    if ( s.collection === this.collection ) allowed.add("move");
    return allowed;
  }

  /* -------------------------------------------- */

  /** @override */
  _defaultDropBehavior(event, data) {
    if ( !data?.uuid ) return "copy";
    if ( (data.type !== "Folder") && (data.type !== "Item") ) return "none";
    return foundry.utils.parseUuid(data.uuid).collection === this.collection ? "move" : "copy";
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _handleDroppedEntry(target, data) {
    // Obtain the dropped Document
    const behavior = this._dropBehavior(event, data);
    let item = await Item.fromDropData(data);
    if ( !item || (behavior === "none") ) return;

    // Create item and its contents if it doesn't already exist here
    if ( (behavior === "copy") || !this._entryAlreadyExists(item) ) {
      const contents = await item.system.contents;
      const toCreate = contents?.size
        ? await Item5e.createWithContents([item], { transformAll: item => item.toCompendium() })
        : [{ ...item.toCompendium(), "system.container": null }];
      if ( toCreate.length ) {
        const folder = target?.closest("[data-folder-id]")?.dataset.folderId;
        if ( folder ) toCreate.map(d => d.folder = folder);
        [item] = await Item5e.createDocuments(toCreate, { pack: this.collection.collection, keepId: true });
      }
    }

    // Otherwise, if it is within a container, take it out
    else if ( item.system.container ) await item.update({"system.container": null});

    // Let parent method perform sorting
    super._handleDroppedEntry(target, item.toDragData());
  }

  /* -------------------------------------------- */

  /** @override */
  async _onClickEntry(event) {
    const { entryId } = event.target.closest("[data-entry-id]")?.dataset ?? {};
    const item = await this.collection.getDocument?.(entryId);
    if ( !item ) return;
    const mode = item.sheet?._mode ?? (this.collection.locked ? ItemSheet5e.MODES.PLAY : ItemSheet5e.MODES.EDIT);
    item.sheet.render(true, { mode });
  }
}

/**
 * Items sidebar with added support for item containers.
 */
class ItemDirectory5e extends DragDropApplicationMixin(foundry.applications.sidebar.tabs.ItemDirectory) {

  /** @override */
  _allowedDropBehaviors(event, data) {
    const allowed = new Set(["copy"]);
    if ( !data?.uuid ) return allowed;
    const { CompendiumCollection } = foundry.documents.collections;
    const fromCompendium = foundry.utils.parseUuid(data.uuid).collection instanceof CompendiumCollection;
    if ( data.type === "Folder" ) return fromCompendium ? allowed : new Set(["move"]);
    else if ( !fromCompendium ) allowed.add("move");
    return allowed;
  }

  /* -------------------------------------------- */

  /** @override */
  _defaultDropBehavior(event, data) {
    if ( !data?.uuid ) return "copy";
    if ( (data.type !== "Folder") && (data.type !== "Item") ) return "none";
    const collection = foundry.utils.parseUuid(data.uuid).collection;
    return ((data.type === "Folder") && (collection instanceof Folder))
      || ((data.type === "Item") && (collection === this.collection)) ? "move" : "copy";
  }

  /* -------------------------------------------- */

  /** @override */
  _onDrop(event) {
    const data = foundry.applications.ux.TextEditor.implementation.getDragEventData(event);
    if ( !data.type ) return;
    const target = event.target.closest(".directory-item") || null;

    // Call the drop handler
    switch ( data.type ) {
      case "Folder":
        return this._handleDroppedFolder(target, data);
      case this.collection.documentName:
        return this._handleDroppedEntry(target, data, event);
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _handleDroppedEntry(target, data, event) {
    // Obtain the dropped Document
    const behavior = this._dropBehavior(event, data);
    let item = await this._getDroppedEntryFromData(data);
    if ( (behavior === "none") || !item ) return;

    // Create item and its contents if it doesn't already exist here
    if ( (behavior === "copy") || !this._entryAlreadyExists(item) ) {
      const toCreate = await Item5e.createWithContents([item]);
      const folder = target?.closest("[data-folder-id]")?.dataset.folderId;
      if ( folder ) toCreate.map(d => d.folder = folder);
      [item] = await Item5e.createDocuments(toCreate, {keepId: true});
      if ( behavior === "move" ) fromUuid(data.uuid).then(d => d?.delete({ deleteContents: true }));
    }

    // Otherwise, if it is within a container, take it out
    else if ( item.system.container ) await item.update({"system.container": null});

    // Let parent method perform sorting
    super._handleDroppedEntry(target, item.toDragData());
  }

  /* -------------------------------------------- */

  /** @override */
  async _onClickEntry(event, target) {
    const { entryId } = target.closest("[data-entry-id]")?.dataset ?? {};
    const item = this.collection.get(entryId) ?? await this.collection.getDocument(entryId);
    if ( !item ) return super._onClickEntry(event, target);
    const mode = item.sheet?._mode ?? (this.collection.locked ? ItemSheet5e.MODES.PLAY : ItemSheet5e.MODES.EDIT);
    item.sheet.render({ force: true, mode });
  }
}

var _module$n = /*#__PURE__*/Object.freeze({
  __proto__: null,
  ContainerSheet: ContainerSheet,
  ItemCompendium5e: ItemCompendium5e,
  ItemDirectory5e: ItemDirectory5e,
  ItemSheet5e: ItemSheet5e,
  StartingEquipmentConfig: StartingEquipmentConfig
});

const TextEditor$5 = foundry.applications.ux.TextEditor.implementation;

/**
 * @import { JournalEditorConfiguration } from "./_types.mjs";
 */

/**
 * Pop out ProseMirror editor window for journal entries with multiple text areas that need editing.
 * @extends {DocumentSheet5e<ApplicationConfiguration & JournalEditorConfiguration>}
 */
class JournalEditor extends DocumentSheet5e {

  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["journal-editor", "dnd5e2-journal", "titlebar", "dialog-lg"],
    window: {
      resizable: true
    },
    form: {
      submitOnChange: true
    },
    position: {
      width: 550,
      height: 640
    },
    sheetConfig: false,
    textKeyPath: null
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    editor: {
      template: "systems/dnd5e/templates/journal/journal-editor.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  get title() {
    return this.options.window.title ?? this.document.name;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    const rawText = foundry.utils.getProperty(this.document, this.options.textKeyPath) ?? "";
    return foundry.utils.mergeObject(context, {
      document: this.document,
      enriched: await TextEditor$5.enrichHTML(rawText, {
        relativeTo: this.document, secrets: this.document.isOwner
      }),
      keyPath: this.options.textKeyPath,
      source: rawText
    });
  }
}

const { JournalEntryPageHandlebarsSheet: JournalEntryPageHandlebarsSheet$1 } = foundry.applications.sheets.journal;
const TextEditor$4 = foundry.applications.ux.TextEditor.implementation;

/**
 * @import { SpellcastingDescription } from "../../documents/_types.mjs";
 */

/**
 * Journal entry page that displays an automatically generated summary of a class along with additional description.
 */
class JournalClassPageSheet extends JournalEntryPageHandlebarsSheet$1 {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    actions: {
      deleteItem: JournalClassPageSheet.#onDeleteItem,
      launchTextEditor: JournalClassPageSheet.#onLaunchTextEditor
    },
    classes: ["class"],
    includeTOC: true,
    position: {
      width: 600
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static EDIT_PARTS = {
    header: super.EDIT_PARTS.header,
    config: {
      classes: ["standard-form"],
      template: "systems/dnd5e/templates/journal/page-{type}-edit.hbs"
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static VIEW_PARTS = {
    content: {
      root: true,
      template: "systems/dnd5e/templates/journal/page-{type}-view.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Whether this page represents a class or subclass.
   * @type {string}
   */
  get type() {
    return this.document.type;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _configureRenderParts(options) {
    const parts = super._configureRenderParts(options);
    Object.values(parts).forEach(p => p.template = p.template.replace("{type}", this.type));
    return parts;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    new CONFIG.ux.DragDrop({
      permissions: { drop: this._canDragDrop.bind(this) },
      callbacks: { drop: this._onDrop.bind(this) }
    }).bind(this.element);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    context.system = context.document.system;
    context.systemFields = this.document.system.schema.fields;

    context.styleOptions = [
      { value: "", label: game.i18n.localize("JOURNALENTRYPAGE.DND5E.Class.Style.Inferred") },
      { rule: true },
      { value: "2024", label: game.i18n.localize("JOURNALENTRYPAGE.DND5E.Class.Style.Modern") },
      { value: "2014", label: game.i18n.localize("JOURNALENTRYPAGE.DND5E.Class.Style.Legacy") }
    ];

    context.title = {
      ...context.title,
      ...Object.fromEntries(Array.fromRange(4, 1).map(n => [`level${n}`, context.title.level + n - 1]))
    };
    context.type = this.type;

    const linked = await fromUuid(this.document.system.item);
    context.subclasses = this.type === "class" ? await this._getSubclasses(this.document.system.subclassItems) : null;

    if ( !linked ) return context;
    context.linked = {
      document: linked,
      name: linked.name,
      lowercaseName: linked.name.toLowerCase()
    };
    const modernStyle = context.modernStyle = (context.system.style || linked.system.source.rules) === "2024";

    context.advancement = this._getAdvancement(linked, { modernStyle });
    context.enriched = await this._getDescriptions(context.document);
    context.table = await this._getTable(linked, { modernStyle });
    context.optionalTable = await this._getOptionalTable(linked, { modernStyle });
    context.features = await this._getFeatures(linked, { modernStyle });
    context.optionalFeatures = await this._getFeatures(linked, { modernStyle, optional: true });

    if ( context.subclasses?.length ) {
      for ( const subclass of context.subclasses ) {
        const initialLevel = parseInt(Object.entries(subclass.document.advancement.byLevel)
          .find(([lvl, d]) => d.length)?.[0] ?? 1);
        subclass.table = await this._getTable(subclass.document, { initialLevel, modernStyle });
        subclass.features = await this._getFeatures(subclass.document, { modernStyle });
      }
      context.subclasses.sort((lhs, rhs) => lhs.name.localeCompare(rhs.name, game.i18n.lang));
    }

    if ( linked.system.primaryAbility ) {
      context.primaryAbility = game.i18n.getListFormatter(
        { type: linked.system.primaryAbility.all ? "conjunction" : "disjunction" }
      ).format(Array.from(linked.system.primaryAbility.value).map(v => CONFIG.DND5E.abilities[v]?.label));
    }

    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare features granted by various advancement types.
   * @param {Item5e} item                  Class item belonging to this journal.
   * @param {object} options
   * @param {boolean} options.modernStyle  Is the modern style being displayed?
   * @returns {object}                     Prepared advancement section.
   * @protected
   */
  _getAdvancement(item, { modernStyle }) {
    const advancement = {};

    const hp = item.advancement.byType.HitPoints?.[0];
    if ( hp ) {
      advancement.hp = {
        hitDice: modernStyle ? hp.hitDie.toUpperCase() : `1${hp.hitDie}`,
        max: hp.hitDieValue,
        average: Math.floor(hp.hitDieValue / 2) + 1
      };
    }

    const traits = item.advancement.byType.Trait ?? [];
    const makeTrait = type => {
      const advancement = traits.find(a => {
        const rep = a.representedTraits();
        if ( (rep.size > 1) || (rep.first() !== type) ) return false;
        return (a.classRestriction !== "secondary") && (a.level === 1) && (a.configuration.mode === "default");
      });
      if ( !advancement ) return game.i18n.localize("None");
      return advancement.hint || localizedList(advancement.configuration);
    };
    if ( traits.length ) {
      advancement.traits = {
        armor: makeTrait("armor"),
        weapons: makeTrait("weapon"),
        tools: makeTrait("tool"),
        saves: makeTrait("saves"),
        skills: makeTrait("skills")
      };
    }

    if ( item.type === "class" ) advancement.equipment = item.system.getStartingEquipmentDescription({ modernStyle });

    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.
   * @protected
   */
  async _getDescriptions(page) {
    const descriptions = await Promise.all(Object.entries(page.system.description ?? {})
      .map(async ([id, text]) => {
        const enriched = await TextEditor$4.enrichHTML(text, {
          relativeTo: this.document,
          secrets: this.document.isOwner
        });
        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 {object} options
   * @param {number} [options.initialLevel=1]  Level at which the table begins.
   * @param {boolean} options.modernStyle      Is the modern style being displayed?
   * @returns {object}                         Prepared table.
   * @protected
   */
  async _getTable(item, { initialLevel=1, modernStyle }={}) {
    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?.headers?.length > 1 ) {
      headers[0].forEach(h => h.rowSpan = 2);
      headers[0].push(...spellProgression.headers[0]);
      headers[1] = spellProgression.headers[1];
    } else if ( spellProgression?.headers?.length ) {
      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?.length ) cols.push(...spellProgression.cols);

    const prepareFeature = uuid => {
      const index = fromUuidSync(uuid);
      if ( index?.type !== "feat" ) return null;
      return linkForUuid(uuid);
    };

    const rows = [];
    for ( const level of Array.fromRange((CONFIG.DND5E.maxLevel - (initialLevel - 1)), initialLevel) ) {
      let features = [];
      for ( const advancement of item.advancement.byLevel[level] ) {
        switch ( advancement.constructor.typeName ) {
          case "AbilityScoreImprovement":
            features.push(advancement._defaultTitle);
            continue;
          case "ItemGrant":
            if ( advancement.configuration.optional ) continue;
            features.push(...await Promise.all(advancement.configuration.items.map(i => prepareFeature(i.uuid))));
            break;
        }
      }
      features = features.filter(_ => _);

      // Level & proficiency bonus
      const cells = [{class: "level", content: modernStyle ? level : 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?.[level - 1];
      if ( spellCells ) cells.push(...spellCells);

      // Skip empty rows on subclasses
      if ( item.type === "subclass" ) {
        let displayRow = features.length || spellCells;
        if ( rows.length ) displayRow ||= rows.at(-1).some((cell, index) =>
          (cell.class === "scale") && (cell.content !== cells[index].content)
        );
        else if ( scaleValues.length ) displayRow ||= cells.filter(c => (c.class === "scale") && c.content).length;
        if ( !displayRow ) 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|null}  Prepared spell progression table.
   * @protected
   */
  async _getSpellProgression(item) {
    const spellcasting = foundry.utils.deepClone(item.spellcasting);
    if ( !spellcasting || (spellcasting.progression === "none") ) return null;

    const spellcastingModel = CONFIG.DND5E.spellcasting[spellcasting.type];
    const table = {};

    if ( spellcastingModel?.isSingleLevel ) {
      const spellSlotKey = spellcastingModel.getSpellSlotKey();
      const spells = { [spellSlotKey]: {} };

      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
      table.rows = [];
      for ( const level of Array.fromRange(CONFIG.DND5E.maxLevel, 1) ) {
        const progression = { [spellSlotKey]: 0 };
        spellcasting.levels = level;
        Actor5e.computeClassProgression(progression, item, { spellcasting });
        Actor5e.prepareSpellcastingSlots(spells, spellcasting.type, progression);
        table.rows.push(spells[spellSlotKey].max ? [
          { class: "spell-slots", content: spells[spellSlotKey].max },
          { class: "slot-level", content: spells[spellSlotKey].level.ordinalString() }
        ] : null);
      }
    } else if ( spellcastingModel?.slots ) {
      const maxSpellLevel = Object.keys(CONFIG.DND5E.spellLevels).length - 1;
      const spells = Object.fromEntries(Array.fromRange(maxSpellLevel, 1).map(l => {
        return [spellcastingModel.getSpellSlotKey(l), {}];
      }));

      let largestSlot;
      table.rows = [];
      for ( const level of Array.fromRange(CONFIG.DND5E.maxLevel, 1).reverse() ) {
        const progression = { [spellcasting.type]: 0 };
        spellcasting.levels = level;
        Actor5e.computeClassProgression(progression, item, { spellcasting });
        Actor5e.prepareSpellcastingSlots(spells, spellcasting.type, progression);

        if ( !largestSlot ) largestSlot = Object.values(spells).reduce((slot, { max, level }) => {
          if ( !max ) return slot;
          return Math.max(slot, level || -1);
        }, -1);

        const hasSlots = Object.values(spells).some(slot => slot.max > 0);
        const row = hasSlots ? Array.fromRange(largestSlot, 1).map(spellLevel => ({
          class: "spell-slots",
          content: spells[spellcastingModel.getSpellSlotKey(spellLevel)]?.max || "&mdash;"
        })) : null;

        table.rows.push(row);
      }

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

    /**
     * A hook event that fires to generate the table for 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 foundry.utils.isEmpty(table) ? null : table;
  }

  /* -------------------------------------------- */

  /**
   * Prepare options table based on optional GrantItem advancement.
   * @param {Item5e} item                  Class item belonging to this journal.
   * @param {object} options
   * @param {boolean} options.modernStyle  Is the modern style being displayed?
   * @returns {object|null}                Prepared optional features table.
   * @protected
   */
  async _getOptionalTable(item, { modernStyle }) {
    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 prepareFeature = uuid => {
      const index = fromUuidSync(uuid);
      if ( index?.type !== "feat" ) return null;
      return linkForUuid(uuid);
    };

    const rows = [];
    for ( const level of Array.fromRange(CONFIG.DND5E.maxLevel, 1) ) {
      let 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(i => prepareFeature(i.uuid))));
            break;
        }
      }
      features = features.filter(_ => _);
      if ( !features.length ) continue;

      // Level & proficiency bonus
      const cells = [
        { class: "level", content: modernStyle ? level : 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 {object} options
   * @param {boolean} options.modernStyle       Is the modern style being displayed?
   * @param {boolean} [options.optional=false]  Should optional features be fetched rather than required features?
   * @returns {object[]}   Prepared features.
   * @protected
   */
  async _getFeatures(item, { modernStyle, optional=false }) {
    const prepareFeature = async (f, level) => {
      const document = await fromUuid(f.uuid);
      if ( document?.type !== "feat" ) return null;
      return {
        document, level,
        name: modernStyle ? game.i18n.format("JOURNALENTRYPAGE.DND5E.Class.Features.Name", {
          name: document.name, level: formatNumber(level)
        }) : document.name,
        description: await TextEditor$4.enrichHTML(document.system.description.value, {
          relativeTo: document, secrets: false
        })
      };
    };

    let features = [];
    const itemGrants = Array.from(item.advancement.byType.ItemGrant ?? []);
    for ( const advancement of itemGrants ) {
      if ( !!advancement.configuration.optional !== optional ) continue;
      features.push(...advancement.configuration.items.map(f => prepareFeature(f, advancement.level)));
    }

    const asi = (item.advancement.byType.AbilityScoreImprovement ?? []).reduce((obj, advancement) => {
      if ( advancement.isEpicBoon ) obj.boons.push(advancement);
      else obj.levels.push(advancement.level);
      return obj;
    }, { levels: [], boons: [] });
    if ( asi.levels.length ) {
      const [firstLevel, ...otherLevels] = asi.levels.sort((a, b) => a - b);
      const name = game.i18n.localize("DND5E.ADVANCEMENT.AbilityScoreImprovement.Journal.Name");
      features.push({
        description: game.i18n.format(
          `DND5E.ADVANCEMENT.AbilityScoreImprovement.Journal.Description${modernStyle ? "Modern" : "Legacy"}`,
          {
            class: item.name,
            firstLevel: formatNumber(firstLevel),
            firstLevelOrdinal: formatNumber(firstLevel, { ordinal: true }),
            maxAbilityScore: formatNumber(CONFIG.DND5E.maxAbilityScore),
            otherLevels: game.i18n.getListFormatter({ style: "long" }).format(otherLevels.map(l => formatNumber(l))),
            otherLevelsOrdinal: game.i18n.getListFormatter({ style: "long" })
              .format(otherLevels.map(l => formatNumber(l, { ordinal: true })))
          }
        ),
        level: asi.levels[0],
        name: modernStyle ? game.i18n.format("JOURNALENTRYPAGE.DND5E.Class.Features.Name", {
          name: name, level: formatNumber(firstLevel)
        }) : name
      });
    }
    for ( const advancement of asi.boons ) {
      const recommendation = await fromUuid(advancement.configuration.recommendation);
      features.push({
        description: game.i18n.format(
          `DND5E.ADVANCEMENT.AbilityScoreImprovement.Journal.DescriptionEpic${recommendation ? "Recommendation" : ""}`,
          { recommendation: recommendation?.toAnchor().outerHTML }
        ),
        level: advancement.level,
        name: game.i18n.format("JOURNALENTRYPAGE.DND5E.Class.Features.Name", {
          name: advancement._defaultTitle, level: formatNumber(advancement.level)
        })
      });
    }

    features = await Promise.all(features);
    return features.filter(f => f).sort((lhs, rhs) => lhs.level - rhs.level);
  }

  /* -------------------------------------------- */

  /**
   * Fetch each subclass and their features.
   * @param {string[]} uuids   UUIDs for the subclasses to fetch.
   * @returns {object[]|null}  Prepared subclasses.
   * @protected
   */
  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.
   * @protected
   */
  async _getSubclass(item) {
    return {
      document: item,
      name: item.name,
      description: await TextEditor$4.enrichHTML(item.system.description.value, {
        relativeTo: item, secrets: false
      })
    };
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /**
   * Handle deleting a dropped item.
   * @this {JournalClassPageSheet}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static async #onDeleteItem(event, target) {
    const container = target.closest("[data-item-uuid]");
    const uuidToDelete = container?.dataset.itemUuid;
    if ( !uuidToDelete ) return;
    switch ( container.dataset.itemType ) {
      case "linked":
        await this.document.update({ "system.item": "" });
        break;
      case "subclass":
        const itemSet = this.document.system.subclassItems;
        itemSet.delete(uuidToDelete);
        await this.document.update({ "system.subclassItems": Array.from(itemSet) });
        break;
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle launching the individual text editing window.
   * @this {JournalClassPageSheet}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static #onLaunchTextEditor(event, target) {
    const textKeyPath = target.dataset.target;
    const label = event.target.closest(".form-group").querySelector("label");
    const editor = new JournalEditor({ document: this.document, textKeyPath, window: { title: label?.innerText } });
    editor.render({ force: true });
  }

  /* -------------------------------------------- */
  /*  Drag & Drop                                 */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _canDragDrop() {
    return this.isEditable;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onDrop(event) {
    const data = TextEditor$4.getDragEventData(event);

    if ( data?.type !== "Item" ) return false;
    const item = await Item.implementation.fromDropData(data);
    const type = this.type === item.type ? "linked" : item.type;
    switch ( type ) {
      case "linked":
        await this.document.update({"system.item": item.uuid});
        break;
      case "subclass":
        const itemSet = this.document.system.subclassItems;
        itemSet.add(item.uuid);
        await this.document.update({"system.subclassItems": Array.from(itemSet)});
        break;
      default:
        return false;
    }
  }
}

const { JournalEntryPageProseMirrorSheet } = foundry.applications.sheets.journal;

/**
 * Journal entry page that displays a controls for editing map markers.
 */
class JournalMapLocationPageSheet extends JournalEntryPageProseMirrorSheet {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["text", "map"]
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    const code = this.document.system.code ?? "";

    if ( this.isView ) {
      const title = this.element.querySelector(".journal-page-header :first-child");
      if ( title ) title.dataset.mapLocationCode = code;
    } else {
      const header = this.element.querySelector(".journal-header");
      const name = header.querySelector("input");
      const div = document.createElement("div");
      const input = document.createElement("input");
      Object.assign(input, { name: "system.code", type: "text", value: code });
      div.append(input, name);
      header.insertAdjacentElement("afterbegin", div);
    }
  }
}

/**
 * Journal entry page that displays a controls for editing rule page tooltip & type.
 */
class JournalRulePageSheet extends foundry.applications.sheets.journal.JournalEntryPageProseMirrorSheet {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["text", "rule"]
  };

  /* -------------------------------------------- */

  /** @override */
  static EDIT_PARTS = {
    header: super.EDIT_PARTS.header,
    content: super.EDIT_PARTS.content,
    tooltip: {
      template: "systems/dnd5e/templates/journal/page-rule-edit.hbs"
    },
    footer: super.EDIT_PARTS.footer
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    context.CONFIG = CONFIG.DND5E;
    return context;
  }
}

/**
 * Variant of the standard journal sheet with support for additional page types.
 */
class JournalEntrySheet5e extends foundry.applications.sheets.journal.JournalEntrySheet {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["dnd5e2", "dnd5e2-journal", "titlebar"]
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _createContextMenu(handler, selector, options={}) {
    options.fixed = true;
    return super._createContextMenu(handler, selector, options);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    this.element.querySelectorAll(".action-buttons :is(.previous, .next)").forEach(el => {
      el.classList.add("inline-control");
    });
    if ( options.parts.includes("pages") ) this.constructor._injectNavigation(this.document, this.element);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _preparePageData() {
    const pages = super._preparePageData();
    this.constructor._adjustTOCNumbering(this.document, pages);
    return pages;
  }

  /* -------------------------------------------- */

  /**
   * Append the dnd5e system styling classes to journal pages inside system journal sheets.
   * @param {JournalEntryPageSheet} page  The page application.
   * @param {HTMLElement} element         The page application's rendered element.
   */
  static onRenderJournalPageSheet(page, element) {
    if ( page.document.parent?.sheet instanceof JournalEntrySheet5e ) {
      element.classList.add("dnd5e2", "dnd5e2-journal", "titlebar", "dialog-lg");
    }
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _attachFrameListeners() {
    super._attachFrameListeners();
    this.element.addEventListener("pointerdown", event => {
      const tooltipActive = event.target.ownerDocument.getElementById("tooltip")?.classList.contains("active");
      if ( (event.button === 1) && tooltipActive ) event.preventDefault();
    });
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Adjust ToC numbering for custom page types.
   * @param {JournalEntry} entry                             The parent JournalEntry.
   * @param {Record<string, JournalSheetPageContext>} pages  The page descriptors.
   * @internal
   */
  static _adjustTOCNumbering(entry, pages) {
    let adjustment = 0;
    for ( const descriptor of Object.values(pages) ) {
      const page = entry.pages.get(descriptor.id);
      const numbering = page?.system.adjustTOCNumbering?.(descriptor.number);
      if ( numbering ) {
        descriptor.number = numbering.number;
        adjustment += numbering.adjustment ?? 0;
      }
      else descriptor.number += adjustment;
    }
  }

  /* -------------------------------------------- */

  /**
   * Add navigation controls for journal entries that define them.
   * @param {JournalEntry} entry  The parent JournalEntry.
   * @param {HTMLElement} html    The
   * @internal
   */
  static async _injectNavigation(entry, html) {
    const nav = entry.getFlag("dnd5e", "navigation");
    if ( !nav ) return;
    const getDocument = id => entry.pack ? entry.collection.getDocument(id) : entry.collection.get(id);
    const previous = nav.previous ? await getDocument(nav.previous) : null;
    const up = nav.up ? await getDocument(nav.up) : null;
    const next = nav.next ? await getDocument(nav.next) : null;
    const element = document.createElement("nav");
    element.classList.add("book-navigation");
    const list = document.createElement("ul");
    element.append(list);
    list.append(JournalEntrySheet5e.#makeNavigation(previous, "prev"));
    list.append(JournalEntrySheet5e.#makeNavigation(up, "up"));
    list.append(JournalEntrySheet5e.#makeNavigation(next, "next"));
    html.querySelector(".journal-entry-content .journal-header")?.after(element);
  }

  /* -------------------------------------------- */

  /**
   * Generate markup for a navigation link.
   * @param {JournalEntry} doc        The journal entry that will be navigated to.
   * @param {"next"|"prev"|"up"} dir  The navigation direction.
   * @returns {HTMLLIElement}
   */
  static #makeNavigation(doc, dir) {
    const li = document.createElement("li");
    if ( !doc?.testUserPermission(game.user, "OBSERVER") ) return li;
    const anchor = document.createElement("a");
    anchor.classList.add("content-link");
    if ( dir === "up" ) anchor.classList.add("parent");
    else {
      anchor.rel = dir;
      anchor.dataset.tooltipDirection = dir === "prev" ? "LEFT" : "RIGHT";
    }
    const i18n = { prev: "Previous", next: "Next", up: "Up" };
    Object.assign(anchor.dataset, { link: "", tooltip: `DND5E.JOURNALENTRY.Navigation.${i18n[dir]}`, uuid: doc.uuid });
    anchor.append(doc.name);
    li.append(anchor);
    return li;
  }
}

/**
 * Variant of the standard journal sheet to handle custom TOC numbering.
 */
class JournalSheet5e extends foundry.appv1.sheets.JournalSheet {
  constructor(...args) {
    foundry.utils.logCompatibilityWarning("The JournalSheet5e application has been deprecated and replaced with "
      + "JournalEntrySheet5e.");
    super(...args);
  }

  /* -------------------------------------------- */

  /** @override */
  static _warnedAppV1 = true;

  /* --------------------------------------------- */

  /** @inheritDoc */
  static get defaultOptions() {
    const options = super.defaultOptions;
    options.classes.push("dnd5e2-journal", "dnd5e2-journal-legacy");
    return options;
  }

  /* --------------------------------------------- */
  /*  Rendering                                    */
  /* --------------------------------------------- */

  /** @inheritDoc */
  _getPageData() {
    const pageData = super._getPageData();
    JournalEntrySheet5e._adjustTOCNumbering(this.document, Object.fromEntries(pageData.map(p => {
      p.id = p._id;
      return [p.id, p];
    })));
    return pageData;
  }

  /* --------------------------------------------- */

  /** @inheritdoc */
  async _render(...args) {
    await super._render(...args);
    return JournalEntrySheet5e._injectNavigation(this.document, this.element[0]);
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @inheritDoc */
  activateListeners(html) {
    super.activateListeners(html);
    html.on("pointerdown", event => {
      if ( (event.button === 1) && document.getElementById("tooltip")?.classList.contains("active") ) {
        event.preventDefault();
      }
    });
  }
}

/**
 * Custom items collection to hide items in containers automatically.
 */
class Items5e extends foundry.documents.collections.Items {
  /** @override */
  _getVisibleTreeContents(entry) {
    return this.contents.filter(c => c.visible && !this.has(c.system?.container));
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async importFromCompendium(pack, id, updateData={}, options={}) {
    const created = await super.importFromCompendium(pack, id, updateData, options);

    const item = await pack.getDocument(id);
    const contents = await item.system.contents;
    if ( contents ) {
      const fromOptions = foundry.utils.mergeObject({ clearSort: false }, options);
      const toCreate = await Item5e.createWithContents(contents, {
        container: created, keepId: options.keepId, transformAll: item => this.fromCompendium(item, fromOptions)
      });
      await Item5e.createDocuments(toCreate, {fromCompendium: true, keepId: true});
    }

    return created;
  }
}

/**
 * Application for configuring a single unlinked spell in a spell list.
 */
class SpellsUnlinkedConfig extends DocumentSheet5e {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["unlinked-spell-config"],
    form: {
      submitOnChange: true
    },
    position: {
      width: 400
    },
    sheetConfig: false,
    unlinkedId: null
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    spell: {
      template: "systems/dnd5e/templates/journal/spell/unlinked-spell.hbs"
    },
    source: {
      template: "systems/dnd5e/templates/journal/spell/unlinked-source.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  get title() {
    return game.i18n.localize("JOURNALENTRYPAGE.DND5E.SpellList.UnlinkedSpells.Configuration");
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = {
      ...await super._prepareContext(options),
      ...this.document.system.unlinkedSpells.find(u => u._id === this.options.unlinkedId),
      fields: this.document.system.schema.fields.unlinkedSpells.element.fields,
      spellLevelOptions: Object.entries(CONFIG.DND5E.spellLevels).map(([value, label]) => ({ value, label })),
      spellSchoolOptions: Object.entries(CONFIG.DND5E.spellSchools).map(([value, { label }]) => ({ value, label }))
    };
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _processFormData(event, form, formData) {
    const submitData = super._processFormData(event, form, formData);
    const unlinkedSpells = this.document.system.toObject().unlinkedSpells;
    const editing = unlinkedSpells.find(s => s._id === this.options.unlinkedId);
    foundry.utils.mergeObject(editing, submitData);
    return { system: { unlinkedSpells: unlinkedSpells } };
  }
}

const TextEditor$3 = foundry.applications.ux.TextEditor.implementation;
const { JournalEntryPageHandlebarsSheet } = foundry.applications.sheets.journal;

/**
 * Journal entry page that displays a list of spells for a class, subclass, background, or something else.
 */
class JournalSpellListPageSheet extends JournalEntryPageHandlebarsSheet {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["spells"],
    position: {
      width: 750
    },
    displayAsTable: false,
    embedRendering: false,
    grouping: null,
    actions: {
      addUnlinked: JournalSpellListPageSheet.#onAddUnlinked,
      delete: JournalSpellListPageSheet.#onDelete,
      editUnlinked: JournalSpellListPageSheet.#onEditUnlinked
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static EDIT_PARTS = {
    header: super.EDIT_PARTS.header,
    config: {
      classes: ["standard-form"],
      template: "systems/dnd5e/templates/journal/spell/config.hbs"
    },
    list: {
      classes: ["right", "spell-list"],
      template: "systems/dnd5e/templates/journal/spell/list.hbs"
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static VIEW_PARTS = {
    content: {
      root: true,
      template: "systems/dnd5e/templates/journal/spell/view.hbs"
    }
  };

  /* -------------------------------------------- */

  /**
   * Different ways in which spells can be grouped on the sheet.
   * @type {Record<string, string>}
   */
  static get GROUPING_MODES() {
    return dnd5e.dataModels.journal.SpellListJournalPageData.GROUPING_MODES;
  }

  /* -------------------------------------------- */

  /**
   * Currently selected grouping mode.
   * @type {string|null}
   */
  grouping = null;

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _configureRenderParts(options) {
    const parts = super._configureRenderParts(options);
    if ( ("content" in parts) && this.options.displayAsTable ) {
      parts.content.template = "systems/dnd5e/templates/journal/spell/table.hbs";
    }
    return parts;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onFirstRender(context, options) {
    await super._onFirstRender(context, options);
    if ( this.isView ) return;
    const left = document.createElement("div");
    left.classList.add("left", "flexcol", "standard-form");
    this.element.querySelector(".window-content").insertAdjacentElement("afterbegin", left);
    left.append(...this.element.querySelectorAll('[data-application-part="config"], [data-application-part="header"]'));
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    new CONFIG.ux.DragDrop({
      permissions: { drop: this._canDragDrop.bind(this) },
      callbacks: { drop: this._onDrop.bind(this) }
    }).bind(this.element);
    if ( this.isView ) {
      this.element.querySelector('[name="grouping"]')?.addEventListener("change", this._onChangeGroup.bind(this));
    }

    // FIXME: Workaround for core bug.
    else if ( options.parts.includes("config") ) {
      const editor = foundry.applications.elements.HTMLProseMirrorElement.create({
        name: "system.description.value",
        value: this.document.system._source.description.value
      });
      this.element.querySelector('[data-application-part="config"]').append(editor);
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    context.CONFIG = CONFIG.DND5E;
    context.system = context.document.system;
    context.embedRendering = this.options.embedRendering ?? false;

    context.description = await TextEditor$3.enrichHTML(context.system.description.value, { relativeTo: this.document });
    if ( context.description === "<p></p>" ) context.description = "";

    context.title = {
      ...context.title,
      ...Object.fromEntries(Array.fromRange(4, 1).map(n => [`level${n}`, context.title.level + n - 1]))
    };

    context.GROUPING_MODES = this.constructor.GROUPING_MODES;
    context.grouping = this.grouping || this.options.grouping || context.system.grouping;

    context.spells = await this.prepareSpells(context.grouping);

    context.sections = {};
    for ( const data of context.spells ) {
      const spell = data.spell ?? data.unlinked;
      let section;
      switch ( context.grouping ) {
        case "level":
          const level = spell.system.level;
          section = context.sections[level] ??= { header: CONFIG.DND5E.spellLevels[level], spells: [] };
          break;
        case "school":
          const school = spell.system.school;
          section = context.sections[school] ??= { header: CONFIG.DND5E.spellSchools[school]?.label, spells: [] };
          break;
        case "alphabetical":
          const letter = spell.name.slice(0, 1).toLowerCase();
          section = context.sections[letter] ??= { header: letter.toUpperCase(), spells: [] };
          break;
        default:
          continue;
      }
      section.spells.push(data);
    }
    if ( context.grouping === "school" ) context.sections = sortObjectEntries(context.sections, "header");

    if ( this.options.displayAsTable ) Object.values(context.sections).forEach(section => {
      const spells = section.spells.map(s => s.display).filter(_ => _);
      section.spellList = game.i18n.getListFormatter({ type: "unit" }).format(spells);
    });

    return context;
  }

  /* -------------------------------------------- */

  /**
   * Load indices with necessary information for spells.
   * @param {string} grouping  Grouping mode to respect.
   * @returns {Promise<object[]>}
   */
  async prepareSpells(grouping) {
    let fields;
    switch ( grouping ) {
      case "level": fields = ["system.level"]; break;
      case "school": fields = ["system.school"]; break;
      default: fields = []; break;
    }

    const unlinkedData = {};
    const uuids = new Set(this.document.system.spells);
    for ( const unlinked of this.document.system.unlinkedSpells ) {
      if ( unlinked.source.uuid ) {
        uuids.add(unlinked.source.uuid);
        unlinkedData[unlinked.source.uuid] = unlinked;
      }
    }

    let collections = new Collection();
    const remappedUuids = new Set();
    for ( const baseUuid of uuids ) {
      const { collection, uuid } = foundry.utils.parseUuid(baseUuid);
      remappedUuids.add(uuid);
      if ( collection && !collections.has(collection) ) {
        if ( collection instanceof Items5e ) collections.set(collection, collection);
        else collections.set(collection, collection.getIndex({ fields }));
      } else if ( !collection ) uuids.delete(baseUuid);
    }

    const spells = (await Promise.all(collections.values())).flatMap(c => c.filter(s => remappedUuids.has(s.uuid)));

    for ( const unlinked of this.document.system.unlinkedSpells ) {
      if ( !uuids.has(unlinked.source.uuid) ) spells.push({ unlinked });
    }

    return spells
      .map(spell => {
        const data = spell.unlinked ? spell : { spell };
        data.unlinked ??= unlinkedData[data.spell?.uuid];
        data.name = data.spell?.name ?? data.unlinked?.name ?? "";
        if ( data.spell ) {
          data.display = linkForUuid(data.spell.uuid, {
            tooltip: '<section class="loading"><i class="fas fa-spinner fa-spin-pulse"></i></section>'
          });
        } else {
          data.display = `<span class="unlinked-spell" data-tooltip="${
            data.unlinked.source.label}">${data.unlinked.name ?? "—"}*</span>`;
        }
        return data;
      })
      .sort((a, b) => a.name.localeCompare(b.name, game.i18n.lang));
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /**
   * Handle adding an unlinked spell.
   * @this {JournalSpellListPageSheet}
   */
  static async #onAddUnlinked() {
    await this.document.update({ "system.unlinkedSpells": [...this.document.system.unlinkedSpells, {}] });
    const id = this.document.system._source.unlinkedSpells.at(-1)._id;
    new SpellsUnlinkedConfig({ document: this.document, unlinkedId: id }).render({ force: true });
  }

  /* -------------------------------------------- */

  /**
   * Handle deleting a spell.
   * @this {JournalSpellListPageSheet}
   * @param {PointerEvent} event  The originating event.
   * @param {HTMLElement} target  The action target.
   */
  static #onDelete(event, target) {
    const { itemUuid, unlinkedId } = target.closest(".item")?.dataset ?? {};
    if ( itemUuid ) {
      const spellSet = this.document.system.spells.filter(s => s !== itemUuid);
      this.document.update({ "system.spells": Array.from(spellSet) });
    } else if ( unlinkedId ) {
      const unlinkedSet = this.document.system.unlinkedSpells.filter(s => s._id !== unlinkedId);
      this.document.update({ "system.unlinkedSpells": Array.from(unlinkedSet) });
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle editing an unlinked spell.
   * @this {JournalSpellListPageSheet}
   * @param {PointerEvent} event  The originating event.
   * @param {HTMLElement} target  The action target.
   */
  static #onEditUnlinked(event, target) {
    const { unlinkedId } = target.closest(".item")?.dataset ?? {};
    if ( unlinkedId ) new SpellsUnlinkedConfig({ document: this.document, unlinkedId }).render({ force: true });
  }

  /* -------------------------------------------- */

  /**
   * Handle changing the grouping.
   * @param {Event} event  The triggering event.
   * @protected
   */
  _onChangeGroup(event) {
    this.grouping = (event.target.value === this.document.system.grouping) ? null : event.target.value;
    this.document.parent.sheet.render();
  }

  /* -------------------------------------------- */
  /*  Drag & Drop                                 */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _canDragDrop() {
    return this.isEditable;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onDrop(event) {
    const data = TextEditor$3.getDragEventData(event);
    let spells;
    switch ( data?.type ) {
      case "Folder":
        spells = (await Folder.implementation.fromDropData(data))?.contents;
        break;
      case "Item":
        spells = [await Item.implementation.fromDropData(data)];
        break;
      default: return false;
    }

    const spellUuids = this.document.system.spells;
    spells = spells.filter(item => (item.type === "spell") && !spellUuids.has(item.uuid));
    if ( !spells.length ) return false;

    spells.forEach(i => spellUuids.add(i.uuid));
    await this.document.update({"system.spells": Array.from(spellUuids)});
  }
}

/**
 * Compendium that renders pages as a table of contents.
 */
class TableOfContentsCompendium extends foundry.applications.sidebar.apps.Compendium {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["table-of-contents"],
    window: {
      resizable: true,
      contentTag: "article"
    },
    position: {
      width: 800,
      height: 950
    },
    actions: {
      activateEntry: this.prototype._onClickLink
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    article: {
      root: true,
      template: "systems/dnd5e/templates/journal/table-of-contents.hbs"
    }
  };

  /* -------------------------------------------- */

  /**
   * Position of pages based on type.
   * @enum {number}
   */
  static TYPES = {
    chapter: 0,
    appendix: 100
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @override */
  _configureRenderParts(options) {
    // Skip normal compendium render parts logic.
    return foundry.utils.deepClone(this.constructor.PARTS);
  }

  /* -------------------------------------------- */

  /** @override */
  _createContextMenus() {
    this._createContextMenu(this._getEntryContextOptions, "[data-entry-id]", { fixed: true });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    new CONFIG.ux.DragDrop({
      dragSelector: "[data-document-id]",
      dropSelector: "article",
      permissions: {
        dragstart: this._canDragStart.bind(this),
        drop: this._canDragDrop.bind(this)
      },
      callbacks: {
        dragstart: this._onDragStart.bind(this),
        drop: this._onDrop.bind(this)
      }
    }).bind(this.element);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    const documents = await this.collection.getDocuments();

    context.chapters = [];
    const specialEntries = [];
    for ( const entry of documents ) {
      const flags = entry.flags?.dnd5e;
      if ( !flags ) continue;
      const keys = Object.keys(flags);
      if ( flags.tocHidden || !keys.length || ((keys.length === 1) && (keys[0] === "navigation")) ) continue;
      const type = flags.type ?? "chapter";

      if ( type === "header" ) {
        const page = entry.pages.contents[0];
        context.header = {
          title: flags.title ?? page?.name,
          content: page?.text.content
        };
        continue;
      }

      const data = {
        type, flags,
        id: entry.id,
        name: flags.title ?? entry.name,
        pages: Array.from(entry.pages).map(({ flags, id, name, sort }) => ({
          id, sort, flags,
          name: flags.dnd5e?.title ?? name,
          entryId: entry.id
        }))
      };

      if ( type === "special" ) {
        data.showPages = flags.showPages ?? !flags.append;
        specialEntries.push(data);
      } else {
        data.order = (this.constructor.TYPES[type] ?? 200) + (flags.position ?? 0);
        data.showPages = (flags.showPages !== false) && ((flags.showPages === true) || (type === "chapter"));
        context.chapters.push(data);
      }
    }

    context.chapters.sort((lhs, rhs) => lhs.order - rhs.order);
    for ( const entry of specialEntries ) {
      const append = entry.flags.append;
      const order = entry.flags.order;
      if ( append && (append <= context.chapters.length) ) {
        context.chapters[append - 1].pages.push({ ...entry, sort: order, entry: true });
      } else {
        context.chapters.push(entry);
      }
    }

    for ( const chapter of context.chapters ) {
      chapter.pages = chapter.pages
        .filter(p => !p.flags.tocHidden && (chapter.showPages || p.entry))
        .sort((lhs, rhs) => lhs.sort - rhs.sort);
      for ( const page of chapter.pages ) {
        if ( page.pages ) page.pages.sort((lhs, rhs) => lhs.sort - rhs.sort);
      }
    }

    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _renderFrame(options) {
    const frame = await super._renderFrame(options);
    frame.dataset.compendiumId = this.collection.metadata.id;
    return frame;
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /**
   * Handle clicking a link to a journal entry or page.
   * @param {PointerEvent} event  The triggering click event.
   * @param {HTMLElement} target  The action target.
   * @protected
   */
  async _onClickLink(event, target) {
    const entryId = target.closest("[data-entry-id]")?.dataset.entryId;
    if ( !entryId ) return;
    const entry = await this.collection.getDocument(entryId);
    entry?.sheet.render(true, {
      pageId: target.closest("[data-page-id]")?.dataset.pageId
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDragStart(event) {
    let dragData;
    if ( ui.context ) ui.context.close({animate: false});
    dragData = this._getEntryDragData(event.target.dataset.documentId);
    if ( !dragData ) return;
    event.dataTransfer.setData("text/plain", JSON.stringify(dragData));
  }
}

var _module$m = /*#__PURE__*/Object.freeze({
  __proto__: null,
  JournalClassPageSheet: JournalClassPageSheet,
  JournalEditor: JournalEditor,
  JournalEntrySheet5e: JournalEntrySheet5e,
  JournalMapLocationPageSheet: JournalMapLocationPageSheet,
  JournalRulePageSheet: JournalRulePageSheet,
  JournalSheet5e: JournalSheet5e,
  JournalSpellListPageSheet: JournalSpellListPageSheet,
  TableOfContentsCompendium: TableOfContentsCompendium
});

/**
 * Config sheet for the Difficult Terrain region behavior.
 */
class DifficultTerrainConfig extends foundry.applications.sheets.RegionBehaviorConfig {
  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _getFields() {
    const fieldsets = super._getFields();
    for ( const fieldset of fieldsets ) {
      const typesField = fieldset.fields.find(f => f.field.name === "types")?.field;
      if ( typesField ) {
        typesField.element.choices = CONFIG.DND5E.difficultTerrainTypes;
        break;
      }
    }
    return fieldsets;
  }
}

/**
 * Config sheet for the Rotate Area region behavior.
 */
class RotateAreaConfig extends foundry.applications.sheets.RegionBehaviorConfig {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    actions: {
      addPosition: RotateAreaConfig.#addPosition,
      deletePosition: RotateAreaConfig.#deletePosition,
      rotateToPosition: RotateAreaConfig.#rotateToPosition
    },
    classes: ["rotate-area"]
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    form: {
      template: "systems/dnd5e/templates/region-behaviors/rotate-area-config.hbs",
      scrollable: [""]
    },
    footer: {
      template: "templates/generic/form-footer.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    context.fields.forEach(fs => fs.fields.forEach(f => f.localize = true));
    context.positions = context.source.system.positions.map((data, index) => ({
      data,
      fields: this.document.system.schema.fields.positions.element.fields,
      label: `#${index}`
    }));
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _getFields() {
    const fieldsets = super._getFields();
    for ( const fieldset of fieldsets ) {
      fieldset.fields = fieldset.fields.filter(f => !f.field.options.hidden);
    }
    return fieldsets;
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle adding a new position.
   * @this {RotateAreaConfig}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static async #addPosition(event, target) {
    await this.submit();
    const positions = this.document.system.toObject().positions;
    let angle = 0;
    if ( positions.length > 1 ) {
      angle = positions.at(-1).angle + positions.at(-1).angle - positions.at(-2).angle;
    } else if ( positions.length === 1 ) angle = positions[0].angle + 90;
    this.document.update({ "system.positions": [...positions, { angle }] });
  }

  /* -------------------------------------------- */

  /**
   * Handle removing a position.
   * @this {RotateAreaConfig}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static async #deletePosition(event, target) {
    await this.submit();
    const position = Number(target.closest("[data-index]")?.dataset.index);
    this.document.update({ "system.positions": this.document.system.toObject().positions.toSpliced(position, 1) });
  }

  /* -------------------------------------------- */

  /**
   * Handle rotating to a specific position.
   * @this {RotateAreaConfig}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static async #rotateToPosition(event, target) {
    await this.submit();
    const position = Number(target.closest("[data-index]")?.dataset.index);
    this.document.system.rotateTo({ position });
  }

  /* -------------------------------------------- */
  /*  Form Handling                               */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _prepareSubmitData(event, form, formData, updateData) {
    const submitData = super._prepareSubmitData(event, form, formData, updateData);
    if ( submitData.system?.regions?.ids?.includes(this.document.parent.id) ) {
      throw new Error(game.i18n.localize("DND5E.REGIONBEHAVIORS.ROTATEAREA.Warning.RecursiveRegion"));
    }
    return submitData;
  }
}

var _module$l = /*#__PURE__*/Object.freeze({
  __proto__: null,
  DifficultTerrainConfig: DifficultTerrainConfig,
  RotateAreaConfig: RotateAreaConfig
});

/**
 * Generate sidebar links.
 * @returns {HTMLUListElement}
 * @private
 */
function _generateLinks() {
  const links = document.createElement("ul");
  links.classList.add("unlist", "links");
  links.innerHTML = `
    <li>
      <a href="https://github.com/foundryvtt/dnd5e/releases/latest" target="_blank">
        ${game.i18n.localize("DND5E.Notes")}
      </a>
    </li>
    <li>
      <a href="https://github.com/foundryvtt/dnd5e/issues" target="_blank">${game.i18n.localize("DND5E.Issues")}</a>
    </li>
    <li>
      <a href="https://github.com/foundryvtt/dnd5e/wiki" target="_blank">${game.i18n.localize("DND5E.Wiki")}</a>
    </li>
    <li>
      <a href="https://discord.com/channels/170995199584108546/670336046164213761" target="_blank">
        ${game.i18n.localize("DND5E.Discord")}
      </a>
    </li>
  `;
  return links;
}

/* -------------------------------------------- */

/**
 * Render a custom entry for game details in the settings sidebar.
 * @param {HTMLElement} html  The settings sidebar HTML.
 */
function renderSettings(html) {
  const pip = html.querySelector(".info .system .notification-pip");
  html.querySelector(".info .system").remove();

  const section = document.createElement("section");
  section.classList.add("dnd5e2", "sidebar-info");
  section.innerHTML = `
    <h4 class="divider">${game.i18n.localize("WORLD.FIELDS.system.label")}</h4>
    <div class="system-badge">
      <img src="systems/dnd5e/ui/official/dnd-badge-32.webp" data-tooltip="${dnd5e.title}" alt="${dnd5e.title}">
      <span class="system-info">${dnd5e.version}</span>
    </div>
  `;
  section.append(_generateLinks());
  if ( pip ) section.querySelector(".system-info").insertAdjacentElement("beforeend", pip);
  html.querySelector(".info").insertAdjacentElement("afterend", section);
}

var sidebar = /*#__PURE__*/Object.freeze({
  __proto__: null,
  renderSettings: renderSettings
});

var _module$k = /*#__PURE__*/Object.freeze({
  __proto__: null,
  BaseSettingsConfig: BaseSettingsConfig,
  BastionSettingsConfig: BastionSettingsConfig,
  CalendarSettingsConfig: CalendarSettingsConfig,
  CompendiumBrowserSettingsConfig: CompendiumBrowserSettingsConfig,
  ModuleArtSettingsConfig: ModuleArtSettingsConfig,
  VisibilitySettingsConfig: VisibilitySettingsConfig,
  sidebar: sidebar
});

var _module$j = /*#__PURE__*/Object.freeze({
  __proto__: null,
  CreatureTypeConfig: CreatureTypeConfig,
  MovementSensesConfig: MovementSensesConfig,
  SourceConfig: SourceConfig
});

/**
 * @typedef {object} AccordionConfiguration
 * @property {string} headingSelector    The CSS selector that identifies accordion headers in the given markup.
 * @property {string} contentSelector    The CSS selector that identifies accordion content in the given markup. This
 *                                       can match content within the heading element, or sibling to the heading
 *                                       element, with priority given to the former.
 * @property {boolean} [collapseOthers]  Automatically collapses the other headings in this group when one heading is
 *                                       clicked.
 */

/**
 * A class responsible for augmenting markup with an accordion effect.
 * @param {AccordionConfiguration} config  Configuration options.
 */
class Accordion {
  constructor(config) {
    foundry.utils.logCompatibilityWarning(
      "The `Accordion` class is no longer used in favor of expanded sections provided by the `ApplicationV2Mixin`.",
      { since: "DnD5e 5.2", until: "DnD5e 5.4" }
    );
    config.contentSelector = `${config.contentSelector}:not(.accordion-content)`;
    this.#config = config;
  }

  /**
   * Configuration options.
   * @type {AccordionConfiguration}
   */
  #config;

  /**
   * A mapping of heading elements to content elements.
   * @type {Map<HTMLElement, HTMLElement>}
   */
  #sections = new Map();

  /**
   * A mapping of heading elements to any ongoing transition effect functions.
   * @type {Map<HTMLElement, Function>}
   */
  #ongoing = new Map();

  /**
   * Record the state of collapsed sections.
   * @type {boolean[]}
   */
  #collapsed;

  /* -------------------------------------------- */

  /**
   * Augment the given markup with an accordion effect.
   * @param {HTMLElement} root  The root HTML node.
   */
  bind(root) {
    const firstBind = this.#sections.size < 1;
    if ( firstBind ) this.#collapsed = [];
    this.#sections = new Map();
    this.#ongoing = new Map();
    const { headingSelector, contentSelector } = this.#config;
    let collapsedIndex = 0;
    for ( const heading of root.querySelectorAll(headingSelector) ) {
      const content = heading.querySelector(contentSelector) ?? heading.parentElement.querySelector(contentSelector);
      if ( !content ) continue;
      const wrapper = document.createElement("div");
      wrapper.classList.add("accordion");
      heading.before(wrapper);
      wrapper.append(heading, content);
      this.#sections.set(heading, content);
      content._fullHeight = content.getBoundingClientRect().height;
      if ( firstBind ) this.#collapsed.push(this.#collapsed.length > 0);
      else if ( this.#collapsed[collapsedIndex] ) wrapper.classList.add("collapsed");
      heading.classList.add("accordion-heading");
      content.classList.add("accordion-content");
      heading.addEventListener("click", this._onClickHeading.bind(this));
      collapsedIndex++;
    }
    requestAnimationFrame(() => this._restoreCollapsedState());
  }

  /* -------------------------------------------- */

  /**
   * Handle clicking an accordion heading.
   * @param {PointerEvent} event  The triggering event.
   * @protected
   */
  _onClickHeading(event) {
    if ( event.target.closest("a") ) return;
    const heading = event.currentTarget;
    const content = this.#sections.get(heading);
    if ( !content ) return;
    event.preventDefault();
    const collapsed = heading.parentElement.classList.contains("collapsed");
    if ( collapsed ) this._onExpandSection(heading, content);
    else this._onCollapseSection(heading, content);
  }

  /* -------------------------------------------- */

  /**
   * Handle expanding a section.
   * @param {HTMLElement} heading             The section heading.
   * @param {HTMLElement} content             The section content.
   * @param {object} [options]
   * @param {boolean} [options.animate=true]  Whether to animate the expand effect.
   * @protected
   */
  _onExpandSection(heading, content, { animate=true }={}) {
    this.#cancelOngoing(heading);

    if ( this.#config.collapseOthers ) {
      for ( const [otherHeading, otherContent] of this.#sections.entries() ) {
        if ( (heading !== otherHeading) && !otherHeading.parentElement.classList.contains("collapsed") ) {
          this._onCollapseSection(otherHeading, otherContent, { animate });
        }
      }
    }

    heading.parentElement.classList.remove("collapsed");
    if ( animate ) content.style.height = "0";
    else {
      content.style.height = `${content._fullHeight}px`;
      return;
    }
    requestAnimationFrame(() => {
      const onEnd = this._onEnd.bind(this, heading, content);
      this.#ongoing.set(heading, onEnd);
      content.addEventListener("transitionend", onEnd, { once: true });
      content.style.height = `${content._fullHeight}px`;
    });
  }

  /* -------------------------------------------- */

  /**
   * Handle collapsing a section.
   * @param {HTMLElement} heading             The section heading.
   * @param {HTMLElement} content             The section content.
   * @param {object} [options]
   * @param {boolean} [options.animate=true]  Whether to animate the collapse effect.
   * @protected
   */
  _onCollapseSection(heading, content, { animate=true }={}) {
    this.#cancelOngoing(heading);
    const { height } = content.getBoundingClientRect();
    heading.parentElement.classList.add("collapsed");
    content._fullHeight = height || content._fullHeight;
    if ( animate ) content.style.height = `${height}px`;
    else {
      content.style.height = "0";
      return;
    }
    requestAnimationFrame(() => {
      const onEnd = this._onEnd.bind(this, heading, content);
      this.#ongoing.set(heading, onEnd);
      content.addEventListener("transitionend", onEnd, { once: true });
      content.style.height = "0";
    });
  }

  /* -------------------------------------------- */

  /**
   * A function to invoke when the height transition has ended.
   * @param {HTMLElement} heading  The section heading.
   * @param {HTMLElement} content  The section content.
   * @protected
   */
  _onEnd(heading, content) {
    content.style.height = "";
    this.#ongoing.delete(heading);
  }

  /* -------------------------------------------- */

  /**
   * Cancel an ongoing effect.
   * @param {HTMLElement} heading  The section heading.
   */
  #cancelOngoing(heading) {
    const ongoing = this.#ongoing.get(heading);
    const content = this.#sections.get(heading);
    if ( ongoing && content ) content.removeEventListener("transitionend", ongoing);
  }

  /* -------------------------------------------- */

  /**
   * Save the accordion state.
   * @protected
   */
  _saveCollapsedState() {
    this.#collapsed = [];
    for ( const heading of this.#sections.keys() ) {
      this.#collapsed.push(heading.parentElement.classList.contains("collapsed"));
    }
  }

  /* -------------------------------------------- */

  /**
   * Restore the accordion state.
   * @protected
   */
  _restoreCollapsedState() {
    const entries = Array.from(this.#sections.entries());
    for ( let i = 0; i < entries.length; i++ ) {
      const collapsed = this.#collapsed[i];
      const [heading, content] = entries[i];
      if ( collapsed ) this._onCollapseSection(heading, content, { animate: false });
    }
  }
}

const { DiceTerm: DiceTerm$1, NumericTerm: NumericTerm$1 } = foundry.dice.terms;

/**
 * @import {
 *   BasicRollConfiguration, BasicRollDialogConfiguration, BasicRollMessageConfiguration,
 *   BasicRollOptions, BasicRollProcessConfiguration
 * } from "./_types.mjs";
 */

/**
 * Custom base roll type with methods for building rolls, presenting prompts, and creating messages.
 */
class BasicRoll extends Roll {

  /**
   * Default application used for the roll configuration prompt.
   * @type {typeof RollConfigurationDialog}
   */
  static DefaultConfigurationDialog = RollConfigurationDialog;

  /* -------------------------------------------- */
  /*  Static Construction                         */
  /* -------------------------------------------- */

  /**
   * Create a roll instance from a roll config.
   * @param {BasicRollConfiguration} config          Configuration info for the roll.
   * @param {BasicRollProcessConfiguration} process  Configuration info for the whole rolling process.
   * @returns {BasicRoll}
   */
  static fromConfig(config, process) {
    const formula = (config.parts ?? []).join(" + ");
    config.options ??= {};
    config.options.target ??= process.target;
    return new this(formula, config.data, config.options);
  }

  /* -------------------------------------------- */

  /**
   * Construct roll parts and populate its data object.
   * @param {object} parts   Information on the parts to be constructed.
   * @param {object} [data]  Roll data to use and populate while constructing the parts.
   * @returns {{ parts: string[], data: object }}
   */
  static constructParts(parts, data={}) {
    const finalParts = [];
    for ( const [key, value] of Object.entries(parts) ) {
      if ( !value && (value !== 0) ) continue;
      finalParts.push(`@${key}`);
      foundry.utils.setProperty(
        data, key, foundry.utils.getType(value) === "string" ? BasicRoll.replaceFormulaData(value, data) : value
      );
    }
    return { parts: finalParts, data };
  }

  /* -------------------------------------------- */

  /**
   * Construct and perform a roll through the standard workflow.
   * @param {BasicRollProcessConfiguration} [config={}]   Configuration for the rolls.
   * @param {BasicRollDialogConfiguration} [dialog={}]    Configuration for roll prompt.
   * @param {BasicRollMessageConfiguration} [message={}]  Configuration for message creation.
   * @returns {BasicRoll[]}
   */
  static async build(config={}, dialog={}, message={}) {
    const rolls = await this.buildConfigure(config, dialog, message);
    await this.buildEvaluate(rolls, config, message);
    await this.buildPost(rolls, config, message);
    return rolls;
  }

  /* -------------------------------------------- */

  /**
   * Stage one of the standard rolling workflow, configuring the roll.
   * @param {BasicRollProcessConfiguration} [config={}]   Configuration for the rolls.
   * @param {BasicRollDialogConfiguration} [dialog={}]    Configuration for roll prompt.
   * @param {BasicRollMessageConfiguration} [message={}]  Configuration for message creation.
   * @returns {Promise<BasicRoll[]>}
   */
  static async buildConfigure(config={}, dialog={}, message={}) {
    config.hookNames = [...(config.hookNames ?? []), ""];

    /**
     * A hook event that fires before a roll is performed. Multiple hooks may be called depending on the rolling
     * method (e.g. `dnd5e.preRollSkill`, `dnd5e.preRollAbilityCheck`, `dnd5e.preRoll`). Exact contents of the
     * configuration object will also change based on the roll type, but the same objects will always be present.
     * @function dnd5e.preRoll
     * @memberof hookEvents
     * @param {BasicRollProcessConfiguration} config   Configuration data for the pending roll.
     * @param {BasicRollDialogConfiguration} dialog    Presentation data for the roll configuration dialog.
     * @param {BasicRollMessageConfiguration} message  Configuration data for the roll's message.
     * @returns {boolean}                              Explicitly return `false` to prevent the roll.
     */
    for ( const hookName of config.hookNames ) {
      if ( Hooks.call(`dnd5e.preRoll${hookName.capitalize()}`, config, dialog, message) === false ) return [];
      if ( Hooks.call(`dnd5e.preRoll${hookName.capitalize()}V2`, config, dialog, message) === false ) return [];
    }

    this.applyKeybindings(config, dialog, message);

    let rolls;
    if ( dialog.configure === false ) {
      rolls = config.rolls?.map((r, index) => {
        dialog.options?.buildConfig?.(config, r, null, index);
        for ( const hookName of config.hookNames ) {
          Hooks.callAll(`dnd5e.postBuild${hookName.capitalize()}RollConfig`, config, r, index);
        }
        return this.fromConfig(r, config);
      }) ?? [];
    } else {
      const DialogClass = dialog.applicationClass ?? this.DefaultConfigurationDialog;
      rolls = await DialogClass.configure(config, dialog, message);
    }

    // Store the roll type in roll.options so it can be accessed from only the roll
    const rollType = foundry.utils.getProperty(message, "data.flags.dnd5e.roll.type");
    if ( rollType ) rolls.forEach(roll => roll.options.rollType ??= rollType);

    /**
     * A hook event that fires after roll configuration is complete, but before the roll is evaluated.
     * Multiple hooks may be called depending on the rolling method (e.g. `dnd5e.postSkillCheckRollConfiguration`,
     * `dnd5e.postAbilityTestRollConfiguration`, and `dnd5e.postRollConfiguration` for skill checks). Exact contents of
     * the configuration object will also change based on the roll type, but the same objects will always be present.
     * @function dnd5e.postRollConfiguration
     * @memberof hookEvents
     * @param {BasicRoll[]} rolls                      Rolls that have been constructed but not evaluated.
     * @param {BasicRollProcessConfiguration} config   Configuration information for the roll.
     * @param {BasicRollDialogConfiguration} dialog    Configuration for the roll dialog.
     * @param {BasicRollMessageConfiguration} message  Configuration for the roll message.
     * @returns {boolean}                              Explicitly return `false` to prevent rolls.
     */
    for ( const hookName of config.hookNames ) {
      const name = `dnd5e.post${hookName.capitalize()}RollConfiguration`;
      if ( Hooks.call(name, rolls, config, dialog, message) === false ) return [];
    }

    return rolls;
  }

  /* -------------------------------------------- */

  /**
   * Stage two of the standard rolling workflow, evaluating the rolls.
   * @param {BasicRoll[]} rolls                           Rolls to evaluate.
   * @param {BasicRollProcessConfiguration} [config={}]   Configuration for the rolls.
   * @param {BasicRollMessageConfiguration} [message={}]  Configuration for message creation.
   */
  static async buildEvaluate(rolls, config={}, message={}) {
    if ( config.evaluate !== false ) {
      for ( const roll of rolls ) await roll.evaluate();
    }
  }

  /* -------------------------------------------- */

  /**
   * Stage three of the standard rolling workflow, posting a message to chat.
   * @param {BasicRoll[]} rolls                      Rolls to evaluate.
   * @param {BasicRollProcessConfiguration} config   Configuration for the rolls.
   * @param {BasicRollMessageConfiguration} message  Configuration for message creation.
   * @returns {ChatMessage5e|void}
   */
  static async buildPost(rolls, config, message) {
    message.data = foundry.utils.expandObject(message.data ?? {});
    const messageId = config.event?.target.closest("[data-message-id]")?.dataset.messageId;
    if ( messageId ) foundry.utils.setProperty(message.data, "flags.dnd5e.originatingMessage", messageId);

    if ( rolls?.length && (config.evaluate !== false) ) {
      message[message.create !== false ? "document" : "data"] = await this.toMessage(
        rolls, message.data, { create: message.create, rollMode: message.rollMode }
      );
      if ( message.document ) rolls.forEach(r => r.parent = message.document);
    }

    return message.document;
  }

  /* -------------------------------------------- */

  /**
   * Determines whether the roll process should be fast forwarded.
   * @param {BasicRollProcessConfiguration} config   Roll configuration data.
   * @param {BasicRollDialogConfiguration} dialog    Data for the roll configuration dialog.
   * @param {BasicRollMessageConfiguration} message  Message configuration data.
   */
  static applyKeybindings(config, dialog, message) {
    dialog.configure ??= true;
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Is the result of this roll a failure? Returns `undefined` if roll isn't evaluated.
   * @type {boolean|void}
   */
  get isFailure() {
    if ( !this._evaluated ) return;
    if ( !Number.isNumeric(this.options.target) ) return false;
    return this.total < this.options.target;
  }

  /* -------------------------------------------- */

  /**
   * Is the result of this roll a success? Returns `undefined` if roll isn't evaluated.
   * @type {boolean|void}
   */
  get isSuccess() {
    if ( !this._evaluated ) return;
    if ( !Number.isNumeric(this.options.target) ) return false;
    return this.total >= this.options.target;
  }

  /* -------------------------------------------- */
  /*  Chat Messages                               */
  /* -------------------------------------------- */

  /**
   * Transform a Roll instance into a ChatMessage, displaying the roll result.
   * This function can either create the ChatMessage directly, or return the data object that will be used to create it.
   *
   * @param {BasicRoll[]} rolls              Rolls to add to the message.
   * @param {object} messageData             The data object to use when creating the message.
   * @param {options} [options]              Additional options which modify the created message.
   * @param {string} [options.rollMode]      The template roll mode to use for the message from CONFIG.Dice.rollModes
   * @param {boolean} [options.create=true]  Whether to automatically create the chat message, or only return the
   *                                         prepared chatData object.
   * @returns {Promise<ChatMessage|object>}  A promise which resolves to the created ChatMessage document if create is
   *                                         true, or the Object of prepared chatData otherwise.
   */
  static async toMessage(rolls, messageData={}, { rollMode, create=true }={}) {
    for ( const roll of rolls ) {
      if ( !roll._evaluated ) await roll.evaluate({ allowInteractive: rollMode !== CONST.DICE_ROLL_MODES.BLIND });
      rollMode ??= roll.options.rollMode;
    }
    rollMode ??= game.settings.get("core", "rollMode");

    // Prepare chat data
    messageData = foundry.utils.mergeObject({ sound: CONFIG.sounds.dice }, messageData);
    this._prepareMessageData(rolls, messageData);
    messageData.rolls = rolls.map(r => this.fromData(r.toJSON()));

    // Process the chat data
    const cls = getDocumentClass("ChatMessage");
    const msg = new cls(messageData);

    // Either create or return the data
    if ( create ) return cls.create(msg.toObject(), { rollMode });
    else {
      if ( rollMode ) msg.applyRollMode(rollMode);
      return msg.toObject();
    }
  }

  /* -------------------------------------------- */

  /**
   * Perform specific changes to message data before creating message.
   * @param {BasicRoll[]} rolls   Rolls to add to the message.
   * @param {object} messageData  The data object to use when creating the message.
   * @protected
   */
  static _prepareMessageData(rolls, messageData) {}

  /* -------------------------------------------- */
  /*  Evaluate Methods                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async evaluate(options={}) {
    this.preCalculateDiceTerms(options);
    return super.evaluate(options);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  evaluateSync(options={}) {
    this.preCalculateDiceTerms(options);
    return super.evaluateSync(options);
  }

  /* -------------------------------------------- */
  /*  Roll Formula Parsing                        */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static replaceFormulaData(formula, data, options) {
    // This looks for the pattern `$!!$` and replaces it with just the value between the marks (the bang has
    // been added to ensure this is a deliberate shim from the system, not a unintentional usage that should
    // show an error).
    return super.replaceFormulaData(formula, data, options).replaceAll(/\$"?!(.+?)!"?\$/g, "$1");
  }

  /* -------------------------------------------- */
  /*  Inversion Methods                           */
  /* -------------------------------------------- */

  /**
   * Invert the roll's formula, resulting in a formula whose total, if multiplied by -1, will be the same as
   * the evaluated total of the original formula.
   * @returns {BasicRoll}
   */
  invert() {
    // Add "0 +" to the start of formulas that don't begin with a numeric term
    if ( !(this.terms[0] instanceof foundry.dice.terms.NumericTerm) ) this.terms.unshift(
      new foundry.dice.terms.NumericTerm({ number: 0 }),
      new foundry.dice.terms.OperatorTerm({ operator: "+" })
    );

    // Otherwise remove "0 -" from formulas that start with that
    else if ( (this.terms[0]?.number === 0) && (this.terms[1]?.operator === "-") ) this.terms.splice(0, 2);

    // Starting numeric terms should be directly inverted
    if ( this.terms[0] instanceof foundry.dice.terms.NumericTerm ) this.terms[0].number *= -1;

    // Invert all addition & subtraction operators
    this.terms = this.terms.map((term, index) => {
      if ( term instanceof foundry.dice.terms.OperatorTerm ) {
        if ( term.operator === "+" ) term.operator = "-";
        else if ( term.operator === "-" ) term.operator = "+";
      }
      return term;
    });

    if ( this._evaluated ) this._total *= -1;
    this.resetFormula();
    return this;
  }

  /* -------------------------------------------- */
  /*  Maximize/Minimize Methods                   */
  /* -------------------------------------------- */

  /**
   * Replaces all dice terms that have modifiers with their maximum/minimum value.
   *
   * @param {object} [options={}]            Extra optional arguments which describe or modify the BasicRoll.
   */
  preCalculateDiceTerms(options={}) {
    if ( this._evaluated || (!options.maximize && !options.minimize) ) return;
    this.terms = this.terms.map(term => {
      if ( (term instanceof DiceTerm$1) && term.modifiers.length ) {
        const minimize = !options.maximize;
        const number = this.constructor.preCalculateTerm(term, { minimize });
        if ( Number.isFinite(number) ) return new NumericTerm$1({ number, options: term.options });
      }
      return term;
    });
  }

  /* -------------------------------------------- */

  /**
   * Gets information from passed die and calculates the maximum or minimum value that could be rolled.
   *
   * @param {DiceTerm} die                            DiceTerm to get the maximum/minimum value.
   * @param {object} [preCalculateOptions={}]         Additional options to modify preCalculate functionality.
   * @param {boolean} [preCalculateOptions.minimize=false]  Calculate the minimum value instead of the maximum.
   * @returns {number|null}                                 Maximum/Minimum value that could be rolled as an integer, or
   *                                                        null if the modifiers could not be precalculated.
   */
  static preCalculateTerm(die, { minimize=false }={}) {
    let face = minimize ? 1 : die.faces;
    let number = die.number;
    const currentModifiers = foundry.utils.deepClone(die.modifiers);
    const keep = new Set(["k", "kh", "kl"]);
    const drop = new Set(["d", "dh", "dl"]);
    const validModifiers = new Set([...keep, ...drop, "max", "min"]);
    let matchedModifier = false;

    for ( const modifier of currentModifiers ) {
      const rgx = /(m[ai][xn]|[kd][hl]?)(\d+)?/i;
      const match = modifier.match(rgx);
      if ( !match ) continue;
      if ( match[0].length < match.input.length ) currentModifiers.push(match.input.slice(match[0].length));
      let [, command, value] = match;
      command = command.toLowerCase();
      if ( !validModifiers.has(command) ) continue;

      matchedModifier = true;
      const amount = parseInt(value) || (command === "max" || command === "min" ? -1 : 1);
      if ( amount > 0 ) {
        if ( (command === "max" && minimize) || (command === "min" && !minimize) ) continue;
        else if ( (command === "max" || command === "min") ) face = Math.min(die.faces, amount);
        else if ( keep.has(command) ) number = Math.min(number, amount);
        else if ( drop.has(command) ) number = Math.max(1, number - amount);
      }
    }

    return matchedModifier ? face * number : null;
  }

  /* -------------------------------------------- */
  /*  Simplification Methods                      */
  /* -------------------------------------------- */

  /**
   * Replace number and faces of dice terms with numeric values where possible.
   */
  simplify() {
    for ( const die of this.dice ) {
      const n = die._number;
      if ( (n instanceof BasicRoll) && n.isDeterministic ) die._number = n.evaluateSync().total;
      const f = die._faces;
      if ( (f instanceof BasicRoll) && f.isDeterministic ) die._faces = f.evaluateSync().total;

      // Preserve flavor.
      if ( f.terms?.[0]?.flavor ) die.options.flavor = f.terms[0].flavor;
    }

    this.resetFormula();
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Merge two roll configurations in place.
   * @param {Partial<BasicRollConfiguration>} original  The initial configuration that will be merged into.
   * @param {Partial<BasicRollConfiguration>} other     The configuration to merge.
   * @returns {Partial<BasicRollConfiguration>}         The original instance.
   */
  static mergeConfigs(original, other={}) {
    if ( other.data ) {
      original.data ??= {};
      Object.assign(original.data, other.data);
    }

    if ( other.parts?.length ) {
      original.parts ??= [];
      original.parts.unshift(...other.parts);
    }

    if ( other.options ) {
      original.options = this.mergeOptions(original.options, other.options);
    }

    return original;
  }

  /* -------------------------------------------- */

  /**
   * Merge two roll options objects.
   * @param {Partial<BasicRollOptions>} [original]  The initial options that will be merged into.
   * @param {Partial<BasicRollOptions>} [other]     The options to merge.
   * @returns {Partial<BasicRollOptions>}           The merged version.
   */
  static mergeOptions(original={}, other={}) {
    return foundry.utils.mergeObject(original, other, { inplace: false });
  }
}

const { DiceTerm, FunctionTerm, NumericTerm, OperatorTerm, ParentheticalTerm, StringTerm } = foundry.dice.terms;

/**
 * @import { CriticalDamageConfiguration, DamageRollOptions } from "./_types.mjs";
 */

/**
 * A type of Roll specific to a damage (or healing) roll in the 5e system.
 * @extends {BasicRoll}
 */
class DamageRoll extends BasicRoll {
  /**
   * @param {string} formula                  The string formula to parse.
   * @param {object} data                     The data object against which to parse attributes within the formula.
   * @param {DamageRollOptions} [options={}]  Extra optional arguments which describe or modify the DamageRoll.
   */
  constructor(formula, data, options) {
    super(formula, data, options);
    if ( !this.options.preprocessed ) this.preprocessFormula();
    if ( !this.options.configured ) this.configureDamage();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static DefaultConfigurationDialog = DamageRollConfigurationDialog;

  /* -------------------------------------------- */
  /*  Static Construction                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static fromConfig(config, process) {
    if ( process.critical ) {
      config = foundry.utils.deepClone(config);
      config.options ??= {};
      config.options.critical = foundry.utils.mergeObject(
        process.critical, config.options.critical ?? {}, { inplace: false }
      );
    }
    return super.fromConfig(config, process);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static async build(config={}, dialog={}, message={}) {
    config.critical ??= {};
    config.critical.multiplyNumeric ??= game.settings.get("dnd5e", "criticalDamageModifiers");
    config.critical.powerfulCritical ??= game.settings.get("dnd5e", "criticalDamageMaxDice");
    return super.build(config, dialog, message);
  }

  /* -------------------------------------------- */

  /** @override */
  static applyKeybindings(config, dialog, message) {
    const keys = {
      default: areKeysPressed(config.event, "skipDialogNormal"),
      normal: areKeysPressed(config.event, "skipDialogDisadvantage"),
      critical: areKeysPressed(config.event, "skipDialogAdvantage")
    };

    // Should the roll configuration dialog be displayed?
    dialog.configure ??= Object.values(keys).every(k => !k);

    // Determine critical mode
    config.isCritical ||= keys.critical;
    config.isCritical &&= !keys.normal;
    for ( const roll of config.rolls ) {
      roll.options ??= {};
      roll.options.isCritical ??= config.isCritical;
    }
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Is this damage critical.
   * @type {boolean}
   */
  get isCritical() {
    return this.options.isCritical === true;
  }

  /* -------------------------------------------- */
  /*  Roll Configuration                          */
  /* -------------------------------------------- */

  /**
   * 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 FunctionTerm) && (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.resetFormula();

    // Mark configuration as complete
    this.options.preprocessed = true;
  }

  /* -------------------------------------------- */

  /**
   * Apply optional modifiers which customize the behavior of the d20term.
   * @param {object} [options={}]
   * @param {CriticalDamageConfiguration} [options.critical={}]  Critical configuration to take into account, will be
   *                                                             superseded by the roll's configuration.
   * @protected
   */
  configureDamage({ critical={} }={}) {
    critical = foundry.utils.mergeObject(critical, this.options.critical ?? {}, { inplace: false });

    // Remove previous critical bonus damage
    this.terms = this.terms.filter(t => !t.options.criticalBonusDamage && !t.options.criticalFlatBonus);

    const flatBonus = new Map();
    for ( let [i, term] of this.terms.entries() ) {
      // Multiply dice terms
      if ( term instanceof DiceTerm ) {
        if ( term._number instanceof Roll ) {
          // Complex number term.
          if ( !term._number.isDeterministic ) continue;
          if ( !term._number._evaluated ) term._number.evaluateSync();
        }
        term.options.baseNumber = term.options.baseNumber ?? term.number; // Reset back
        term.number = term.options.baseNumber;
        if ( this.isCritical ) {
          let cm = critical.multiplier ?? 2;

          // Powerful critical - maximize damage and reduce the multiplier by 1
          if ( critical.powerfulCritical ) {
            const bonus = Roll.create(term.formula).evaluateSync({ maximize: true }).total;
            if ( bonus > 0 ) {
              const flavor = term.flavor?.toLowerCase().trim() ?? game.i18n.localize("DND5E.PowerfulCritical");
              flatBonus.set(flavor, (flatBonus.get(flavor) ?? 0) + bonus);
            }
            cm = Math.max(1, cm-1);
          }

          // Alter the damage term
          let cb = (critical.bonusDice && (i === 0)) ? critical.bonusDice : 0;
          term.alter(cm, cb);
          term.options.critical = true;
        }
      }

      else if ( term instanceof NumericTerm ) {
        // Multiply numeric terms
        if ( critical.multiplyNumeric ) {
          term.options.baseNumber = term.options.baseNumber ?? term.number; // Reset back
          term.number = term.options.baseNumber;
          if ( this.isCritical ) {
            term.number *= (critical.multiplier ?? 2);
            term.options.critical = true;
          }
        }
      }
    }

    // Add powerful critical bonus
    if ( critical.powerfulCritical && flatBonus.size ) {
      for ( const [type, number] of flatBonus.entries() ) {
        this.terms.push(new OperatorTerm({ operator: "+", options: { criticalFlatBonus: true } }));
        this.terms.push(new NumericTerm({ number, options: { flavor: type, criticalFlatBonus: true } }));
      }
    }

    // Add extra critical damage term
    if ( this.isCritical && critical.bonusDamage ) {
      let extraTerms = new Roll(critical.bonusDamage, this.data).terms;
      if ( !(extraTerms[0] instanceof OperatorTerm) ) extraTerms.unshift(new OperatorTerm({ operator: "+" }));
      extraTerms.forEach(t => t.options.criticalBonusDamage = true);
      this.terms.push(...extraTerms);
    }

    // Re-compile the underlying formula
    this.resetFormula();

    // Mark configuration as complete
    this.options.configured = true;
  }
}

class ChatMessage5e extends ChatMessage {

  /**
   * HTML tag names for chat trays that can open and close.
   * @type {string[]}
   */
  static TRAY_TYPES = ["damage-application", "effect-application"];

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The currently highlighted token for attack roll evaluation.
   * @type {Token5e|null}
   */
  _highlighted = null;

  /* -------------------------------------------- */

  /**
   * Should the apply damage options appear?
   * @type {boolean}
   */
  get canApplyDamage() {
    const type = this.flags.dnd5e?.roll?.type;
    if ( type && (type !== "damage") ) return false;
    return this.isRoll && this.isContentVisible && !!canvas.tokens?.controlled.length;
  }

  /* -------------------------------------------- */

  /**
   * Should the select targets options appear?
   * @type {boolean}
   */
  get canSelectTargets() {
    if ( this.flags.dnd5e?.roll?.type !== "attack" ) return false;
    return this.isRoll && this.isContentVisible;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  get isRoll() {
    if ( this.system?.isRoll !== undefined ) return this.system.isRoll;
    return super.isRoll && !this.flags.dnd5e?.rest;
  }

  /* -------------------------------------------- */

  /**
   * Should roll DCs and other challenge details be displayed on this card?
   * @type {boolean}
   */
  get shouldDisplayChallenge() {
    if ( game.user.isGM || (this.author === game.user) ) return true;
    switch ( game.settings.get("dnd5e", "challengeVisibility") ) {
      case "all": return true;
      case "player": return !this.author?.isGM;
      default: return false;
    }
  }

  /* -------------------------------------------- */

  /**
   * Store the state of any trays in the message.
   * @type {Map<string, boolean>}
   * @protected
   */
  _trayStates;

  /* -------------------------------------------- */
  /*  Data Migration                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static migrateData(source) {
    source = super.migrateData(source);
    if ( foundry.utils.hasProperty(source, "flags.dnd5e.itemData") ) {
      foundry.utils.setProperty(source, "flags.dnd5e.item.data", source.flags.dnd5e.itemData);
      delete source.flags.dnd5e.itemData;
    }
    if ( foundry.utils.hasProperty(source, "flags.dnd5e.use") ) {
      const use = source.flags.dnd5e.use;
      foundry.utils.setProperty(source, "flags.dnd5e.messageType", "usage");
      if ( use.type ) foundry.utils.setProperty(source, "flags.dnd5e.item.type", use.type);
      if ( use.itemId ) foundry.utils.setProperty(source, "flags.dnd5e.item.id", use.itemId);
      if ( use.itemUuid ) foundry.utils.setProperty(source, "flags.dnd5e.item.uuid", use.itemUuid);
    }
    return source;
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareData() {
    super.prepareData();
    if ( !this.flags.dnd5e?.item?.data && this.flags.dnd5e?.item?.id ) {
      const itemData = this.getFlag("dnd5e", "use.consumed.deleted")?.find(i => i._id === this.flags.dnd5e.item.id);
      if ( itemData ) Object.defineProperty(this.flags.dnd5e.item, "data", { value: itemData });
    }
    dnd5e.registry.messages.track(this);
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async renderHTML(options={}) {
    const html = await super.renderHTML(options);

    if ( foundry.utils.getType(this.system?.getHTML) === "function" ) {
      await this.system.getHTML(html, options);
      return html;
    }

    this._displayChatActionButtons(html);
    this._highlightCriticalSuccessFailure(html);
    if ( game.settings.get("dnd5e", "autoCollapseItemCards") ) {
      html.querySelectorAll(".description.collapsible").forEach(el => el.classList.add("collapsed"));
    }

    await this._enrichChatCard(html);
    this._collapseTrays(html);
    this._activateActivityListeners(html);
    dnd5e.bastion._activateChatListeners(this, html);

    /**
     * A hook event that fires after dnd5e-specific chat message modifications have completed.
     * @function dnd5e.renderChatMessage
     * @memberof hookEvents
     * @param {ChatMessage5e} message  Chat message being rendered.
     * @param {HTMLElement} html       HTML contents of the message.
     */
    Hooks.callAll("dnd5e.renderChatMessage", this, html);

    return html;
  }

  /* -------------------------------------------- */

  /**
   * Handle collapsing or expanding trays depending on user settings.
   * @param {HTMLElement} html  Rendered contents of the message.
   */
  _collapseTrays(html) {
    let collapse;
    switch ( game.settings.get("dnd5e", "autoCollapseChatTrays") ) {
      case "always": collapse = true; break;
      case "never":
      case "manual": collapse = false; break;
      // Collapse chat message trays older than 5 minutes
      case "older": collapse = this.timestamp < Date.now() - (5 * 60 * 1000); break;
    }
    for ( const tray of html.querySelectorAll(".card-tray") ) {
      tray.classList.toggle("collapsed", this._trayStates?.get(tray.className.replace(" collapsed", "")) ?? collapse);
    }
    for ( const element of html.querySelectorAll(this.constructor.TRAY_TYPES.join(", ")) ) {
      element.toggleAttribute("open", this._trayStates?.get(element.tagName) ?? !collapse);
    }
  }

  /* -------------------------------------------- */

  /**
   * Optionally hide the display of chat card action buttons which cannot be performed by the user
   * @param {HTMLElement} html  Rendered contents of the message.
   * @protected
   */
  _displayChatActionButtons(html) {
    const chatCard = html.querySelector(".chat-card");
    if ( chatCard ) {
      const flavor = html.querySelector(".flavor-text");
      if ( flavor?.innerText === html.querySelector(".item-name")?.innerText ) flavor?.remove();

      if ( this.shouldDisplayChallenge ) chatCard.dataset.displayChallenge = "";

      const actor = game.actors.get(this.speaker.actor);
      const isCreator = game.user.isGM || actor?.isOwner || (this.author?.id === game.user.id);
      for ( const button of html.querySelectorAll(".card-buttons button") ) {
        if ( button.dataset.visibility === "all" ) continue;

        // GM buttons should only be visible to GMs, otherwise button should only be visible to message's creator
        if ( ((button.dataset.visibility === "gm") && !game.user.isGM) || !isCreator
          || this.getAssociatedActivity()?.shouldHideChatButton(button, this) ) button.hidden = true;
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Highlight critical success or failure on d20 rolls.
   * @param {HTMLElement} html  Rendered contents of the message.
   * @protected
   */
  _highlightCriticalSuccessFailure(html) {
    if ( !this.isContentVisible || !this.rolls.length ) return;
    const originatingMessage = this.getOriginatingMessage();
    const displayChallenge = originatingMessage?.shouldDisplayChallenge;
    const displayAttackResult = game.user.isGM || (game.settings.get("dnd5e", "attackRollVisibility") !== "none");
    const forceSuccess = this.flags.dnd5e?.roll?.forceSuccess === true;

    /**
     * Create an icon to indicate success or failure.
     * @param {string} cls  The icon class.
     * @returns {HTMLElement}
     */
    function makeIcon(cls) {
      const icon = document.createElement("i");
      icon.classList.add("fas", cls);
      icon.setAttribute("inert", "");
      return icon;
    }

    // Highlight rolls where the first part is a d20 roll
    const totals = html.querySelectorAll(".dice-total");
    for ( let [index, d20Roll] of this.rolls.entries() ) {

      const d0 = d20Roll.dice[0];
      if ( (d0?.faces !== 20) || (d0?.values.length !== 1) ) continue;

      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 ) continue;

      // Highlight successes and failures
      const total = totals[index];
      if ( !total ) continue;
      // Only attack rolls and death saves can crit or fumble.
      const canCrit = ["attack", "death"].includes(this.getFlag("dnd5e", "roll.type"));
      const isAttack = this.getFlag("dnd5e", "roll.type") === "attack";
      const showResult = isAttack ? displayAttackResult : displayChallenge;
      if ( d.options.target && showResult ) {
        if ( d20Roll.isSuccess || forceSuccess ) total.classList.add("success");
        else total.classList.add("failure");
      }
      if ( canCrit && d20Roll.isCritical ) total.classList.add("critical");
      if ( canCrit && d20Roll.isFumble && !forceSuccess ) total.classList.add("fumble");

      const icons = document.createElement("div");
      icons.classList.add("icons");
      if ( total.classList.contains("critical") ) icons.append(makeIcon("fa-check"), makeIcon("fa-check"));
      else if ( total.classList.contains("fumble") ) icons.append(makeIcon("fa-xmark"), makeIcon("fa-xmark"));
      else if ( total.classList.contains("success") ) icons.append(makeIcon("fa-check"));
      else if ( total.classList.contains("failure") ) icons.append(makeIcon("fa-xmark"));
      if ( icons.children.length ) total.append(icons);
    }
  }

  /* -------------------------------------------- */

  /**
   * Augment the chat card markup for additional styling.
   * @param {HTMLElement} html  The chat card markup.
   * @protected
   */
  async _enrichChatCard(html) {
    html.querySelectorAll(".dnd5e2").forEach(el => el.classList.remove("dnd5e2")); // Legacy
    html.classList.add("dnd5e2");

    // Header matter
    const actor = this.getAssociatedActor();
    const avatar = document.createElement("a");
    avatar.classList.add("avatar");
    let avatarImg = document.createElement("img");

    let img;
    let nameText;
    if ( this.isContentVisible ) {
      const artworkData = await actor?.getPreferredArtwork();
      img = artworkData?.src ?? this.author?.avatar;
      nameText = this.alias;
      if ( artworkData?.isToken ) avatar.classList.add("token");
      if ( artworkData?.isVideo ) {
        avatarImg = document.createElement("video");
        avatarImg.toggleAttribute("autoplay", true);
        avatarImg.toggleAttribute("muted", true);
        avatarImg.toggleAttribute("disablepictureinpicture", true);
        avatarImg.toggleAttribute("loop", true);
        avatarImg.toggleAttribute("playsinline", true);
      }
    } else {
      img = this.author?.avatar;
      nameText = this.author?.name ?? "";
    }
    img ??= CONST.DEFAULT_TOKEN;

    if ( actor ) avatar.dataset.uuid = actor.uuid;
    Object.assign(avatarImg, { src: img, alt: nameText });
    avatar.append(avatarImg);

    const name = document.createElement("span");
    name.classList.add("name-stacked");
    const title = document.createElement("span");
    title.classList.add("title");
    title.append(nameText);
    name.append(title);

    const subtitle = document.createElement("span");
    subtitle.classList.add("subtitle");
    if ( this.whisper.length ) subtitle.innerText = html.querySelector(".whisper-to")?.innerText ?? "";
    if ( (nameText !== this.author?.name) && !subtitle.innerText.length ) subtitle.innerText = this.author?.name ?? "";

    name.appendChild(subtitle);

    const sender = html.querySelector(".message-sender");
    sender?.replaceChildren(avatar, name);
    html.querySelector(".whisper-to")?.remove();

    // Context menu
    const metadata = html.querySelector(".message-metadata");
    const deleteButton = metadata.querySelector(".message-delete");
    if ( !game.user.isGM ) deleteButton?.remove();
    else deleteButton?.querySelector("i").classList.add("fa-fw");
    const anchor = document.createElement("a");
    anchor.setAttribute("aria-label", game.i18n.localize("DND5E.AdditionalControls"));
    anchor.classList.add("chat-control");
    anchor.dataset.contextMenu = "";
    anchor.innerHTML = '<i class="fas fa-ellipsis-vertical fa-fw"></i>';
    metadata.appendChild(anchor);

    // SVG icons
    html.querySelectorAll("i.dnd5e-icon").forEach(el => {
      const icon = document.createElement("dnd5e-icon");
      icon.src = el.dataset.src;
      el.replaceWith(icon);
    });

    // Enriched roll flavor
    const roll = this.getFlag("dnd5e", "roll");
    const item = this.getAssociatedItem();
    const activity = this.getAssociatedActivity();
    if ( this.isContentVisible && item && roll ) {
      const isCritical = (roll.type === "damage") && this.rolls[0]?.isCritical;
      const subtitle = roll.type === "damage"
        ? isCritical
          ? game.i18n.localize("DND5E.CriticalHit")
          : activity?.damageFlavor ?? game.i18n.localize("DND5E.DamageRoll")
        : roll.type === "attack"
          ? (activity?.getActionLabel(roll.attackMode) ?? "")
          : (item.system.type?.label ?? game.i18n.localize(CONFIG.Item.typeLabels[item.type]));
      const flavor = document.createElement("div");
      flavor.classList.add("chat-card");
      flavor.innerHTML = `
        <section class="card-header description ${isCritical ? "critical" : ""}">
          <header class="summary">
            <div class="name-stacked">
              <span class="subtitle">${subtitle}</span>
            </div>
          </header>
        </section>
      `;
      const icon = document.createElement("img");
      Object.assign(icon, { className: "gold-icon", src: item.img, alt: item.name });
      flavor.querySelector("header").insertAdjacentElement("afterbegin", icon);
      const title = document.createElement("span");
      title.classList.add("title");
      title.append(item.name);
      flavor.querySelector(".name-stacked").insertAdjacentElement("afterbegin", title);
      html.querySelector(".message-header .flavor-text").remove();
      html.querySelector(".message-content").insertAdjacentElement("afterbegin", flavor);
    }

    // Attack targets
    this._enrichAttackTargets(html);

    // Dice rolls
    if ( this.isContentVisible ) {
      html.querySelectorAll(".dice-tooltip").forEach((el, i) => {
        if ( !(roll instanceof DamageRoll) && this.rolls[i] ) this._enrichRollTooltip(this.rolls[i], el);
      });
      this._enrichDamageTooltip(this.rolls.filter(r => r instanceof DamageRoll), html);
      this._enrichSaveTooltip(html);
      this._enrichEnchantmentTooltip(html);
      html.querySelectorAll(".dice-roll").forEach(el => el.addEventListener("click", this._onClickDiceRoll.bind(this)));
    } else {
      html.querySelectorAll(".dice-roll").forEach(el => el.classList.add("secret-roll"));
    }

    // Effects tray
    this._enrichUsageEffects(html);

    avatar.addEventListener("click", this._onTargetMouseDown.bind(this));
    avatar.addEventListener("pointerover", this._onTargetHoverIn.bind(this));
    avatar.addEventListener("pointerout", this._onTargetHoverOut.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * Augment roll tooltips with some additional information and styling.
   * @param {Roll} roll            The roll instance.
   * @param {HTMLDivElement} html  The roll tooltip markup.
   */
  _enrichRollTooltip(roll, html) {
    const constant = Number(simplifyRollFormula(roll._formula, { deterministic: true }));
    if ( !constant ) return;
    const sign = constant < 0 ? "-" : "+";
    const part = document.createElement("section");
    part.classList.add("tooltip-part", "constant");
    part.innerHTML = `
      <div class="dice">
        <ol class="dice-rolls"></ol>
        <div class="total">
          <span class="value"><span class="sign">${sign}</span>${Math.abs(constant)}</span>
        </div>
      </div>
    `;
    html.appendChild(part);
  }

  /* -------------------------------------------- */

  /**
   * Augment attack cards with additional information.
   * @param {HTMLLIElement} html   The chat card.
   * @protected
   */
  _enrichAttackTargets(html) {
    const attackRoll = this.rolls[0];
    if ( !(attackRoll instanceof dnd5e.dice.D20Roll) ) return;

    const masteryConfig = CONFIG.DND5E.weaponMasteries[attackRoll.options.mastery];
    if ( masteryConfig ) {
      const p = document.createElement("p");
      p.classList.add("supplement");
      let mastery = masteryConfig.label;
      if ( masteryConfig.reference ) mastery = `
        <a class="content-link" draggable="true" data-link data-uuid="${masteryConfig.reference}"
           data-tooltip="${mastery}">${mastery}</a>
      `;
      p.innerHTML = `<strong>${game.i18n.format("DND5E.WEAPON.Mastery.Flavor")}</strong> ${mastery}`;
      (html.querySelector(".chat-card") ?? html.querySelector(".message-content"))?.appendChild(p);
    }

    const visibility = game.settings.get("dnd5e", "attackRollVisibility");
    const isVisible = game.user.isGM || (visibility !== "none");
    if ( !isVisible ) return;

    const targets = this.getFlag("dnd5e", "targets");
    if ( !targets?.length ) return;
    const tray = document.createElement("div");
    tray.innerHTML = `
      <div class="card-tray targets-tray collapsible collapsed">
        <label class="roboto-upper">
          <i class="fas fa-bullseye" inert></i>
          <span>${game.i18n.localize("DND5E.TargetPl")}</span>
          <i class="fas fa-caret-down" inert></i>
        </label>
        <div class="collapsible-content">
          <ul class="unlist evaluation wrapper"></ul>
        </div>
      </div>
    `;
    const evaluation = tray.querySelector("ul");
    const rows = targets.map(({ name, ac, uuid }) => {
      const isMiss = !attackRoll.isCritical && ((attackRoll.total < ac) || attackRoll.isFumble);
      if ( !game.user.isGM && (visibility !== "all") ) ac = "";
      const li = document.createElement("li");
      Object.assign(li.dataset, { uuid, miss: isMiss });
      li.className = `target ${isMiss ? "miss" : "hit"}`;
      li.innerHTML = `
        <i class="fas ${isMiss ? "fa-times" : "fa-check"}"></i>
        <div class="name"></div>
        ${(ac !== "") ? `
        <div class="ac">
          <i class="fas fa-shield-halved"></i>
          <span>${(ac === null) ? "&infin;" : ac}</span>
        </div>
        ` : ""}
      `;
      li.querySelector(".name").append(name);
      return li;
    }).sort((a, b) => {
      const missA = Boolean(a.dataset.miss);
      const missB = Boolean(b.dataset.miss);
      return missA === missB ? 0 : missA ? 1 : -1;
    });
    evaluation.append(...rows);
    evaluation.querySelectorAll("li.target").forEach(target => {
      target.addEventListener("click", this._onTargetMouseDown.bind(this));
      target.addEventListener("pointerover", this._onTargetHoverIn.bind(this));
      target.addEventListener("pointerout", this._onTargetHoverOut.bind(this));
    });
    html.querySelector(".message-content")?.appendChild(tray);
  }

  /* -------------------------------------------- */

  /**
   * Coalesce damage rolls into a single breakdown.
   * @param {DamageRoll[]} rolls  The damage rolls.
   * @param {HTMLElement} html    The chat card markup.
   * @protected
   */
  _enrichDamageTooltip(rolls, html) {
    if ( !rolls.length ) return;
    const aggregatedRolls = CONFIG.DND5E.aggregateDamageDisplay ? aggregateDamageRolls(rolls) : rolls;
    let { formula, total, breakdown } = aggregatedRolls.reduce((obj, r) => {
      obj.formula.push(CONFIG.DND5E.aggregateDamageDisplay ? r.formula : ` + ${r.formula}`);
      obj.total += Math.max(0, r.total);
      obj.breakdown.push(this._simplifyDamageRoll(r));
      return obj;
    }, { formula: [], total: 0, breakdown: [] });
    formula = formula.join("").replace(/^ \+ /, "");
    html.querySelectorAll(".dice-roll").forEach(el => el.remove());
    const roll = document.createElement("div");
    roll.classList.add("dice-roll");

    const tooltipContents = breakdown.reduce((str, { type, total, constant, dice, icon, method }) => {
      const config = CONFIG.DND5E.damageTypes[type] ?? CONFIG.DND5E.healingTypes[type];
      return `${str}
        <section class="tooltip-part">
          <div class="dice">
            ${icon
              ? `<span class="part-method" data-tooltip aria-label="${game.i18n.localize(method)}">${icon}</span>` : ""}
            <ol class="dice-rolls">
              ${dice.reduce((str, { result, classes }) => `
                ${str}<li class="roll ${classes}">${result}</li>
              `, "")}
              ${constant ? `
              <li class="constant"><span class="sign">${constant < 0 ? "-" : "+"}</span>${Math.abs(constant)}</li>
              ` : ""}
            </ol>
            <div class="total">
              ${config ? `<img src="${config.icon}" alt="${config.label}">` : ""}
              <span class="label">${config?.label ?? ""}</span>
              <span class="value">${total}</span>
            </div>
          </div>
        </section>
      `;
    }, "");

    roll.innerHTML = `
      <div class="dice-result">
        <div class="dice-formula">${formula}</div>
        <div class="dice-tooltip-collapser">
          <div class="dice-tooltip">
            ${tooltipContents}
          </div>
        </div>
        <h4 class="dice-total">${total}</h4>
      </div>
    `;
    html.querySelector(".message-content").appendChild(roll);

    const damageOnSave = this.getFlag("dnd5e", "roll.damageOnSave");
    if ( damageOnSave ) {
      const p = document.createElement("p");
      p.classList.add("supplement");
      p.innerHTML = `<strong>${game.i18n.format("DND5E.SAVE.OnSave")}</strong> ${
        game.i18n.localize(`DND5E.SAVE.FIELDS.damage.onSave.${damageOnSave.capitalize()}`)
      }`;
      html.querySelector(".chat-card, .message-content")?.appendChild(p);
    }

    if ( game.user.isGM ) {
      const damageApplication = document.createElement("damage-application");
      damageApplication.damages = aggregateDamageRolls(rolls, { respectProperties: true }).map(roll => ({
        value: Math.max(0, roll.total),
        type: roll.options.type,
        properties: new Set(roll.options.properties ?? [])
      }));
      html.querySelector(".message-content").appendChild(damageApplication);
    }
  }

  /* -------------------------------------------- */

  /**
   * Simplify damage roll information for use by damage tooltip.
   * @param {DamageRoll} roll   The damage roll to simplify.
   * @returns {object}          The object holding simplified damage roll data.
   * @protected
   */
  _simplifyDamageRoll(roll) {
    const { OperatorTerm, NumericTerm, DiceTerm, PoolTerm } = foundry.dice.terms;
    const termResultClasses = ["success", "failure", "rerolled", "exploded", "discarded"];
    const aggregate = {
      type: roll.options.type, total: Math.max(0, roll.total), constant: 0, dice: [], icon: null, method: null
    };
    let hasMultiplication = false;
    for ( let i = roll.terms.length - 1; i >= 0; ) {
      const term = roll.terms[i--];
      if ( !(term instanceof NumericTerm) && !(term instanceof DiceTerm) && !(term instanceof PoolTerm) ) {
        continue;
      }
      const value = term.total;
      if ( term instanceof DiceTerm ) {
        const tooltipData = term.getTooltipData();
        aggregate.dice.push(...tooltipData.rolls);
        aggregate.icon ??= tooltipData.icon;
        aggregate.method ??= tooltipData.method;
      }
      if ( term instanceof PoolTerm ) {
        term.rolls.forEach((poolTermRoll, i) => {
          // Get simplified data for each roll
          const simplified = this._simplifyDamageRoll(poolTermRoll);
          const result = term.results[i];
          // Apply main result classes to individual dice
          simplified.dice.forEach(die => {
            const resultClasses = termResultClasses.filter(c => result[c]).join(" ");
            if ( resultClasses.length ) die.classes += ` ${resultClasses}`;
          });
          aggregate.dice.push(...simplified.dice);
          aggregate.icon ??= simplified.icon;
          aggregate.method ??= simplified.method;
        });
      }
      let multiplier = 1;
      let operator = roll.terms[i];
      while ( operator instanceof OperatorTerm ) {
        if ( !["+", "-"].includes(operator.operator) ) hasMultiplication = true;
        if ( operator.operator === "-" ) multiplier *= -1;
        operator = roll.terms[--i];
      }
      if ( term instanceof NumericTerm ) aggregate.constant += value * multiplier;
    }
    if ( hasMultiplication ) aggregate.constant = null;
    return aggregate;
  }

  /* -------------------------------------------- */

  /**
   * Display the enrichment application interface if necessary.
   * @param {HTMLLIElement} html   The chat card.
   * @protected
   */
  _enrichEnchantmentTooltip(html) {
    const enchantmentProfile = this.getFlag("dnd5e", "use.enchantmentProfile");
    if ( !enchantmentProfile ) return;

    // Ensure concentration is still being maintained
    const concentrationId = this.getFlag("dnd5e", "use.concentrationId");
    if ( concentrationId && !this.getAssociatedActor()?.effects.get(concentrationId) ) return;

    // Create the enchantment tray
    const enchantmentApplication = document.createElement("enchantment-application");
    const afterElement = html.querySelector(".card-footer");
    if ( afterElement ) afterElement.insertAdjacentElement("beforebegin", enchantmentApplication);
    else html.querySelector(".chat-card")?.append(enchantmentApplication);
  }

  /* -------------------------------------------- */

  /**
   * Display option to resist a failed save using a legendary resistance.
   * @param {HTMLLIElement} html  The chat card.
   * @protected
   */
  _enrichSaveTooltip(html) {
    const actor = this.getAssociatedActor();
    const roll = this.getFlag("dnd5e", "roll");
    if ( (actor?.type !== "npc") || (roll?.type !== "save") || this.rolls.some(r => r.isSuccess) ) return;

    const content = document.createElement("div");
    content.classList.add("chat-card");

    // If message has the `forceSuccess` flag, mark it as resisted
    if ( roll.forceSuccess ) content.insertAdjacentHTML("beforeend", `
      <p class="supplement">
        <strong>${game.i18n.localize("DND5E.ROLL.Status")}</strong>
        ${game.i18n.localize("DND5E.LegendaryResistance.Resisted")}
      </p>
    `);

    // Otherwise if actor has legendary resistances remaining, display resist button
    else if ( actor.system.resources.legres.value && actor.isOwner ) {
      content.insertAdjacentHTML("beforeend", `
        <div class="card-buttons">
          <button type="button">
            <i class="fa-solid fa-dragon" inert></i>
            ${game.i18n.localize("DND5E.LegendaryResistance.Action.Resist")}
          </button>
        </div>
      `);
      const button = content.querySelector("button");
      button.addEventListener("click", () => actor.system.resistSave(this));
    }

    else return;

    html.querySelector(".message-content").append(content);
  }

  /* -------------------------------------------- */

  /**
   * Display the effects tray with effects the user can apply.
   * @param {HTMLLiElement} html  The chat card.
   * @protected
   */
  _enrichUsageEffects(html) {
    if ( this.getFlag("dnd5e", "messageType") !== "usage" ) return;
    const item = this.getAssociatedItem();
    const effects = this.getFlag("dnd5e", "use.effects")
      ?.map(id => item?.effects.get(id))
      .filter(e => e && (game.user.isGM || (e.transfer && (this.author?.id === game.user.id))));
    if ( !effects?.length ) return;

    const effectApplication = document.createElement("effect-application");
    effectApplication.effects = effects;
    html.querySelector(".message-content").appendChild(effectApplication);
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /**
   * 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
   */
  static addChatMessageContextOptions(html, options) {
    const canApply = li => game.messages.get(li.dataset.messageId)?.canApplyDamage;
    const canTarget = li => game.messages.get(li.dataset.messageId)?.canSelectTargets;
    options.push(
      {
        name: game.i18n.localize("DND5E.ChatContextDamage"),
        icon: '<i class="fas fa-user-minus"></i>',
        condition: canApply,
        callback: li => game.messages.get(li.dataset.messageId)?.applyChatCardDamage(li, 1),
        group: "damage"
      },
      {
        name: game.i18n.localize("DND5E.ChatContextHealing"),
        icon: '<i class="fas fa-user-plus"></i>',
        condition: canApply,
        callback: li => game.messages.get(li.dataset.messageId)?.applyChatCardDamage(li, -1),
        group: "damage"
      },
      {
        name: game.i18n.localize("DND5E.ChatContextTempHP"),
        icon: '<i class="fas fa-user-clock"></i>',
        condition: canApply,
        callback: li => game.messages.get(li.dataset.messageId)?.applyChatCardTemp(li),
        group: "damage"
      },
      {
        name: game.i18n.localize("DND5E.ChatContextDoubleDamage"),
        icon: '<i class="fas fa-user-injured"></i>',
        condition: canApply,
        callback: li => game.messages.get(li.dataset.messageId)?.applyChatCardDamage(li, 2),
        group: "damage"
      },
      {
        name: game.i18n.localize("DND5E.ChatContextHalfDamage"),
        icon: '<i class="fas fa-user-shield"></i>',
        condition: canApply,
        callback: li => game.messages.get(li.dataset.messageId)?.applyChatCardDamage(li, 0.5),
        group: "damage"
      },
      {
        name: game.i18n.localize("DND5E.ChatContextSelectHit"),
        icon: '<i class="fas fa-bullseye"></i>',
        condition: canTarget,
        callback: li => game.messages.get(li.dataset.messageId)?.selectTargets(li, "hit"),
        group: "attack"
      },
      {
        name: game.i18n.localize("DND5E.ChatContextSelectMiss"),
        icon: '<i class="fas fa-bullseye"></i>',
        condition: canTarget,
        callback: li => game.messages.get(li.dataset.messageId)?.selectTargets(li, "miss"),
        group: "attack"
      }
    );
    return options;
  }

  /* -------------------------------------------- */

  /**
   * Add event listeners for chat messages created from activities.
   * @param {HTMLElement} html  The chat message HTML.
   */
  _activateActivityListeners(html) {
    this.getAssociatedActivity()?.activateChatListeners(this, html);
  }

  /* -------------------------------------------- */

  /**
   * Handle target selection and panning.
   * @param {Event} event   The triggering event.
   * @returns {Promise}     A promise that resolves once the canvas pan has completed.
   * @protected
   */
  async _onTargetMouseDown(event) {
    event.stopPropagation();
    const uuid = event.currentTarget.dataset.uuid;
    const actor = fromUuidSync(uuid);
    const token = actor?.token?.object ?? actor?.getActiveTokens()[0];
    if ( !token || !actor.testUserPermission(game.user, "OBSERVER")) return;
    const releaseOthers = !event.shiftKey;
    if ( token.controlled ) token.release();
    else {
      token.control({ releaseOthers });
      return canvas.animatePan(token.center);
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle hovering over a target in an attack roll message.
   * @param {Event} event     Initiating hover event.
   * @protected
   */
  _onTargetHoverIn(event) {
    const uuid = event.currentTarget.dataset.uuid;
    const actor = fromUuidSync(uuid);
    const token = actor?.token?.object ?? actor?.getActiveTokens()[0];
    if ( token && token.isVisible ) {
      if ( !token.controlled ) token._onHoverIn(event, { hoverOutOthers: true });
      this._highlighted = token;
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle hovering out of a target in an attack roll message.
   * @param {Event} event     Initiating hover event.
   * @protected
   */
  _onTargetHoverOut(event) {
    if ( this._highlighted ) this._highlighted._onHoverOut(event);
    this._highlighted = null;
  }

  /* -------------------------------------------- */

  /**
   * 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}
   */
  applyChatCardDamage(li, multiplier) {
    const damages = aggregateDamageRolls(this.rolls, { respectProperties: true }).map(roll => ({
      value: Math.max(0, roll.total) * (roll.options.type in CONFIG.DND5E.healingTypes ? -1 : 1),
      type: roll.options.type,
      properties: new Set(roll.options.properties ?? [])
    }));
    return Promise.all(canvas.tokens.controlled.map(t => {
      return t.actor?.applyDamage(damages, { multiplier, isDelta: true });
    }));
  }

  /* -------------------------------------------- */

  /**
   * Select the hit or missed targets.
   * @param {HTMLElement} li    The chat entry which contains the roll data.
   * @param {string} type       The type of selection ('hit' or 'miss').
   */
  selectTargets(li, type) {
    if ( !canvas?.ready ) return;
    const lis = li.closest("[data-message-id]").querySelectorAll(`.evaluation li.target.${type}`);
    const uuids = new Set(Array.from(lis).map(n => n.dataset.uuid));
    canvas.tokens.releaseAll();
    uuids.forEach(uuid => {
      const actor = fromUuidSync(uuid);
      if ( !actor ) return;
      const tokens = actor.isToken ? [actor.token?.object] : actor.getActiveTokens();
      for ( const token of tokens ) {
        if ( token?.isVisible && actor.testUserPermission(game.user, "OWNER") ) {
          token.control({ releaseOthers: false });
        }
      }
    });
  }

  /* -------------------------------------------- */

  /**
   * 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}
   */
  applyChatCardTemp(li) {
    const total = this.rolls.reduce((acc, roll) => acc + roll.total, 0);
    return Promise.all(canvas.tokens.controlled.map(t => {
      return t.actor?.applyTempHP(total);
    }));
  }

  /* -------------------------------------------- */

  /**
   * Handle dice roll expansion.
   * @param {PointerEvent} event  The triggering event.
   * @protected
   */
  _onClickDiceRoll(event) {
    event.stopPropagation();
    const target = event.currentTarget;
    target.classList.toggle("expanded");
  }

  /* -------------------------------------------- */

  /**
   * Handle rendering a chat popout.
   * @param {ChatPopout} app    The ChatPopout Application instance.
   * @param {HTMLElement} html  The rendered Application HTML.
   */
  static onRenderChatPopout(app, html) {
    if ( game.user.isGM ) html.dataset.gmUser = "";
    const close = html.querySelector(".header-button.close");
    if ( close ) {
      close.innerHTML = '<i class="fas fa-times"></i>';
      close.dataset.tooltip = game.i18n.localize("Close");
      close.setAttribute("aria-label", close.dataset.tooltip);
    }
    html.querySelector(".message-metadata [data-context-menu]")?.remove();
  }

  /* -------------------------------------------- */

  /**
   * Wait to apply appropriate element heights until after the chat log has completed its initial batch render.
   * @param {HTMLElement} html
   */
  static onRenderChatLog(html) {
    if ( game.user.isGM ) {
      html.dataset.gmUser = "";
      const notifications = document.getElementById("chat-notifications");
      if ( notifications ) notifications.dataset.gmUser = "";
    }
    if ( !game.settings.get("dnd5e", "autoCollapseItemCards") ) {
      requestAnimationFrame(() => {
        // FIXME: Allow time for transitions to complete. Adding a transitionend listener does not appear to work, so
        // the transition time is hard-coded for now.
        setTimeout(() => ui.chat.scrollBottom(), 250);
      });
    }
  }

  /* -------------------------------------------- */

  /**
   * Listen for shift key being pressed to show the chat message "delete" icon, or released (or focus lost) to hide it.
   */
  static activateListeners() {
    window.addEventListener("keydown", this.toggleModifiers, { passive: true });
    window.addEventListener("keyup", this.toggleModifiers, { passive: true });
    window.addEventListener("blur", () => this.toggleModifiers({ releaseAll: true }), { passive: true });
  }

  /* -------------------------------------------- */

  /**
   * Toggles attributes on the chatlog based on which modifier keys are being held.
   * @param {object} [options]
   * @param {boolean} [options.releaseAll=false]  Force all modifiers to be considered released.
   */
  static toggleModifiers({ releaseAll=false }={}) {
    const MODIFIER_KEYS = (foundry.helpers?.interaction?.KeyboardManager ?? KeyboardManager).MODIFIER_KEYS;
    document.querySelectorAll(".chat-sidebar > ol, #chat .chat-scroll > ol").forEach(chatlog => {
      for ( const key of Object.values(MODIFIER_KEYS) ) {
        if ( game.keyboard.isModifierActive(key) && !releaseAll ) chatlog.dataset[`modifier${key}`] = "";
        else delete chatlog.dataset[`modifier${key}`];
      }
    });
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preCreate(data, options, user) {
    if ( (await super._preCreate(data, options, user)) === false ) return false;
    if ( !foundry.utils.hasProperty(data, "flags.core.canPopout") ) {
      this.updateSource({ "flags.core.canPopout": true });
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDelete(options, userId) {
    super._onDelete(options, userId);
    dnd5e.registry.messages.untrack(this);
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Get the Activity that created this chat card.
   * @returns {Activity|void}
   */
  getAssociatedActivity() {
    const activity = fromUuidSync(this.getFlag("dnd5e", "activity.uuid"), { strict: false });
    if ( activity ) return activity;
    return this.getAssociatedItem()?.system.activities?.get(this.getFlag("dnd5e", "activity.id"));
  }

  /* -------------------------------------------- */

  /**
   * Get the Actor which is the author of a chat card.
   * @returns {Actor|void}
   */
  getAssociatedActor() {
    if ( this.speaker.scene && this.speaker.token ) {
      const scene = game.scenes.get(this.speaker.scene);
      const token = scene?.tokens.get(this.speaker.token);
      if ( token ) return token.actor;
    }
    return game.actors.get(this.speaker.actor);
  }

  /* -------------------------------------------- */

  /**
   * Get the item associated with this chat card.
   * @returns {Item5e|void}
   */
  getAssociatedItem() {
    const item = fromUuidSync(this.getFlag("dnd5e", "item.uuid"), { strict: false });
    if ( item ) return item;
    const actor = this.getAssociatedActor();
    if ( !actor ) return;
    const storedData = this.getFlag("dnd5e", "item.data") ?? this.getOriginatingMessage().getFlag("dnd5e", "item.data");
    if ( storedData ) return new Item.implementation(storedData, { parent: actor });
  }

  /* -------------------------------------------- */

  /**
   * Get a list of all chat messages containing rolls that originated from this message.
   * @param {string} [type]  Type of rolls to get. If empty, all roll types will be fetched.
   * @returns {ChatMessage5e[]}
   */
  getAssociatedRolls(type) {
    return dnd5e.registry.messages.get(this.id, type);
  }

  /* -------------------------------------------- */

  /**
   * Get the original chat message from which this message was created. If no originating message exists,
   * will return this message.
   * @type {ChatMessage5e}
   */
  getOriginatingMessage() {
    return game.messages.get(this.getFlag("dnd5e", "originatingMessage")) ?? this;
  }
}

/**
 * Custom implementation of the chat log to support saving tray states.
 */
class ChatLog5e extends foundry.applications.sidebar.tabs.ChatLog {
  /**
   * The active intersection observer.
   * @type {IntersectionObserver}
   */
  #intersections;

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onFirstRender(context, options) {
    const scroll = this.element.querySelector(".chat-scroll");
    const log = this.element.querySelector(".chat-log");
    new MutationObserver(this.#onLogMutated.bind(this)).observe(log, { childList: true });
    this.#intersections = new IntersectionObserver(this.#onCardIntersects.bind(this), { root: scroll });
    return super._onFirstRender(context, options);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async updateMessage(message, notify=false) {
    const card = this.element.querySelector(`.message[data-message-id="${message.id}"]`);
    if ( card ) message._trayStates = new Map([
      ...Array.from(card.querySelectorAll(".card-tray"))
        .map(t => [t.className.replace(" collapsed", ""), t.classList.contains("collapsed")]),
      ...Array.from(card.querySelectorAll(ChatMessage5e.TRAY_TYPES.join(", "))).map(t => [t.tagName, t.open])
    ]);
    await super.updateMessage(message, notify);
  }

  /* -------------------------------------------- */

  /**
   * Record visibility of cards as they intersect the viewport.
   * @param {IntersectionObserverEntry[]} entries  The observed elements that have entered or left the viewport.
   */
  #onCardIntersects(entries) {
    const selector = ChatMessage5e.TRAY_TYPES.join(", ");
    for ( const { isIntersecting, target } of entries ) {
      target.querySelectorAll(selector).forEach(t => t.visible = isIntersecting);
    }
  }

  /* -------------------------------------------- */

  /**
   * React to changes in the chat log.
   * @param {MutationRecord[]} records  The change list.
   */
  #onLogMutated(records) {
    for ( const { addedNodes, removedNodes } of records ) {
      for ( const node of addedNodes ) this.#intersections.observe(node);
      for ( const node of removedNodes ) this.#intersections.unobserve(node);
    }
  }
}

/**
 * Mixin method for v2-style dialogs.
 * @param {typeof Application} Base   Application class being extended.
 * @returns {class}
 * @mixin
 */
var dialogMixin = Base => class extends Base {
  /** @inheritDoc */
  async _renderOuter() {
    const html = await super._renderOuter();
    const header = html[0].querySelector(".window-header");
    header.querySelectorAll(".header-button").forEach(btn => {
      const label = btn.querySelector(":scope > i").nextSibling;
      btn.dataset.tooltip = label.textContent;
      btn.setAttribute("aria-label", label.textContent);
      label.remove();
    });
    return html;
  }
};

/**
 * Sheet config with extra options.
 */
class SheetConfig5e extends foundry.applications.apps.DocumentSheetConfig {

  /** @inheritDoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      template: "systems/dnd5e/templates/shared/sheet-config.hbs"
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  getData(options) {
    const context = super.getData(options);
    context.CONFIG = CONFIG.DND5E;
    return context;
  }

  /* -------------------------------------------- */

  async _updateObject(event, formData) {
    super._updateObject(event, formData);
    delete formData.sheetClass;
    delete formData.defaultClass;
    this.object.update(formData);

    if ( "flags.dnd5e.theme" in formData ) {
      const sheet = this.object.sheet.element?.[0];
      if ( sheet ) setTheme(sheet, formData["flags.dnd5e.theme"]);
    }
  }
}

/**
 * A specialized subclass of Tabs that handles tabs which exist outside an Application's inner HTML.
 * @extends {Tabs}
 */
class Tabs5e extends foundry.applications.ux.Tabs {
  /** @override */
  bind(html) {
    if ( !this._nav ) {
      this._nav = html.closest(".app")?.querySelector(this._navSelector);
      this._nav?.addEventListener("click", this._onClickNav.bind(this));
    }
    if ( !this._nav ) return;
    if ( !this._contentSelector ) this._content = null;
    else if ( html.matches(this._contentSelector) ) this._content = html;
    else this._content = html.querySelector(this._contentSelector);
    this.activate(this.active);
  }
}

/**
 * Custom token configuration application for handling dynamic rings & resource labels.
 */
class TokenConfig5e extends foundry.applications.sheets.TokenConfig {

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    if ( !this.rendered ) return;
    this._prepareResourceLabels(this.element);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    context.scale = Math.abs(this.token._source.texture.scaleX);
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareResourcesTab() {
    const context = await super._prepareResourcesTab();
    this._addItemAttributes(context.barAttributes);
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Adds charge based items as attributes for the current token.
   * @param {object} attributes The attribute groups to add the item entries to.
   * @protected
   */
  _addItemAttributes(attributes) {
    const actor = this.actor ?? this.object?.actor;
    const items = actor?.items.reduce((arr, i) => {
      if ( i.hasLimitedUses ) arr.push([i.getRelativeUUID(actor), i.name]);
      return arr;
    }, []) ?? [];
    if ( items.length ) {
      const group = game.i18n.localize("DND5E.ConsumeCharges");
      items.sort(([, a], [, b]) => a.localeCompare(b, game.i18n.lang));
      attributes.push(...items.map(([value, label]) => ({ group, value, label })));
    }
  }

  /* -------------------------------------------- */

  /**
   * Replace the attribute paths in token resources with human readable labels and sort them alphabetically.
   * @param {HTMLElement} html  The rendered markup.
   * @protected
   */
  _prepareResourceLabels(html) {
    const actor = this.actor ?? this.object?.actor;

    for ( const select of html.querySelectorAll('select:is(.bar-attribute, [name$=".attribute"])') ) {
      select.querySelectorAll("optgroup").forEach(group => {
        const options = Array.from(group.querySelectorAll("option"));

        // Localize attribute paths.
        options.forEach(option => {
          const label = getHumanReadableAttributeLabel(option.value, { actor });
          if ( label ) option.innerText = label;
        });

        // Sort options by localized label.
        options.sort((a, b) => a.innerText.localeCompare(b.innerText, game.i18n.lang));
        group.append(...options);
      });
    }
  }
}

/**
 * Custom prototype token configuration application for handling dynamic rings & resource labels.
 */
class PrototypeTokenConfig5e extends foundry.applications.sheets.PrototypeTokenConfig {
  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    if ( !this.rendered ) return;
    TokenConfig5e.prototype._prepareResourceLabels.call(this, this.element);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareResourcesTab() {
    const context = await super._prepareResourcesTab();
    TokenConfig5e.prototype._addItemAttributes.call(this, context.barAttributes);
    return context;
  }
}

var applications = /*#__PURE__*/Object.freeze({
  __proto__: null,
  Accordion: Accordion,
  Award: Award,
  ChatLog5e: ChatLog5e,
  CompendiumBrowser: CompendiumBrowser,
  ContextMenu5e: ContextMenu5e,
  CreateDocumentDialog: CreateDocumentDialog,
  CurrencyManager: CurrencyManager,
  DialogMixin: dialogMixin,
  PropertyAttribution: PropertyAttribution,
  PrototypeTokenConfig5e: PrototypeTokenConfig5e,
  SheetConfig: SheetConfig5e,
  Tabs5e: Tabs5e,
  TokenConfig5e: TokenConfig5e,
  activity: _module$v,
  actor: _module$u,
  advancement: _module$t,
  api: _module$s,
  calendar: _module$r,
  combat: _module$q,
  components: _module$p,
  dice: _module$o,
  fields: fields,
  item: _module$n,
  journal: _module$m,
  regionBehavior: _module$l,
  settings: _module$k,
  shared: _module$j
});

/**
 * The detection mode for Blindsight.
 */
class DetectionModeBlindsight extends foundry.canvas.perception.DetectionMode {
  constructor() {
    super({
      id: "blindsight",
      label: "DND5E.SenseBlindsight",
      type: (foundry.canvas?.perception?.DetectionMode ?? DetectionMode).DETECTION_TYPES.OTHER,
      walls: true,
      angle: false
    });
  }

  /* -------------------------------------------- */

  /** @override */
  static getDetectionFilter() {
    return this._detectionFilter ??= OutlineOverlayFilter.create({
      outlineColor: [1, 1, 1, 1],
      knockout: true,
      wave: true
    });
  }

  /* -------------------------------------------- */

  /** @override */
  _canDetect(visionSource, target) {
    if ( visionSource.object.document.hasStatusEffect(CONFIG.specialStatusEffects.BURROW) ) return false;
    if ( target instanceof foundry.canvas.placeables.Token ) {
      if ( target.document.hasStatusEffect(CONFIG.specialStatusEffects.BURROW) ) return false;
    }
    return true;
  }

  /* -------------------------------------------- */

  /** @override */
  _testLOS(visionSource, mode, target, test) {
    return !CONFIG.Canvas.polygonBackends.sight.testCollision(
      { x: visionSource.x, y: visionSource.y },
      test.point,
      {
        type: "sight",
        mode: "any",
        source: visionSource,
        // Blindsight is restricted by total cover and therefore cannot see
        // through windows. So we do not want blindsight to see through
        // a window as we get close to it. That's why we ignore thresholds.
        // We make the assumption that all windows are configured as threshold
        // walls. A move-based visibility check would also be an option to check
        // for total cover, but this would have the undesirable side effect that
        // blindsight wouldn't work through fences, portcullises, etc.
        useThreshold: false
      }
    );
  }
}

CONFIG.Canvas.detectionModes.blindsight = new DetectionModeBlindsight();

var _module$i = /*#__PURE__*/Object.freeze({
  __proto__: null,
  DetectionModeBlindsight: DetectionModeBlindsight
});

class TokenLayer5e extends foundry.canvas.layers.TokenLayer {
  /**
   * Determine whether the provided grid space is being occupied by a token which should block the provided token
   * @param {GridOffset3D} gridSpace            The grid space to check
   * @param {Token5e} token                     The token being moved
   * @param {object} [options]                  Additional options
   * @param {boolean} [options.preview=false]   Whether the movement in question is previewed
   * @returns {boolean} Whether the moving token should be blocked
   */
  isOccupiedGridSpaceBlocking(gridSpace, token, { preview=false }={}) {
    const tokenSize = CONFIG.DND5E.actorSizes[token.actor?.system.traits.size]?.numerical ?? 2;
    const modernRules = game.settings.get("dnd5e", "rulesVersion") === "modern";
    const halflingNimbleness = token.actor?.getFlag("dnd5e", "halflingNimbleness");
    const found = this.#getRelevantOccupyingTokens(gridSpace, token, { preview }).filter(t => {
      // Only creatures block movement.
      if ( !t.actor?.system.isCreature ) return false;

      // Friendly tokens never block movement
      if ( token.document.disposition === t.document.disposition ) return false;

      // If creature has any statuses that should never block movement, don't block movement
      if ( t.actor.statuses.intersects(CONFIG.DND5E.neverBlockStatuses) ) return false;

      const size = t.actor.system.details?.type?.swarm || t.actor.system.traits?.size;
      const occupiedSize = CONFIG.DND5E.actorSizes[size]?.numerical ?? 2;
      // In modern rules, Tiny creatures can be moved through
      if ( modernRules && (occupiedSize === 0) ) return false;

      // Halfling Nimbleness means no larger creature can block
      if ( halflingNimbleness && (occupiedSize > tokenSize) ) return false;

      // A size difference of less than 2 should block
      return Math.abs(tokenSize - occupiedSize) < 2;
    });

    /**
     * Hook event that fires when determining whether a grid space is occupied by a token which should block movement
     * for a provided token.
     * @function dnd5e.determineOccupiedGridSpaceBlocking
     * @memberof hookEvents
     * @param {GridOffset3D} gridSpace  The grid space being checked.
     * @param {Token5e} token           The token being moved.
     * @param {object} options          Additional options.
     * @param {boolean} options.preview Whether the movement in question is previewed.
     * @param {Set<Token5e>} found      The found set of tokens which would block movement. *Will be mutated.*
     */
    Hooks.callAll("dnd5e.determineOccupiedGridSpaceBlocking", gridSpace, token, { preview }, found);
    return found.size > 0;
  }

  /* -------------------------------------------- */

  /**
   * Determine whether the provided grid space is being occupied by a token which should at least cause difficult
   * terrain for the provided token
   * @param {GridOffset3D} gridSpace            The grid space to check
   * @param {Token5e} token                     The token being moved
   * @param {object} [options]                  Additional options
   * @param {boolean} [options.preview=false]   Whether the movement in question is previewed
   * @returns {boolean} Whether the moving token should suffer difficult terrain
   */
  isOccupiedGridSpaceDifficult(gridSpace, token, { preview=false }={}) {
    const modernRules = game.settings.get("dnd5e", "rulesVersion") === "modern";
    const found = this.#getRelevantOccupyingTokens(gridSpace, token, { preview }).filter(t => {
      // Only consider creatures as difficult terrain for now.
      if ( !t.actor?.system.isCreature ) return false;

      const friendlyToken = token.document.disposition === t.document.disposition;

      // In modern rules, friendly tokens are not difficult terrain
      if ( modernRules && friendlyToken ) return false;
      const occupiedSize = CONFIG.DND5E.actorSizes[t.actor?.system.traits.size]?.numerical ?? 2;

      // In modern rules, Tiny creatures are not difficult terrain
      if ( modernRules && (occupiedSize === 0) ) return false;

      // Any token which has not been filtered out by this point should at least be difficult terrain, if not blocking
      return true;
    });

    /**
     * Hook event that fires when determining whether a grid space is occupied by a token which should cause difficult
     * terrain for a provided token.
     * @function dnd5e.determineOccupiedGridSpaceDifficult
     * @memberof hookEvents
     * @param {GridOffset3D} gridSpace  The grid space being checked.
     * @param {Token5e} token           The token being moved.
     * @param {object} options          Additional options.
     * @param {boolean} options.preview Whether the movement in question is previewed.
     * @param {Set<Token5e>} found      The found set of tokens which would cause difficult terrain. *Will be mutated.*
     */
    Hooks.callAll("dnd5e.determineOccupiedGridSpaceDifficult", gridSpace, token, { preview }, found);
    return found.size > 0;
  }

  /* -------------------------------------------- */

  /**
   * Determine the set of tokens occupying the provided grid space which may be relevant for blocking/difficult terrain
   * considerations
   * @param {GridOffset3D} gridSpace            The grid space to check
   * @param {Token5e} token                     The token being moved
   * @param {object} [options]                  Additional options
   * @param {boolean} [options.preview=false]   Whether the movement in question is previewed
   * @returns {Set<Token5e>} The set of potentially relevant tokens occupying the provided grid space
   */
  #getRelevantOccupyingTokens(gridSpace, token, { preview=false }={}) {
    const grid = canvas.grid;
    if ( grid.isGridless ) return [];
    const topLeft = grid.getTopLeftPoint(gridSpace);
    const rect = new PIXI.Rectangle(topLeft.x, topLeft.y, grid.sizeX, grid.sizeY);
    const lowerElevation = gridSpace.k * grid.distance;
    const upperElevation = (gridSpace.k + 1) * grid.distance;
    return game.canvas.tokens.quadtree.getObjects(rect, {
      collisionTest: ({ t }) => {
        // Ignore self
        if ( t === token ) return false;

        // Ignore tokens when moving together
        if ( canvas.tokens.controlled.includes(t) ) return false;

        // Always ignore hidden tokens
        if ( t.document.hidden ) return false;

        // If preview movement, don't reveal blocked or difficult terrain for non-visible tokens
        if ( preview && !t.visible ) return false;

        // Always ignore secret tokens
        if ( t.document.disposition === CONST.TOKEN_DISPOSITIONS.SECRET ) return false;

        // Ignore different elevation
        const occupiedElevation = t.document._source.elevation;
        if ( (occupiedElevation < lowerElevation) || (occupiedElevation >= upperElevation) ) return false;

        // Ensure space is actually occupied, not merely touching border of rectangle
        const gridSpaces = t.document.getOccupiedGridSpaceOffsets(t.document._source);
        return gridSpaces.some(coord => (coord.i === gridSpace.i) && (coord.j === gridSpace.j));
      }
    });
  }
}

var _module$h = /*#__PURE__*/Object.freeze({
  __proto__: null,
  TokenLayer5e: TokenLayer5e
});

/**
 * Add support for drawing custom control icons based on linked journal page type.
 */
class Note5e extends foundry.canvas.placeables.Note {
  /** @inheritDoc */
  _drawControlIcon() {
    const tint = Color.from(this.document.texture.tint || null);
    const systemIcon = this.page?.system?.getControlIcon?.({ size: this.document.iconSize, tint });
    if ( !systemIcon ) return super._drawControlIcon();
    systemIcon.x -= (this.document.iconSize / 2);
    systemIcon.y -= (this.document.iconSize / 2);
    return systemIcon;
  }
}

/**
 * Extend the base Token class to implement additional system-specific logic.
 */
class Token5e extends foundry.canvas.placeables.Token {

  /**
   * Update the token ring when this token is targeted.
   * @param {User5e} user         The user whose targeting has changed.
   * @param {Token5e} token       The token that was targeted.
   * @param {boolean} targeted    Is the token targeted or not?
   */
  static onTargetToken(user, token, targeted) {
    if ( !targeted ) return;
    if ( !token.hasDynamicRing ) return;
    const color = Color.from(user.color);
    token.ring.flashColor(color, { duration: 500, easing: token.ring.constructor.easeTwoPeaks });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  findMovementPath(waypoints, options) {

    // Normal behavior if token blocking is disabled or this actor is not a creature or cannot block
    if ( (game.settings.get("dnd5e", "movementAutomation") !== "full") || !this.document.actor?.system.isCreature
      || this.document.actor.statuses.intersects(CONFIG.DND5E.neverBlockStatuses) ) {
      return super.findMovementPath(waypoints, options);
    }

    // Get all grid spaces as waypoints so that running into a blocking token stops us immediately before it
    waypoints = this.document.getCompleteMovementPath(waypoints);

    // Drop all intermediate waypoints except those immediately before a blocking token
    const grid = this.document.parent.grid;
    waypoints = waypoints.filter((waypoint, i) => {
      return !waypoint.intermediate || this.layer.isOccupiedGridSpaceBlocking(grid.getOffset(waypoints[i + 1]), this);
    });
    return super.findMovementPath(waypoints, options);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _getDragConstrainOptions() {
    const unconstrainedMovement = game.user.isGM
      && ui.controls.controls.tokens.tools.unconstrainedMovement.active;
    return { ...super._getDragConstrainOptions(), ignoreTokens: unconstrainedMovement };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _getMovementCostFunction(options) {
    const costFunction = super._getMovementCostFunction(options);
    if ( game.settings.get("dnd5e", "movementAutomation") === "none" ) return costFunction;

    const ignoredDifficultTerrain = this.actor?.system.attributes?.movement?.ignoredDifficultTerrain ?? new Set();
    const ignoreDifficult = ["all", "nonmagical"].some(i => ignoredDifficultTerrain.has(i));
    const preview = options?.preview && canvas.visibility.tokenVision && !game.user.isGM;
    return (from, to, distance, segment) => {
      const cost = costFunction(from, to, distance, segment);

      // Terrain already difficult, no stacking
      if ( segment.terrain?.difficultTerrain ) return cost;

      // If ignoring all/nonmagical, don't consider tokens difficult terrain
      if ( ignoreDifficult ) return cost;

      // Check difficult due to occupied tokens
      if ( !this.layer.isOccupiedGridSpaceDifficult(to, this, {preview}) ) return cost;

      // Difficult terrain due to occupied grid space
      return cost + distance;
    };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  constrainMovementPath(waypoints, options) {
    let { preview=false, ignoreTokens=false } = options; // Custom constrain option to ignore tokens

    ignoreTokens ||= game.settings.get("dnd5e", "movementAutomation") !== "full";
    ignoreTokens ||= !this.actor?.system.isCreature;
    ignoreTokens ||= this.actor?.statuses?.intersects(CONFIG.DND5E.neverBlockStatuses);

    // Ignore tokens if path contains resize
    ignoreTokens ||= waypoints.some(w => (w.width !== waypoints[0].width) || (w.height !== waypoints[0].height));

    if ( ignoreTokens ) return super.constrainMovementPath(waypoints, options);

    // Ignore preview if token vision is disabled or the current user is a GM
    if ( !canvas.visibility.tokenVision || game.user.isGM ) preview = false;

    let path = waypoints;
    let constrained = false;

    for ( let k = 0; k < 10; k++ ) {

      // Apply blocking constraints
      const completePath = this.document.getCompleteMovementPath(path);
      let blockedIndex;
      for ( let i = 1; i < completePath.length; i++ ) {
        const waypoint = completePath[i];
        const occupiedGridSpaces = this.document.getOccupiedGridSpaceOffsets(waypoint);
        const elevationOffset = Math.floor((waypoint.elevation / canvas.grid.distance) + 1e-8);
        if ( occupiedGridSpaces.some(space =>
          this.layer.isOccupiedGridSpaceBlocking({...space, k: elevationOffset}, this, {preview}))
        ) {
          blockedIndex = i;
          break;
        }
      }
      const blocked = blockedIndex >= 1;
      if ( blocked ) {
        path = completePath.slice(0, blockedIndex - 1).filter(waypoint => !waypoint.intermediate);
        path.push(completePath.at(blockedIndex - 1));
        constrained = true;
      }

      // Test wall/cost constraints in the first iteration always and in later
      // iterations only if the path changed due to blocking
      if ( (k === 0) || blocked ) {
        const [constrainedPath, wasConstrained] = super.constrainMovementPath(path, options);
        path = constrainedPath;
        if ( !wasConstrained ) return [path, constrained]; // No change: path is valid
        constrained = true;
      }

      // In a later iteration if there was no change due to blocking, we found a valid path
      else if ( !blocked ) return [path, constrained];
    }

    // After 10 failed attempts to find a valid path, remove the last waypoints and constrain this path
    [path] = this.constrainMovementPath(waypoints.slice(0, -1), options);
    return [path, true];
  }

  /* -------------------------------------------- */

  /** @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, effectiveMax, temp, tempmax} = this.document.actor.system.attributes.hp;
    temp = Number(temp || 0);
    tempmax = Number(tempmax || 0);

    // Differentiate between effective maximum and displayed maximum
    effectiveMax = Math.max(0, effectiveMax);
    let displayMax = max + (tempmax > 0 ? tempmax : 0);

    // Allocate percentages of the total
    const tempPct = Math.clamp(temp, 0, displayMax) / displayMax;
    const colorPct = Math.clamp(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)
    let s = canvas.dimensions.uiScale;
    const bw = this.w;
    const bh = 8 * (this.document.height >= 2 ? 1.5 : 1) * s;
    const bs = s;
    const bs1 = bs + s;

    // Overall bar container
    bar.clear();
    bar.beginFill(blk, 0.5).lineStyle(bs, blk, 1.0).drawRoundedRect(0, 0, bw, bh, 3 * s);

    // Temporary maximum HP
    if (tempmax > 0) {
      const pct = max / effectiveMax;
      bar.beginFill(c.tempmax, 1.0).lineStyle(1, blk, 1.0).drawRoundedRect(pct * bw, 0, (1 - pct) * bw, bh, 2 * s);
    }

    // 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 * bw, 0, (1 - pct) * bw, bh, 2 * s);
    }

    // Health bar
    bar.beginFill(hpColor, 1.0).lineStyle(bs, blk, 1.0).drawRoundedRect(0, 0, colorPct * bw, bh, 2 * s);

    // Temporary hit points
    if ( temp > 0 ) {
      bar.beginFill(c.temp, 1.0).lineStyle(0).drawRoundedRect(bs1, bs1, (tempPct * bw) - (2 * bs1), bh - (2 * bs1), s);
    }

    // Set position
    let posY = (number === 0) ? (this.h - bh) : 0;
    bar.position.set(0, posY);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onApplyStatusEffect(statusId, active) {
    const applicableEffects = [CONFIG.specialStatusEffects.DEFEATED, CONFIG.specialStatusEffects.INVISIBLE];
    if ( applicableEffects.includes(statusId) && this.hasDynamicRing ) {
      this.renderFlags.set({refreshRingVisuals: true});
    }
    super._onApplyStatusEffect(statusId, active);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _configureFilterEffect(statusId, active) {
    if ( (statusId === CONFIG.specialStatusEffects.INVISIBLE) && this.hasDynamicRing ) active = false;
    return super._configureFilterEffect(statusId, active);
  }

  /* -------------------------------------------- */

  /** @override */
  getRingColors() {
    return this.document.getRingColors();
  }

  /* -------------------------------------------- */

  /** @override */
  getRingEffects() {
    return this.document.getRingEffects();
  }
}

class TokenRuler5e extends foundry.canvas.placeables.tokens.TokenRuler {
  /** @inheritDoc */
  _getWaypointStyle(waypoint) {
    if ( !waypoint.explicit && waypoint.next && waypoint.previous && waypoint.actionConfig.visualize
      && waypoint.next.actionConfig.visualize && (waypoint.action === waypoint.next.action)
      && (waypoint.unreachable || !waypoint.next.unreachable) ) return { radius: 0 };
    const user = game.users.get(waypoint.userId);
    const scale = canvas.dimensions.uiScale;
    const style = {radius: 6 * scale, color: user?.color ?? 0x000000, alpha: waypoint.explicit ? 1 : 0.5};
    return this.#getSpeedBasedStyle(waypoint, style);
  }

  /* -------------------------------------------- */

  /** @override */
  _getWaypointLabelContext(waypoint, state) {
    const { index, elevation, explicit, next, previous, ray } = waypoint;
    state.hasElevation ||= (elevation !== 0);
    if ( !previous ) {
      state.previousElevation = elevation;
      return;
    }
    if ( !explicit && next && waypoint.actionConfig.visualize && next.actionConfig.visualize
      && (waypoint.action === next.action) && (waypoint.unreachable || !waypoint.next.unreachable) ) return;
    if ( (ray.distance === 0) && (elevation === previous.elevation) ) return;

    // Prepare data structure
    const context = {
      action: waypoint.actionConfig,
      cssClass: [
        waypoint.hidden ? "secret" : "",
        waypoint.next ? "" : "last",
        explicit ? "" : "nonexplicit"
      ].filterJoin(" "),
      secret: waypoint.hidden,
      units: canvas.grid.units,
      uiScale: canvas.dimensions.uiScale,
      position: { x: ray.B.x, y: ray.B.y + (next ? 0 : 0.5 * this.token.h) + (16 * canvas.dimensions.uiScale) }
    };

    // Segment Distance
    context.distance = { total: waypoint.measurement.distance.toNearest(0.01).toLocaleString(game.i18n.lang) };
    if ( index >= 2 ) context.distance.delta = waypoint.measurement.backward.distance.toNearest(0.01).signedString();

    // Segment Cost
    const cost = waypoint.measurement.cost;
    const deltaCost = waypoint.cost;
    context.cost = {
      total: Number.isFinite(cost) ? cost.toNearest(0.01).toLocaleString(game.i18n.lang) : "∞",
      units: canvas.grid.units
    };
    if ( index >= 2 ) context.cost.delta = Number.isFinite(deltaCost) ? deltaCost.toNearest(0.01).signedString() : "∞";

    // Elevation
    const deltaElevation = elevation - state.previousElevation;
    context.elevation = { total: elevation, icon: "fa-solid fa-arrows-up-down", hidden: !state.hasElevation };
    if ( deltaElevation !== 0 ) context.elevation.delta = deltaElevation.signedString();
    state.previousElevation = elevation;

    return context;
  }

  /* -------------------------------------------- */

  /** @override */
  _getSegmentStyle(waypoint) {
    const style = super._getSegmentStyle(waypoint);
    return this.#getSpeedBasedStyle(waypoint, style);
  }

  /* -------------------------------------------- */

  /** @override */
  _getGridHighlightStyle(waypoint, offset) {
    const style = super._getGridHighlightStyle(waypoint, offset);
    return this.#getSpeedBasedStyle(waypoint, style);
  }

  /* -------------------------------------------- */

  /**
   * Modify segment or grid-highlighting style based on movement speed
   * @param {TokenRulerWaypoint} waypoint The waypoint
   * @param {object} style                The default styling of the segment/grid-highlight
   * @returns {object} The adjusted style, or existing style if no adjustment is necessary
   */
  #getSpeedBasedStyle(waypoint, style) {
    // If showing a different client's measurement, use default style
    if ( !(game.user.id in this.token._plannedMovement)
      || CONFIG.Token.movement.actions[waypoint.action]?.teleport ) return style;

    // Get actor's movement speed for currently selected token movement action
    const movement = this.token.actor?.system.attributes?.movement;
    if ( !movement || !this.token.actor?.system.isCreature ) return style;
    let currActionSpeed = movement[waypoint.action] ?? 0;

    // If current action can fall back to walk, treat "max" speed as maximum between current & walk
    if ( CONFIG.DND5E.movementTypes[waypoint.action]?.walkFallback
      || !CONFIG.DND5E.movementTypes[waypoint.action] ) {
      currActionSpeed = Math.max(currActionSpeed, movement.walk);
    }

    // Color `normal` if <= max speed, else `double` if <= double max speed, else `triple`
    const { normal, double, triple } = CONFIG.DND5E.tokenRulerColors;
    const increment = (waypoint.measurement.cost - .1) / currActionSpeed;
    if ( increment <= 1 ) style.color = normal ?? style.color;
    else if ( increment <= 2 ) style.color = double ?? style.color;
    else style.color = triple ?? style.color;
    return style;
  }
}

var canvas$1 = /*#__PURE__*/Object.freeze({
  __proto__: null,
  AbilityTemplate: AbilityTemplate,
  MapLocationControlIcon: MapLocationControlIcon,
  Note5e: Note5e,
  Token5e: Token5e,
  TokenPlacement: TokenPlacement,
  TokenRuler5e: TokenRuler5e,
  detectionModes: _module$i,
  layers: _module$h
});

class SystemDataModel extends SystemDataModel$1 {
  constructor(...args) {
    foundry.utils.logCompatibilityWarning(
      "`dnd5e.dataModels.SystemDataModel has been moved to `dnd5e.dataModels.abstract.SystemDataModel",
      { since: "DnD5e 5.1", until: "DnD5e 6.0", once: true }
    );
    super(...args);
  }
}

class ActorDataModel extends ActorDataModel$1 {
  constructor(...args) {
    foundry.utils.logCompatibilityWarning(
      "`dnd5e.dataModels.ActorDataModel has been moved to `dnd5e.dataModels.abstract.ActorDataModel",
      { since: "DnD5e 5.1", until: "DnD5e 6.0", once: true }
    );
    super(...args);
  }
}

class ItemDataModel extends ItemDataModel$1 {
  constructor(...args) {
    foundry.utils.logCompatibilityWarning(
      "`dnd5e.dataModels.ItemDataModel has been moved to `dnd5e.dataModels.abstract.ItemDataModel",
      { since: "DnD5e 5.1", until: "DnD5e 6.0", once: true }
    );
    super(...args);
  }
}

class SparseDataModel extends SparseDataModel$1 {
  constructor(...args) {
    foundry.utils.logCompatibilityWarning(
      "`dnd5e.dataModels.SparseDataModel has been moved to `dnd5e.dataModels.abstract.SparseDataModel",
      { since: "DnD5e 5.1", until: "DnD5e 6.0", once: true }
    );
    super(...args);
  }
}

/**
 * @import { ChatMessageDataModelMetadata } from "./types.mjs";
 */

/**
 * Abstract base class to add some shared functionality to all of the system's custom chat message types.
 * @abstract
 */
class ChatMessageDataModel extends foundry.abstract.TypeDataModel {

  /**
   * Metadata for this chat message type.
   * @type {ChatMessageDataModelMetadata}
   */
  static metadata = Object.freeze({
    actions: {
      use: ChatMessageDataModel.#useActivity
    },
    template: ""
  });

  get metadata() {
    return this.constructor.metadata;
  }

  /* -------------------------------------------- */

  /**
   * Template to use when rendering this message.
   * @type {string}
   */
  get template() {
    return this.metadata.template;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /**
   * Perform any changes to the chat message's element before displaying in the list.
   * @param {HTMLElement} element  Element representing the entire chat message.
   * @param {object} options       Options forwarded to the render function.
   */
  async getHTML(element, options) {
    const rendered = await this.render(options);
    if ( rendered ) element.querySelector(".message-content").innerHTML = rendered;
    this.parent._enrichChatCard(element);

    const click = this.#onClick.bind(this);
    element.addEventListener("click", click);
    element.addEventListener("contextmenu", click);
    this._onRender(element);
  }

  /* -------------------------------------------- */

  /**
   * Render the contents of this chat message.
   * @param {object} options  Rendering options.
   * @returns {Promise<string>}
   */
  async render(options) {
    if ( !this.template ) return "";
    return foundry.applications.handlebars.renderTemplate(this.template, await this._prepareContext(options));
  }

  /* -------------------------------------------- */

  /**
   * Prepare application rendering context data for a given render request.
   * @param {object} options  Rendering options.
   * @returns {Promise<ApplicationRenderContext>}   Context data for the render operation.
   * @protected
   */
  async _prepareContext(options) {
    return {};
  }

  /* -------------------------------------------- */

  /**
   * Actions taken after the message has been rendered.
   * @param {HTMLElement} element
   * @protected
   */
  _onRender(element) {
    for ( const e of element.querySelectorAll(".item-tooltip") ) {
      const uuid = e.closest("[data-item-uuid]")?.dataset.itemUuid;
      if ( !uuid ) continue;
      Object.assign(e.dataset, {
        tooltip: `<section class="loading" data-uuid="${uuid}"><i class="fas fa-spinner fa-spin-pulse"></i></section>`,
        tooltipClass: "dnd5e2 dnd5e-tooltip item-tooltip",
        tooltipDirection: "LEFT"
      });
    }
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle click events within the card.
   * @param {PointerEvent} event  Triggering pointer event.
   */
  #onClick(event) {
    const target = event.target.closest("[data-action]");
    if ( target ) {
      const action = target.dataset.action;
      let handler = this.metadata.actions[action];
      if ( handler ) {
        let buttons = [0];
        if ( typeof handler === "object" ) {
          buttons = handler.buttons;
          handler = handler.handler;
        }
        if ( buttons.includes(event.button) ) handler?.call(this, event, target);
      } else {
        this._onClickAction(event, target);
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * A generic event handler for action clicks which can be extended by subclasses, called if no action is found in
   * the actions list in the message type's metadata.
   * @param {PointerEvent} event  Triggering pointer event.
   * @param {HTMLElement} target  Button with [data-action] defined.
   * @protected
   */
  _onClickAction(event, target) {}

  /* -------------------------------------------- */

  /**
   * Handle using an activity.
   * @this {ChatMessageDataModel}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static async #useActivity(event, target) {
    target.disabled = true;
    try {
      const activity = await fromUuid(target.closest("[data-activity-uuid]")?.dataset.activityUuid);
      await activity?.use({ event });
    } finally {
      target.disabled = false;
    }
  }
}

var _module$g = /*#__PURE__*/Object.freeze({
  __proto__: null,
  ActorDataModel: ActorDataModel$1,
  ChatMessageDataModel: ChatMessageDataModel,
  ItemDataModel: ItemDataModel$1,
  SparseDataModel: SparseDataModel$1,
  SystemDataModel: SystemDataModel$1
});

/**
 * System data model for enchantment active effects.
 */
let EnchantmentData$1 = class EnchantmentData extends foundry.abstract.TypeDataModel {
  /** @override */
  static defineSchema() {
    return {};
  }

  /* -------------------------------------------- */

  /**
   * Handle enchantment-specific changes to the item.
   * @param {Item5e} item                The Item to whom this effect should be applied.
   * @param {EffectChangeData} change    The change data being applied.
   * @param {Record<string, *>} changes  The aggregate update paths and their updated values.
   * @returns {boolean|void}             Return false to prevent normal application from occurring.
   */
  _applyLegacy(item, change, changes) {
    let key = change.key.replace("system.", "");
    switch ( change.key ) {
      case "system.ability":
        for ( const activity of item.system.activities?.getByTypes("attack") ?? [] ) {
          changes[`system.activities.${activity.id}.attack.ability`] = ActiveEffect.implementation.applyField(
            activity, { ...change, key: "attack.ability" }
          );
        }
        return false;
      case "system.attack.bonus":
      case "system.attack.flat":
        for ( const activity of item.system.activities?.getByTypes("attack") ?? [] ) {
          changes[`system.activities.${activity.id}.${key}`] = ActiveEffect.implementation.applyField(
            activity, { ...change, key }
          );
        }
        return false;
      case "system.damage.bonus":
        change.key = "system.damageBonus";
        break;
      case "system.damage.parts":
        try {
          let damage;
          const parsed = JSON.parse(change.value);
          if ( foundry.utils.getType(parsed) === "Object" ) damage = new DamageData(parsed);
          else damage = new DamageData({ custom: { enabled: true, formula: parsed[0][0] }, types: [parsed[0][1]] });
          for ( const activity of item.system.activities?.getByTypes("attack", "damage", "save") ?? [] ) {
            const value = damage.clone();
            value.enchantment = true;
            value.locked = true;
            changes[`system.activities.${activity.id}.damage.parts`] = ActiveEffect.implementation.applyField(
              activity, { ...change, key, value }
            );
          }
          for ( const activity of item.system.activities?.getByTypes("heal") ?? [] ) {
            const value = damage.formula;
            const keyPath = `healing.${activity.healing.custom.enabled ? "custom.formula" : "bonus"}`;
            changes[`system.activities.${activity.id}.${keyPath}`] = ActiveEffect.implementation.applyField(
              activity, { ...change, key: keyPath, value }
            );
          }
        } catch(err) {}
        return false;
      case "system.damage.types":
        const adjust = (damage, keyPath) =>
          ActiveEffect.implementation.applyField(damage, { ...change, key: "types", value: change.value });
        if ( item.system.damage?.base ) {
          changes["system.damage.base.types"] = adjust(item.system.damage.base);
        }
        for ( const activity of item.system.activities?.getByTypes("attack", "damage", "save") ?? [] ) {
          for ( const part of activity.damage.parts ) adjust(part);
          changes[`system.activities.${activity.id}.damage.parts`] = activity.damage.parts;
        }
        return false;
      case "system.save.dc":
      case "system.save.scaling":
        let value = change.value;
        if ( key === "save.dc" ) key = "save.dc.formula";
        else {
          key = "save.dc.calculation";
          if ( value === "flat" ) value = "";
          else if ( (value === "") && (item.type === "spell") ) value = "spellcasting";
        }
        for ( const activity of item.system.activities?.getByTypes("save") ?? [] ) {
          changes[`system.activities.${activity.id}.${key}`] = ActiveEffect.implementation.applyField(
            activity, { ...change, key, value }
          );
        }
        return false;

      /** @deprecated since 5.1 */
      case "system.preparation.mode":
        foundry.utils.logCompatibilityWarning("system.preparation.mode is deprecated. Please instead use "
          + "system.method to set the spellcasting method, or system.prepared to set the preparation state.",
        { since: "DnD5e 5.1", until: "DnD5e 5.4", once: true });
        change.key = "system.method";
        if ( change.value === "always" ) {
          change.key = "system.prepared";
          change.value = "2";
        }
        break;

      /** @deprecated since 5.1 */
      case "system.preparation.prepared":
        foundry.utils.logCompatibilityWarning("system.preparation.prepared is deprecated. "
          + "Please use system.prepared instead.", { since: "DnD5e 5.1", until: "DnD5e 5.4", once: true });
        change.key = "system.prepared";
        break;
    }
  }
};

const config$5 = {
  enchantment: EnchantmentData$1
};

var _module$f = /*#__PURE__*/Object.freeze({
  __proto__: null,
  EnchantmentData: EnchantmentData$1,
  config: config$5
});

var _module$e = /*#__PURE__*/Object.freeze({
  __proto__: null,
  AppliedEffectField: AppliedEffectField,
  AttackActivityData: BaseAttackActivityData,
  BaseActivityData: BaseActivityData,
  CastActivityData: BaseCastActivityData,
  CheckActivityData: BaseCheckActivityData,
  ConsumptionError: ConsumptionError,
  ConsumptionTargetData: ConsumptionTargetData,
  ConsumptionTargetsField: ConsumptionTargetsField,
  DamageActivityData: BaseDamageActivityData,
  EnchantActivityData: BaseEnchantActivityData,
  ForwardActivityData: BaseForwardActivityData,
  HealActivityData: BaseHealActivityData,
  SaveActivityData: BaseSaveActivityData,
  SummonActivityData: BaseSummonActivityData,
  TransformActivityData: BaseTransformActivityData,
  UtilityActivityData: BaseUtilityActivityData
});

/**
 * @import { RestConfiguration, RestResult } from "../_types.mjs";
 */

/**
 * Object describing the hit dice for an actor.
 */
class HitDice {
  /**
   * Object describing the hit dice for an actor.
   * @param {Actor5e} actor     The actor whose hit dice this document describes.
   */
  constructor(actor) {
    this.actor = actor;

    for ( const item of Object.values(actor.classes) ) {
      this.classes.add(item);
      this.sizes.add(parseInt(item.system.hd.denomination.slice(1)));
    }
  }

  /* -------------------------------------------- */

  /**
   * Store a reference to the actor.
   * @type {Actor5e}
   */
  actor = null;

  /* -------------------------------------------- */

  /**
   * Remaining hit dice.
   * @type {number}
   */
  get value() {
    if ( this.#value !== undefined ) return this.#value;
    this.#value = this.classes.reduce((acc, cls) => acc + cls.system.hd.value, 0);
    return this.#value;
  }

  #value;

  /* -------------------------------------------- */

  /**
   * The actor's total amount of hit dice.
   * @type {number}
   */
  get max() {
    if ( this.#max !== undefined ) return this.#max;
    this.#max = this.classes.reduce((acc, cls) => acc + cls.system.hd.max, 0);
    return this.#max;
  }

  #max;

  /* -------------------------------------------- */

  /**
   * All valid die sizes derived from all classes.
   * @type {Set<number>}
   */
  sizes = new Set();

  /* -------------------------------------------- */

  /**
   * Store valid class items.
   * @type {Set<Item5e>}
   */
  classes = new Set();

  /* -------------------------------------------- */

  /**
   * The smallest denomination.
   * @type {string}
   */
  get smallest() {
    return `d${this.smallestFace}`;
  }

  /* -------------------------------------------- */

  /**
   * The smallest die size of those available.
   * @type {string}
   */
  get smallestAvailable() {
    const bySize = this.bySize;
    for ( const faces of Array.from(this.sizes).sort((a, b) => a - b) ) {
      if ( bySize[`d${faces}`] ) return `d${faces}`;
    }
    return "d0";
  }

  /* -------------------------------------------- */

  /**
   * The smallest die size.
   * @type {number}
   */
  get smallestFace() {
    return this.sizes.size ? Math.min(...this.sizes) : 0;
  }

  /* -------------------------------------------- */

  /**
   * The largest denomination.
   * @type {string}
   */
  get largest() {
    return `d${this.largestFace}`;
  }

  /* -------------------------------------------- */

  /**
   * The largest die size of those available.
   * @type {string}
   */
  get largestAvailable() {
    const bySize = this.bySize;
    for ( const faces of Array.from(this.sizes).sort((a, b) => b - a) ) {
      if ( bySize[`d${faces}`] ) return `d${faces}`;
    }
    return "d0";
  }

  /* -------------------------------------------- */

  /**
   * The largest die size.
   * @type {number}
   */
  get largestFace() {
    return this.sizes.size ? Math.max(...this.sizes) : 0;
  }

  /* -------------------------------------------- */

  /**
   * The percentage of remaining hit dice.
   * @type {number}
   */
  get pct() {
    return Math.clamp(this.max ? (this.value / this.max) * 100 : 0, 0, 100);
  }

  /* -------------------------------------------- */

  /**
   * Return an object of remaining hit dice categorized by size.
   * @returns {object}
   */
  get bySize() {
    const hd = {};
    this.classes.forEach(cls => {
      const d = cls.system.hd.denomination;
      hd[d] = (hd[d] ?? 0) + cls.system.hd.value;
    });
    return hd;
  }

  /* -------------------------------------------- */

  /**
   * Override the default `toString` method for backwards compatibility.
   * @returns {number}    Remaining hit dice.
   */
  toString() {
    return this.value;
  }

  /* -------------------------------------------- */

  /**
   * Create item updates for recovering hit dice during a rest.
   * @param {RestConfiguration} [config]
   * @param {number} [config.maxHitDice]    Maximum number of hit dice to recover.
   * @param {number} [config.fraction=0.5]  Fraction of max hit dice to recover. Only used if
   *                                        `maxHitDice` isn't specified.
   * @param {boolean} [config.largest]      Whether to restore the largest hit dice first.
   * @param {RestResult} [result={}]        Rest result being constructed.
   */
  createHitDiceUpdates({ maxHitDice, fraction=0.5, largest=true, ...config }={}, result={}) {
    if ( !Number.isInteger(maxHitDice) ) maxHitDice = Math.max(Math.floor(this.max * fraction), 1);
    const classes = Array.from(this.classes).sort((lhs, rhs) => {
      const sort = lhs.system.hd.denomination.localeCompare(rhs.system.hd.denomination, "en", { numeric: true });
      return largest ? sort * -1 : sort;
    });
    const updateItems = [];
    let recovered = 0;
    for ( const item of classes ) {
      const used = item.system.hd.spent;
      if ( (recovered < maxHitDice) && (used > 0) ) {
        const delta = Math.min(used, maxHitDice - recovered);
        recovered += delta;
        updateItems.push({ _id: item.id, "system.hd.spent": used - delta });
      }
    }
    foundry.utils.mergeObject(result, {
      deltas: {
        hitDice: (result?.deltas?.hitDice ?? 0) + recovered
      },
      updateItems
    });
  }
}

const { StringField: StringField$o } = foundry.data.fields;

/**
 * Field for storing creature type data.
 */
class CreatureTypeField extends foundry.data.fields.SchemaField {
  constructor(fields={}, options={}) {
    fields = {
      value: new StringField$o({ blank: true, label: "DND5E.CreatureType" }),
      subtype: new StringField$o({ label: "DND5E.CreatureTypeSelectorSubtype" }),
      swarm: new StringField$o({ blank: true, label: "DND5E.CreatureSwarmSize" }),
      custom: new StringField$o({ label: "DND5E.CreatureTypeSelectorCustom" }),
      ...fields
    };
    Object.entries(fields).forEach(([k, v]) => !v ? delete fields[k] : null);
    super(fields, { label: "DND5E.CreatureType", ...options });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  initialize(value, model, options={}) {
    const obj = super.initialize(value, model, options);

    Object.defineProperty(obj, "label", {
      get() {
        return dnd5e.documents.Actor5e.formatCreatureType(this);
      },
      enumerable: false
    });
    Object.defineProperty(obj, "config", {
      get() {
        return CONFIG.DND5E.creatureTypes[this.value];
      },
      enumerable: false
    });

    return obj;
  }
}

const { NumberField: NumberField$f, SchemaField: SchemaField$k } = foundry.data.fields;

/**
 * @import { AttackBonusesData, CreatureTemplateData, SkillData } from "./_types.mjs";
 */

/**
 * A template for all actors that are creatures.
 * @extends {CommonTemplate<CreatureTemplateData>}
 * @mixes CreatureTemplateData
 */
class CreatureTemplate extends CommonTemplate {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return this.mergeSchema(super.defineSchema(), {
      bonuses: new SchemaField$k({
        mwak: makeAttackBonuses(),
        rwak: makeAttackBonuses(),
        msak: makeAttackBonuses(),
        rsak: makeAttackBonuses(),
        abilities: new SchemaField$k({
          check: new FormulaField({ required: true }),
          save: new FormulaField({ required: true }),
          skill: new FormulaField({ required: true })
        }),
        spell: new SchemaField$k({
          dc: new FormulaField({ required: true, deterministic: true })
        })
      }),
      skills: new MappingField(new RollConfigField({
        value: new NumberField$f({
          required: true, nullable: false, min: 0, max: 2, step: 0.5, initial: 0, label: "DND5E.ProficiencyLevel"
        }),
        ability: "dex",
        bonuses: new SchemaField$k({
          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 RollConfigField({
        value: new NumberField$f({
          required: true, nullable: false, min: 0, max: 2, step: 0.5, initial: 0, label: "DND5E.ProficiencyLevel"
        }),
        ability: "int",
        bonuses: new SchemaField$k({
          check: new FormulaField({ required: true, label: "DND5E.CheckBonus" })
        }, { label: "DND5E.ToolBonuses" })
      })),
      spells: new MappingField(new SchemaField$k({
        value: new NumberField$f({
          nullable: false, integer: true, min: 0, initial: 0, label: "DND5E.SpellProgAvailable"
        }),
        override: new NumberField$f({
          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"];
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Whether this Actor type represents a creature.
   * @returns {boolean}
   */
  get isCreature() {
    return true;
  }

  /* -------------------------------------------- */
  /*  Data Migration                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static _migrateData(source) {
    super._migrateData(source);
    CreatureTemplate.#migrateSensesData(source);
    CreatureTemplate.#migrateToolData(source);
  }

  /* -------------------------------------------- */

  /**
   * Migrate the actor traits.senses string to attributes.senses object.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateSensesData(source) {
    const original = source.traits?.senses;
    if ( (original === undefined) || (typeof original !== "string") ) return;
    source.attributes ??= {};
    source.attributes.senses ??= {};

    // Try to match old senses with the format like "Darkvision 60 ft, Blindsight 30 ft"
    const pattern = /([A-z]+)\s?([0-9]+)\s?([A-z]+)?/;
    let wasMatched = false;

    // Match each comma-separated term
    for ( let s of original.split(",") ) {
      s = s.trim();
      const match = s.match(pattern);
      if ( !match ) continue;
      const type = match[1].toLowerCase();
      if ( (type in CONFIG.DND5E.senses) && !(type in source.attributes.senses) ) {
        source.attributes.senses[type] = Number(match[2]).toNearest(0.5);
        wasMatched = true;
      }
    }

    // If nothing was matched, but there was an old string - put the whole thing in "special"
    if ( !wasMatched && original ) source.attributes.senses.special = original;
  }

  /* -------------------------------------------- */

  /**
   * Migrate traits.toolProf to the tools field.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateToolData(source) {
    const original = source.traits?.toolProf;
    if ( !original || foundry.utils.isEmpty(original.value) ) return;
    source.tools ??= {};
    for ( const prof of original.value ) {
      const validProf = (prof in CONFIG.DND5E.toolProficiencies) || (prof in CONFIG.DND5E.tools);
      if ( !validProf || (prof in source.tools) ) continue;
      source.tools[prof] = {
        value: 1,
        ability: "int",
        bonuses: {check: ""}
      };
    }
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /**
   * Prepare modifiers and other values for skills.
   * @param {object} [options={}]
   * @param {object} [options.rollData={}]     Roll data used to calculate bonuses.
   * @param {object} [options.originalSkills]  Original skills data for transformed actors.
   */
  prepareSkills({ rollData={}, originalSkills }={}) {
    const globalBonuses = this.bonuses.abilities;
    const globalCheckBonus = simplifyBonus(globalBonuses.check, rollData);
    const globalSkillBonus = simplifyBonus(globalBonuses.skill, rollData);
    for ( const [id, skillData] of Object.entries(this.skills) ) {
      this.prepareSkill(id, { skillData, rollData, originalSkills, globalBonuses, globalCheckBonus, globalSkillBonus });
    }
  }

  /* -------------------------------------------- */

  /**
   * Prepares data for a specific skill.
   * @param {string} skillId                     The id of the skill to prepare data for.
   * @param {object} [options]                   Additional options.
   * @param {SkillData} [options.skillData]      The base skill data for this skill.
   *                                             If undefined, `this.system.skill[skillId]` is used.
   * @param {object} [options.rollData]          RollData for this actor, used to evaluate dice terms in bonuses.
   *                                             If undefined, `this.getRollData()` is used.
   * @param {object} [options.originalSkills]    Original skills if actor is polymorphed.
   *                                             If undefined, the skills of the actor identified by
   *                                             `this.flags.dnd5e.originalActor` are used.
   * @param {object} [options.globalBonuses]     Global ability bonuses for this actor.
   *                                             If undefined, `this.system.bonuses.abilities` is used.
   * @param {number} [options.globalCheckBonus]  Global check bonus for this actor.
   *                                             If undefined, `globalBonuses.check` will be evaluated using `rollData`.
   * @param {number} [options.globalSkillBonus]  Global skill bonus for this actor.
   *                                             If undefined, `globalBonuses.skill` will be evaluated using `rollData`.
   * @param {string} [options.ability]           The ability to compute bonuses based on.
   *                                             If undefined, skillData.ability is used.
   * @returns {SkillData}
   */
  prepareSkill(skillId, {
    skillData, rollData, originalSkills, globalBonuses,
    globalCheckBonus, globalSkillBonus, ability
  }={}) {
    const flags = this.parent.flags.dnd5e ?? {};

    skillData ??= foundry.utils.deepClone(this.skills[skillId]);
    rollData ??= this.parent.getRollData();
    originalSkills ??= flags.originalActor ? game.actors?.get(flags.originalActor)?.system?.skills : null;
    globalBonuses ??= this.bonuses.abilities ?? {};
    globalCheckBonus ??= simplifyBonus(globalBonuses.check, rollData);
    globalSkillBonus ??= simplifyBonus(globalBonuses.skill, rollData);
    ability ??= skillData.ability;
    const abilityData = this.abilities[ability];
    skillData.ability = ability;
    const baseBonus = simplifyBonus(skillData.bonuses?.check, rollData);
    const originalSkill = originalSkills?.[skillId];
    if ( originalSkill?.value >= 1 ) {
      skillData.merged = true;
      skillData.value = originalSkill?.value;
    }

    // Compute modifier
    const checkBonusAbl = simplifyBonus(abilityData?.bonuses?.check, rollData);
    skillData.effectValue = skillData.value;
    skillData.bonus = baseBonus + globalCheckBonus + checkBonusAbl + globalSkillBonus;
    skillData.mod = abilityData?.mod ?? 0;
    const calculatedProf = this.calculateAbilityCheckProficiency(skillData.value, skillData.ability);
    skillData.prof = originalSkill?.prof?.multiplier > calculatedProf.multiplier
      ? originalSkill.prof.clone() : calculatedProf;
    skillData.value = skillData.proficient = skillData.prof.multiplier;
    skillData.total = skillData.mod + skillData.bonus;
    if ( Number.isNumeric(skillData.prof.term) ) skillData.total += skillData.prof.flat;

    // If we merged skills when transforming, take the highest bonus
    const difference = (originalSkill?.total ?? 0) - skillData.total;
    if ( originalSkill && (difference > 0) ) {
      skillData.bonuses.check = `${skillData.bonuses.check ?? ""} + ${difference}`;
      skillData.bonus += difference;
      skillData.total += difference;
    }

    // Compute passive bonus
    const passive = flags.observantFeat && CONFIG.DND5E.characterFlags.observantFeat.skills.includes(skillId) ? 5 : 0;
    const passiveBonus = simplifyBonus(skillData.bonuses?.passive, rollData);
    const advantageMode = AdvantageModeField.combineFields(this, [
      `abilities.${ability}.check.roll.mode`, `skills.${skillId}.roll.mode`
    ])?.mode ?? 0;
    skillData.passive = CONFIG.DND5E.skillPassive.base + skillData.mod + skillData.bonus + skillData.prof.flat
      + passive + passiveBonus + (advantageMode * CONFIG.DND5E.skillPassive.modifier);

    return skillData;
  }

  /* -------------------------------------------- */

  /**
   * Prepare tool checks. Mutates the values of system.tools.
   * @param {object} [options={}]
   * @param {object} [options.rollData={}]     Roll data used to calculate bonuses.
   */
  prepareTools({ rollData={} }={}) {
    const globalCheckBonus = simplifyBonus(this.bonuses.abilities.check, rollData);
    for ( const tool of Object.values(this.tools) ) {
      const ability = this.abilities[tool.ability];
      const baseBonus = simplifyBonus(tool.bonuses.check, rollData);
      const checkBonusAbl = simplifyBonus(ability?.bonuses?.check, rollData);
      tool.effectValue = tool.value;
      tool.bonus = baseBonus + globalCheckBonus + checkBonusAbl;
      tool.mod = ability?.mod ?? 0;
      tool.prof = this.calculateToolProficiency(tool.value, tool.ability);
      tool.total = tool.mod + tool.bonus;
      if ( Number.isNumeric(tool.prof.term) ) tool.total += tool.prof.flat;
      tool.value = tool.prof.multiplier;
    }
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  getRollData({ deterministic=false }={}) {
    const data = super.getRollData({ deterministic });
    data.classes = {};
    data.subclasses = {};
    for ( const [identifier, cls] of Object.entries(this.parent.classes) ) {
      data.classes[identifier] = {...cls.system};
      data.classes[identifier].hitDice = cls.system.hd.denomination; // Backwards compatibility
      if ( cls.subclass ) {
        data.classes[identifier].subclass = cls.subclass.system;
        data.subclasses[cls.subclass.identifier] = { levels: cls.system.levels };
      }
    }
    return data;
  }
}

/* -------------------------------------------- */

/**
 * Produce the schema field for a simple trait.
 * @param {object} schemaOptions  Options passed to the outer schema.
 * @returns {AttackBonusesData}
 */
function makeAttackBonuses(schemaOptions={}) {
  return new SchemaField$k({
    attack: new FormulaField({required: true}),
    damage: new FormulaField({required: true})
  }, schemaOptions);
}

const {
  ArrayField: ArrayField$9, BooleanField: BooleanField$c, HTMLField: HTMLField$5, IntegerSortField, NumberField: NumberField$e, SchemaField: SchemaField$j, SetField: SetField$e, StringField: StringField$n
} = foundry.data.fields;

/**
 * @import { ActorFavorites5e, CharacterActorSystemData, ResourceData } from "./_types.mjs";
 */

/**
 * System data definition for Characters.
 * @extends {CreatureTemplate<CharacterActorSystemData>}
 * @mixes CharacterActorSystemData
 */
class CharacterData extends CreatureTemplate {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.BONUSES"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static metadata = Object.freeze(foundry.utils.mergeObject(super.metadata, {
    supportsAdvancement: true
  }, { inplace: false }));

  /* -------------------------------------------- */

  /** @inheritDoc */
  static _systemType = "character";

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return this.mergeSchema(super.defineSchema(), {
      attributes: new SchemaField$j({
        ...AttributesFields.common,
        ...AttributesFields.creature,
        hp: new SchemaField$j({
          ...AttributesFields.hitPoints,
          max: new NumberField$e({
            nullable: true, integer: true, min: 0, initial: null, label: "DND5E.HitPointsOverride",
            hint: "DND5E.HitPointsOverrideHint"
          }),
          bonuses: new SchemaField$j({
            level: new FormulaField({ deterministic: true, label: "DND5E.HitPointsBonusLevel" }),
            overall: new FormulaField({ deterministic: true, label: "DND5E.HitPointsBonusOverall" })
          })
        }, { label: "DND5E.HitPoints" }),
        death: new RollConfigField({
          ability: false,
          success: new NumberField$e({
            required: true, nullable: false, integer: true, min: 0, initial: 0, label: "DND5E.DeathSaveSuccesses"
          }),
          failure: new NumberField$e({
            required: true, nullable: false, integer: true, min: 0, initial: 0, label: "DND5E.DeathSaveFailures"
          }),
          bonuses: new SchemaField$j({
            save: new FormulaField({ required: true, label: "DND5E.DeathSaveBonus" })
          })
        }, { label: "DND5E.DeathSave" }),
        inspiration: new BooleanField$c({ required: true, label: "DND5E.Inspiration" })
      }, { label: "DND5E.Attributes" }),
      bastion: new SchemaField$j({
        name: new StringField$n({ required: true }),
        description: new HTMLField$5()
      }),
      details: new SchemaField$j({
        ...DetailsField.common,
        ...DetailsField.creature,
        background: new LocalDocumentField(foundry.documents.BaseItem, {
          required: true, fallback: true, label: "DND5E.Background"
        }),
        originalClass: new StringField$n({ required: true, label: "DND5E.ClassOriginal" }),
        xp: new SchemaField$j({
          value: new NumberField$e({
            required: true, nullable: false, integer: true, min: 0, initial: 0, label: "DND5E.ExperiencePoints.Current"
          })
        }, { label: "DND5E.ExperiencePoints.Label" }),
        appearance: new StringField$n({ required: true, label: "DND5E.Appearance" }),
        trait: new StringField$n({ required: true, label: "DND5E.PersonalityTraits" }),
        gender: new StringField$n({ label: "DND5E.Gender" }),
        eyes: new StringField$n({ label: "DND5E.Eyes" }),
        height: new StringField$n({ label: "DND5E.Height" }),
        faith: new StringField$n({ label: "DND5E.Faith" }),
        hair: new StringField$n({ label: "DND5E.Hair" }),
        skin: new StringField$n({ label: "DND5E.Skin" }),
        age: new StringField$n({ label: "DND5E.Age" }),
        weight: new StringField$n({ label: "DND5E.Weight" })
      }, { label: "DND5E.Details" }),
      traits: new SchemaField$j({
        ...TraitsField.common,
        ...TraitsField.creature,
        weaponProf: new SimpleTraitField({
          mastery: new SchemaField$j({
            value: new SetField$e(new StringField$n()),
            bonus: new SetField$e(new StringField$n())
          })
        }, { label: "DND5E.TraitWeaponProf" }),
        armorProf: new SimpleTraitField({}, { label: "DND5E.TraitArmorProf" })
      }, { label: "DND5E.Traits" }),
      resources: new SchemaField$j({
        primary: makeResourceField({ label: "DND5E.ResourcePrimary" }),
        secondary: makeResourceField({ label: "DND5E.ResourceSecondary" }),
        tertiary: makeResourceField({ label: "DND5E.ResourceTertiary" })
      }, { label: "DND5E.Resources" }),
      favorites: new ArrayField$9(new SchemaField$j({
        type: new StringField$n({ required: true, blank: false }),
        id: new StringField$n({ required: true, blank: false }),
        sort: new IntegerSortField()
      }), { label: "DND5E.Favorites" })
    });
  }

  /* -------------------------------------------- */
  /*  Data Migration                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static _migrateData(source) {
    super._migrateData(source);
    AttributesFields._migrateInitiative(source.attributes);
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareBaseData() {
    this.attributes.hd = new HitDice(this.parent);
    this.details.level = 0;
    this.attributes.attunement.value = 0;

    for ( const item of this.parent.items ) {
      if ( item.type === "class" ) this.details.level += item.system.levels;
    }

    // Character proficiency bonus
    this.attributes.prof = Proficiency.calculateMod(this.details.level);

    // Experience required for next level
    const { xp, level } = this.details;
    xp.max = level >= CONFIG.DND5E.maxLevel ? Infinity : this.parent.getLevelExp(level || 1);
    xp.min = level ? this.parent.getLevelExp(level - 1) : 0;
    if ( Number.isFinite(xp.max) ) {
      const required = xp.max - xp.min;
      const pct = Math.round((xp.value - xp.min) * 100 / required);
      xp.pct = Math.clamp(pct, 0, 100);
    } else if ( game.settings.get("dnd5e", "levelingMode") === "xpBoons" ) {
      const overflow = xp.value - this.parent.getLevelExp(CONFIG.DND5E.maxLevel);
      xp.boonsEarned = Math.max(0, Math.floor(overflow / CONFIG.DND5E.epicBoonInterval));
      const progress = overflow - (CONFIG.DND5E.epicBoonInterval * xp.boonsEarned);
      xp.pct = Math.clamp(Math.round((progress / CONFIG.DND5E.epicBoonInterval) * 100), 0, 100);
    } else {
      xp.pct = 100;
    }

    AttributesFields.prepareBaseArmorClass.call(this);
    AttributesFields.prepareBaseEncumbrance.call(this);
  }

  /* -------------------------------------------- */

  /**
   * Prepare movement & senses values derived from race item.
   */
  prepareEmbeddedData() {
    super.prepareEmbeddedData();
    if ( this.details.race instanceof Item ) {
      AttributesFields.prepareRace.call(this, this.details.race);
      this.details.type = this.details.race.system.type;
    } else {
      this.details.type = new CreatureTypeField({ swarm: false }).initialize({ value: "humanoid" }, this);
    }
    for ( const key of Object.keys(CONFIG.DND5E.movementTypes) ) this.attributes.movement[key] ??= 0;
    for ( const key of Object.keys(CONFIG.DND5E.senses) ) this.attributes.senses[key] ??= 0;
    this.attributes.movement.units ??= defaultUnits("length");
    this.attributes.senses.units ??= defaultUnits("length");
  }

  /* -------------------------------------------- */

  /**
   * Prepare remaining character data.
   */
  prepareDerivedData() {
    const rollData = this.parent.getRollData({ deterministic: true });
    const { originalSaves, originalSkills } = this.parent.getOriginalStats();

    AttributesFields.prepareExhaustionLevel.call(this);
    this.prepareAbilities({ rollData, originalSaves });
    this.prepareSkills({ rollData, originalSkills });
    this.prepareTools({ rollData });
    AttributesFields.prepareArmorClass.call(this, rollData);
    AttributesFields.prepareConcentration.call(this, rollData);
    AttributesFields.prepareEncumbrance.call(this, rollData);
    AttributesFields.prepareInitiative.call(this, rollData);
    AttributesFields.prepareMovement.call(this, rollData);
    AttributesFields.prepareSpellcastingAbility.call(this);
    TraitsField.prepareLanguages.call(this);
    TraitsField.prepareResistImmune.call(this);

    // Hit Points
    const hpOptions = {};
    if ( this.attributes.hp.max === null ) {
      hpOptions.advancement = Object.values(this.parent.classes)
        .map(c => c.advancement.byType.HitPoints?.[0]).filter(a => a);
      hpOptions.bonus = (simplifyBonus(this.attributes.hp.bonuses.level, rollData) * this.details.level)
        + simplifyBonus(this.attributes.hp.bonuses.overall, rollData);
      hpOptions.mod = this.abilities[CONFIG.DND5E.defaultAbilities.hitPoints ?? "con"]?.mod ?? 0;
    }
    AttributesFields.prepareHitPoints.call(this, this.attributes.hp, hpOptions);
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Level used to determine cantrip scaling.
   * @param {Item5e} spell  Spell for which to fetch the cantrip level.
   * @returns {number}
   */
  cantripLevel(spell) {
    return this.details.level;
  }

  /* -------------------------------------------- */

  /**
   * Checks whether the item with the given relative UUID has been favorited
   * @param {string} favoriteId  The relative UUID of the item to check.
   * @returns {boolean}
   */
  hasFavorite(favoriteId) {
    return !!this.favorites.find(f => f.id === favoriteId);
  }

  /* -------------------------------------------- */

  /**
   * Add a favorite item to this actor.
   * If the given item is already favorite, this method has no effect.
   * @param {ActorFavorites5e} favorite  The favorite to add.
   * @returns {Promise<Actor5e>}
   * @throws If the item intended to be favorited does not belong to this actor.
   */
  addFavorite(favorite) {
    if ( this.hasFavorite(favorite.id) ) return Promise.resolve(this.parent);

    if ( favorite.id.startsWith(".") && fromUuidSync(favorite.id, { relative: this.parent }) === null ) {
      // Assume that an ID starting with a "." is a relative ID.
      throw new Error(`The item with id ${favorite.id} is not owned by actor ${this.parent.id}`);
    }

    let maxSort = 0;
    const favorites = this.favorites.map(f => {
      if ( f.sort > maxSort ) maxSort = f.sort;
      return { ...f };
    });
    favorites.push({ ...favorite, sort: maxSort + CONST.SORT_INTEGER_DENSITY });
    return this.parent.update({ "system.favorites": favorites });
  }

  /* -------------------------------------------- */

  /**
   * Removes the favorite with the given relative UUID or resource ID
   * @param {string} favoriteId  The relative UUID or resource ID of the favorite to remove.
   * @returns {Promise<Actor5e>}
   */
  removeFavorite(favoriteId) {
    if ( favoriteId.startsWith("resources.") ) return this.parent.update({ [`system.${favoriteId}.max`]: 0 });
    const favorites = this.favorites.filter(f => f.id !== favoriteId);
    return this.parent.update({ "system.favorites": favorites });
  }
}

/* -------------------------------------------- */

/**
 * Produce the schema field for a simple trait.
 * @param {object} schemaOptions  Options passed to the outer schema.
 * @returns {ResourceData}
 */
function makeResourceField(schemaOptions={}) {
  return new SchemaField$j({
    value: new NumberField$e({required: true, integer: true, initial: 0, labels: "DND5E.ResourceValue"}),
    max: new NumberField$e({required: true, integer: true, initial: 0, labels: "DND5E.ResourceMax"}),
    sr: new BooleanField$c({required: true, labels: "DND5E.REST.Short.Recovery"}),
    lr: new BooleanField$c({required: true, labels: "DND5E.REST.Long.Recovery"}),
    label: new StringField$n({required: true, labels: "DND5E.ResourceLabel"})
  }, schemaOptions);
}

const { HTMLField: HTMLField$4, SchemaField: SchemaField$i } = foundry.data.fields;

/**
 * @import { CurrencyTemplateData } from "../../shared/_types.mjs";
 * @import { GroupTemplateData } from "./_types.mjs";
 */

/**
 * A template for all actors that contain collections of other actors.
 * @extends {ActorDataModel<CurrencyTemplate & GroupTemplateData>}
 * @mixes CurrencyTemplate
 * @mixes GroupTemplateData
 * @mixin
 */
class GroupTemplate extends ActorDataModel$1.mixin(CurrencyTemplate) {
  /** @inheritDoc */
  static defineSchema() {
    return this.mergeSchema(super.defineSchema(), {
      description: new SchemaField$i({
        full: new HTMLField$4({ label: "DND5E.Description" }),
        summary: new HTMLField$4({ label: "DND5E.DescriptionSummary" })
      })
    });
  }

  /* -------------------------------------------- */

  /**
   * Whether this Actor type represents a collection of multiple creatures.
   * @returns {boolean}
   */
  get isGroup() {
    return true;
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Resolve the actors in this group and return them alongside any associated data.
   * @returns {Promise<{ actor: Actor5e, [p: string]: any }[]>}
   * @abstract
   */
  async getMembers() {}

  /* -------------------------------------------- */

  /**
   * Resolve actors in this group so that they may be placed on the canvas.
   * @returns {Promise<{ actor: Actor5e, [p: string]: any }[]>}
   * @abstract
   */
  async getPlaceableMembers() {}

  /* -------------------------------------------- */

  /**
   * Place all members in the group on the current scene.
   */
  async placeMembers() {
    if ( !game.user.isGM || !canvas.scene ) return;
    const members = await this.getPlaceableMembers();
    if ( !members.length ) return;
    const minimized = !this.parent.sheet.minimized;
    await this.parent.sheet.minimize();
    const tokensData = [];

    try {
      const placements = await TokenPlacement.place({
        tokens: members.flatMap(({ actor, quantity }) =>
          Array(Number.isFinite(quantity?.value) ? quantity.value : 1).fill(actor.prototypeToken)
        )
      });
      for ( const placement of placements ) {
        const actor = placement.prototypeToken.actor;
        const appendNumber = !placement.prototypeToken.actorLink && placement.prototypeToken.appendNumber;
        delete placement.prototypeToken;
        const tokenDocument = await actor.getTokenDocument(placement);
        if ( appendNumber ) TokenPlacement.adjustAppendedNumber(tokenDocument, placement);
        tokensData.push(tokenDocument.toObject());
      }
    } catch(err) {
      Hooks.onError("GroupTemplate#placeMembers", err, {
        msg: game.i18n.localize("DND5E.Group.Warning.PlaceMembers"),
        log: "error",
        notify: "error"
      });
    } finally {
      if ( minimized ) this.parent.sheet.maximize();
    }

    await canvas.scene.createEmbeddedDocuments("Token", tokensData);
  }
}

const { ArrayField: ArrayField$8, DocumentUUIDField: DocumentUUIDField$2, NumberField: NumberField$d, SchemaField: SchemaField$h } = foundry.data.fields;

/**
 * @import { EncounterActorSystemData } from "./_types.mjs";
 */

/**
 * An Actor that represents a collection of adversaries.
 * @extends {GroupTemplate<EncounterActorSystemData>}
 * @mixes EncounterActorSystemData
 */
class EncounterData extends GroupTemplate {
  /** @inheritDoc */
  static defineSchema() {
    return this.mergeSchema(super.defineSchema(), {
      members: new ArrayField$8(new SchemaField$h({
        uuid: new DocumentUUIDField$2({ type: "Actor" }),
        quantity: new SchemaField$h({
          value: new NumberField$d({ initial: 1, integer: true, min: 0, label: "DND5E.Quantity" }),
          formula: new FormulaField({ label: "DND5E.QuantityFormula" })
        })
      }), { label: "DND5E.GroupMembers" })
    });
  }

  /* -------------------------------------------- */
  /*  Data Migration                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static _migrateData(source) {
    super._migrateData(source);
    EncounterData.#migrateMembers(source);
  }

  /* -------------------------------------------- */

  /**
   * Migrate group members from world IDs to UUIDs.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateMembers(source) {
    const { members } = source;
    if ( !members ) return;
    source.members = members.map(m => {
      if ( m.actor && !m.uuid ) {
        m.uuid = `Actor.${m.actor}`;
        delete m.actor;
      }
      return m;
    });
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareBaseData() {
    super.prepareBaseData();
    Object.defineProperty(this.members, "uuids", {
      value: new Set(this.members.map(m => m.uuid)),
      enumerable: false,
      writable: false,
      configurable: true
    });
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Add new members to the group.
   * @param {...Actor5e} actors   The actors to add.
   * @returns {Promise<Actor5e>}  The updated encounter Actor.
   */
  async addMember(...actors) {
    const members = this.toObject().members;
    for ( const actor of actors ) {
      if ( actor.type !== "npc" ) throw new Error("Only NPC actors can be part of encounters.");
      let existing = false;
      for ( const member of members ) {
        if ( (member.uuid === actor.uuid) && member.quantity?.value ) {
          member.quantity.value++;
          existing = true;
        }
      }
      if ( !existing ) members.push({ uuid: actor.uuid });
    }
    return this.parent.update({ "system.members": members });
  }

  /* -------------------------------------------- */

  /**
   * Get the difficulty for this encounter for a given party.
   * @param {Actor5e} [party]  A party to determine difficulty against, otherwise it uses the world's primary party.
   * @returns {Promise<"low"|"moderate"|"high"|null>}
   */
  async getDifficulty(party) {
    party ??= game.actors.party;
    if ( party?.type !== "group" ) return null;
    const xp = await this.getXPValue();
    const { creatures, level } = party.system;
    const [low, med] = (CONFIG.DND5E.ENCOUNTER_DIFFICULTY[level] ?? []).map(t => t * creatures.length);
    if ( !low ) return null;
    if ( xp <= low ) return "low";
    else if ( xp <= med ) return "moderate";
    return "high";
  }

  /* -------------------------------------------- */

  /** @override */
  async getMembers() {
    // Batch compendium lookups when retrieving members.
    const collections = new Map();
    const members = new Map();

    for ( const { uuid, ...rest } of this.members ) {
      const { collection, id } = foundry.utils.parseUuid(uuid);
      let ids = collections.get(collection);
      if ( !ids ) {
        ids = [];
        collections.set(collection, ids);
      }
      ids.push(id);
      rest.collection = collection;
      members.set(id, rest);
    }

    for ( const [collection, ids] of collections.entries() ) {
      if ( collection instanceof foundry.documents.collections.CompendiumCollection ) {
        await collection.getDocuments({ _id__in: ids });
      }
    }

    return Array.from(members.entries().map(([id, { collection, ...data }]) => {
      return { actor: collection.get(id), ...foundry.utils.deepClone(data) };
    }).filter(d => d.actor));
  }

  /* -------------------------------------------- */

  /** @override */
  async getPlaceableMembers() {
    return (await Promise.all((await this.getMembers()).map(async member => {
      member.actor = await dnd5e.documents.Actor5e.fetchExisting(member.actor.uuid);
      if ( (member.quantity.value === null) && member.quantity.formula ) {
        const roll = new Roll(member.quantity.formula);
        await roll.evaluate();
        if ( roll.total > 0 ) member.quantity.value = roll.total;
      }
      return member;
    }))).filter(m => m.quantity.value);
  }

  /* -------------------------------------------- */

  /**
   * Get the XP value of this encounter.
   * @returns {Promise<number>}
   */
  async getXPValue() {
    return (await this.getMembers()).reduce((xp, { actor, quantity }) => {
      return xp + ((quantity.value ?? 0) * (actor?.system.details?.xp?.value ?? 0));
    }, 0);
  }

  /* -------------------------------------------- */

  /**
   * Remove a member from the group.
   * @param {Actor5e|string} actor  An actor or its UUID to remove from this group.
   * @returns {Promise<Actor5e>}    The updated encounter Actor.
   */
  async removeMember(actor) {
    const uuid = typeof actor === "string" ? actor : actor.uuid;
    if ( !this.members.uuids.has(uuid) ) return this.parent;
    const members = this.toObject().members;
    return this.parent.update({ "system.members": members.filter(m => m.uuid !== uuid) });
  }

  /* -------------------------------------------- */

  /**
   * Roll the quantity formulas for each member and replace their quantity. Any entries without formulas
   * will not be modified.
   * @param {object} [options={}]
   * @param {number} [options.index]  Index of a specific member to roll.
   * @returns {Promise<Actor5e>}
   */
  async rollQuantities({ index }={}) {
    const membersCollection = this.toObject().members;
    await Promise.all(membersCollection.map(async (member, i) => {
      if ( !member.quantity?.formula || ((index !== undefined) && (index !== i)) ) return member;
      const roll = new Roll(member.quantity.formula);
      await roll.evaluate();
      if ( roll.total > 0 ) member.quantity.value = roll.total;
    }));
    return this.parent.update({ "system.members": membersCollection });
  }
}

const { BooleanField: BooleanField$b, DocumentIdField: DocumentIdField$4, SchemaField: SchemaField$g, SetField: SetField$d, StringField: StringField$m } = foundry.data.fields;

/**
 * A custom model to validate system flags on Group Actors.
 *
 * @property {Set<string>} awardDestinations       Saved targets from previous use of award button.
 * @property {object} [restSettings]
 * @property {boolean} [restSettings.autoRest]     Saved Auto Rest setting from previous group rest.
 * @property {Set<string>} [restSettings.targets]  Saved targets form previous group rest.
 */
class GroupSystemFlags extends foundry.abstract.DataModel {
  /** @override */
  static defineSchema() {
    return {
      awardDestinations: new SetField$d(new DocumentIdField$4(), { required: false }),
      inventorySource: new StringField$m({ blank: false }),
      restSettings: new SchemaField$g({
        autoRest: new BooleanField$b(),
        targets: new SetField$d(new DocumentIdField$4())
      }, { required: false, nullable: true, initial: null }),
      showTokenPortrait: new BooleanField$b()
    };
  }
}

const { ArrayField: ArrayField$7, ForeignDocumentField: ForeignDocumentField$3, NumberField: NumberField$c, SchemaField: SchemaField$f } = foundry.data.fields;

/**
 * @import { SkillToolRollProcessConfiguration } from "../../dice/_types.mjs";
 * @import { RestResult } from "../../documents/_types.mjs";
 * @import { GroupActorSystemData, GroupRestConfiguration, TravelPaceDescriptor } from "./_types.mjs";
 */

/**
 * A data model and API layer which handles the schema and functionality of "group" type Actors in the dnd5e system.
 * @extends {GroupTemplate<GroupActorSystemData>}
 * @mixes GroupActorSystemData
 */
class GroupData extends GroupTemplate {
  /** @inheritDoc */
  static defineSchema() {
    return this.mergeSchema(super.defineSchema(), {
      attributes: new SchemaField$f({
        travel: new TravelField({}, {
          initialTime: () => CONFIG.DND5E.travelTimes.group, initialUnits: () => defaultUnits("travel")
        })
      }, { label: "DND5E.Attributes" }),
      details: new SchemaField$f({
        xp: new SchemaField$f({
          value: new NumberField$c({ integer: true, min: 0, label: "DND5E.ExperiencePoints.Current" })
        }, { label: "DND5E.ExperiencePoints.Label" })
      }, { label: "DND5E.Details" }),
      members: new ArrayField$7(new SchemaField$f({
        actor: new ForeignDocumentField$3(foundry.documents.BaseActor)
      }), { label: "DND5E.GroupMembers" }),
      primaryVehicle: new ForeignDocumentField$3(foundry.documents.BaseActor)
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static metadata = Object.freeze(foundry.utils.mergeObject(super.metadata, {
    systemFlagsModel: GroupSystemFlags
  }, {inplace: false}));

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Return only group members that are creatures.
   * @type {Actor5e[]}
   */
  get creatures() {
    return this.members.reduce((acc, { actor }) => {
      if ( actor?.system.isCreature ) acc.push(actor);
      return acc;
    }, []);
  }

  /* -------------------------------------------- */

  /** @override */
  get transferDestinations() {
    return this.members.map(m => m.actor).filter(a => a.isOwner);
  }

  /* -------------------------------------------- */

  /**
   * Return only the group members that are characters.
   * @type {Actor5e[]}
   */
  get playerCharacters() {
    return this.members.reduce((acc, { actor }) => {
      if ( actor?.type === "character" ) acc.push(actor);
      return acc;
    }, []);
  }

  /* -------------------------------------------- */

  /**
   * Calculate the party's average level for the purpose determining encounter XP budget.
   * @type {number}
   */
  get level() {
    const pcs = this.playerCharacters;
    if ( !pcs.length ) return 0;
    return Math.round(pcs.reduce((acc, a) => acc + a.system.details.level, 0) / pcs.length);
  }

  /* -------------------------------------------- */
  /*  Data Migration                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static _migrateData(source) {
    super._migrateData(source);
    GroupData.#migrateMembers(source);
    GroupData.#migrateTravel(source);
  }

  /* -------------------------------------------- */

  /**
   * Migrate group members from set of IDs into array of metadata objects.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateMembers(source) {
    if ( foundry.utils.getType(source.members) !== "Array" ) return;
    source.members = source.members.map(m => {
      if ( foundry.utils.getType(m) === "Object" ) return m;
      return { actor: m };
    });
  }

  /* -------------------------------------------- */

  /**
   * Migrate travel speeds from `attributes.movement` to `attributes.travel`.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateTravel(source) {
    if ( !source.attributes?.movement ) return;
    const travel = source.attributes.travel ?? {};
    travel.paces ??= {};
    let { pace, units, ...paces } = source.attributes.movement;
    const config = CONFIG.DND5E.movementUnits[units];
    const finalUnit = config?.type === "metric" ? "kph" : "mph";
    const perRound = config?.travelResolution === "round";
    Object.keys(paces).forEach(k => {
      if ( travel.paces?.[k] !== undefined ) return;
      if ( perRound ) paces[k] = TravelField.convertMovementToTravel(paces[k], units, finalUnit) * 8;
      if ( paces[k] ) travel.paces[k] = paces[k];
    });
    if ( pace && (travel.pace === undefined) ) travel.pace = pace;
    if ( units && (travel.units === undefined) ) travel.units = finalUnit;
    delete source.attributes.movement;
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareBaseData() {
    const memberIds = new Set();
    this.members = this.members.filter((member, index) => {
      if ( !member.actor ) {
        const id = this._source.members[index]?.actor;
        console.warn(`Actor "${id}" in group "${this.parent.id}" does not exist within the World.`);
      } else if ( member.actor?.system.isGroup ) {
        console.warn(`Group "${this.parent.id}" may not contain another Group "${member.actor.id}" as a member.`);
      } else if ( memberIds.has(member.actor.id) ) {
        console.warn(`Actor "${member.actor.id}" duplicated in Group "${this.parent.id}".`);
      } else {
        memberIds.add(member.actor.id);
        return true;
      }
      return false;
    });
    Object.defineProperty(this.members, "ids", {
      value: memberIds,
      enumerable: false,
      writable: false
    });
    if ( !memberIds.has(this.primaryVehicle?.id) || (this.primaryVehicle?.type !== "vehicle") ) {
      Object.defineProperty(this, "primaryVehicle", { value: null });
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareDerivedData() {
    const rollData = this.parent.getRollData({ deterministic: true });
    TravelField.prepareData.call(this, rollData);
  }

  /* -------------------------------------------- */
  /*  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.system.isGroup ) {
      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.");
    if ( this.members.ids.has(actor.id) ) return this.parent;
    const membersCollection = this.toObject().members;
    membersCollection.push({ actor: actor.id });
    return this.parent.update({"system.members": membersCollection});
  }

  /* -------------------------------------------- */

  /** @override */
  async getMembers() {
    return this.members.map(({ actor, ...data }) => ({ actor, ...data })).filter(d => d.actor);
  }

  /* -------------------------------------------- */

  /** @override */
  async getPlaceableMembers() {
    return this.getMembers();
  }

  /* -------------------------------------------- */

  /**
   * Get information on travel pace for this group.
   * @returns {TravelPaceDescriptor}
   */
  getTravelPace() {
    let { pace } = this.attributes.travel;
    const slowed = this.members.some(({ actor }) => {
      return actor?.system.isCreature && actor?.system.attributes?.movement?.slowed;
    });
    const travelPaces = Object.keys(CONFIG.DND5E.travelPace);
    const slow = travelPaces.indexOf("slow");
    if ( slowed && (travelPaces.indexOf(pace) > slow) ) pace = "slow";
    const { travel: vehicleTravel } = this.primaryVehicle?.system.attributes ?? {};
    const paces = { ...(vehicleTravel?.paces ?? this.attributes.travel.paces) };
    if ( (slowed && !this.primaryVehicle) || (this.primaryVehicle?.system.details.type === "land") ) {
      const { units } = vehicleTravel ?? this.attributes.travel;
      const unitConfig = CONFIG.DND5E.travelUnits[units];
      for ( const [k, v] of Object.entries(vehicleTravel?.paces ?? this.attributes.travel.prePace) ) {
        paces[k] = TravelField.applyPaceMultiplier(v, pace, unitConfig.type);
      }
    }
    return {
      pace: {
        slowed,
        available: !this.primaryVehicle || (this.primaryVehicle.system.details.type === "land"),
        label: CONFIG.DND5E.travelPace[pace]?.label,
        value: pace
      },
      paces: { ...paces }
    };
  }

  /* -------------------------------------------- */

  /**
   * 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) {
    // 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");

    // Remove the actor and update the parent document
    const membersCollection = this.toObject().members;
    if ( !membersCollection.findSplice(member => member.actor === actorId) ) {
      throw new Error(`Actor id "${actorId}" is not a group member`);
    }
    return this.parent.update({"system.members": membersCollection});
  }

  /* -------------------------------------------- */

  /**
   * Request a group ability check with a given skill.
   * @param {Partial<SkillToolRollProcessConfiguration>} config  Roll configuration.
   * @returns {Promise<false|void>}
   */
  async rollSkill(config) {
    if ( !config.skill ) return;
    const skillConfig = CONFIG.DND5E.skills[config.skill];
    const ability = config.ability ?? skillConfig?.ability ?? "";
    const skillLabel = skillConfig?.label ?? "";
    const abilityLabel = CONFIG.DND5E.abilities[ability]?.label ?? "";
    await foundry.documents.ChatMessage.implementation.create({
      flavor: game.i18n.format("DND5E.SkillPromptTitle", { skill: skillLabel, ability: abilityLabel }),
      speaker: ChatMessage.getSpeaker({ actor: this.parent, alias: this.parent.name }),
      system: {
        button: {
          icon: "fa-solid fa-dice-d20",
          label: game.i18n.localize("DND5E.SkillRoll", { skill: skillLabel, ability: abilityLabel })
        },
        data: { ...config },
        handler: "skill",
        targets: this.members.flatMap(({ actor }) => {
          if ( actor.system.skills ) return { actor: actor.uuid };
          return [];
        })
      },
      type: "request"
    });
    return false;
  }

  /* -------------------------------------------- */
  /*  Resting                                     */
  /* -------------------------------------------- */

  /**
   * Initiate a rest for all members of this group.
   * @param {GroupRestConfiguration} config  Configuration data for the rest.
   * @param {RestResult} result              Results of the rest operation being built.
   * @returns {boolean}                      Returns `false` to prevent regular rest process from completing.
   */
  async rest(config, result) {
    const targets = this.members
      .map(({ actor }) => !config.targets || config.targets.includes(actor.id) ? actor : null)
      .filter(_ => _);

    // Create a rest chat message
    if ( !config.autoRest ) {
      const restConfig = CONFIG.DND5E.restTypes[config.type];
      const messageData = {
        flavor: this.parent.createRestFlavor(config),
        speaker: ChatMessage.getSpeaker({ actor: this.parent, alias: this.parent.name }),
        system: {
          button: {
            icon: restConfig.icon ?? "fa-solid fa-bed",
            label: restConfig.label
          },
          data: {
            newDay: config.newDay === true,
            recoverTemp: config.recoverTemp === true,
            recoverTempMax: config.recoverTempMax === true,
            type: config.type
          },
          handler: "rest",
          targets: targets.map(t => ({ actor: t.uuid }))
        },
        type: "request"
      };
      await ChatMessage.create(messageData);
    }

    const results = new Map();
    for ( const actor of targets ) {
      results.set(
        actor,
        config.autoRest ? await actor[config.type === "short" ? "shortRest" : "longRest"]({
          ...config, dialog: false, advanceBastionTurn: false, advanceTime: false
        }) ?? null : null
      );
    }

    // Advance the game clock
    if ( config.advanceTime && (config.duration > 0) && game.user.isGM ) await game.time.advance(60 * config.duration);

    /**
     * A hook event that fires when the rest process is completed for a group.
     * @function dnd5e.groupRestCompleted
     * @memberof hookEvents
     * @param {Actor5e} group                         The group that just completed resting.
     * @param {Map<Actor5e, RestResult|null>} result  Details on the rests completed.
     */
    Hooks.callAll("dnd5e.groupRestCompleted", this.parent, results);

    if ( config.advanceBastionTurn && game.user.isGM && game.settings.get("dnd5e", "bastionConfiguration").enabled ) {
      await dnd5e.bastion.advanceAllBastions();
    }

    return false;
  }
}

/* -------------------------------------------- */

/**
 * @deprecated
 * @since 5.1.0
 */
class GroupActor extends GroupData {
  constructor(...args) {
    foundry.utils.logCompatibilityWarning("GroupActor is deprecated. Please use GroupData instead.", {
      since: "DnD5e 5.1", until: "DnD5e 5.3"
    });
    super(...args);
  }
}

const TextEditor$2 = foundry.applications.ux.TextEditor.implementation;
const { ArrayField: ArrayField$6, BooleanField: BooleanField$a, NumberField: NumberField$b, SchemaField: SchemaField$e, SetField: SetField$c, StringField: StringField$l } = foundry.data.fields;

/**
 * @import { NPCActorSystemData } from "./_types.mjs";
 */

/**
 * System data definition for NPCs.
 * @extends {CreatureTemplate<NPCActorSystemData>}
 * @mixes NPCActorSystemData
 */
class NPCData extends CreatureTemplate {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.NPC", "DND5E.BONUSES", "DND5E.SOURCE"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static metadata = Object.freeze(foundry.utils.mergeObject(super.metadata, {
    supportsAdvancement: true
  }, { inplace: false }));

  /* -------------------------------------------- */

  /** @inheritDoc */
  static _systemType = "npc";

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return this.mergeSchema(super.defineSchema(), {
      attributes: new SchemaField$e({
        ...AttributesFields.common,
        ...AttributesFields.creature,
        hd: new SchemaField$e({
          spent: new NumberField$b({ integer: true, min: 0, initial: 0 })
        }, { label: "DND5E.HitDice" }),
        hp: new SchemaField$e({
          ...AttributesFields.hitPoints,
          formula: new FormulaField({ required: true, label: "DND5E.HPFormula" })
        }, { label: "DND5E.HitPoints" }),
        death: new RollConfigField({
          ability: false,
          success: new NumberField$b({
            required: true, nullable: false, integer: true, min: 0, initial: 0, label: "DND5E.DeathSaveSuccesses"
          }),
          failure: new NumberField$b({
            required: true, nullable: false, integer: true, min: 0, initial: 0, label: "DND5E.DeathSaveFailures"
          }),
          bonuses: new SchemaField$e({
            save: new FormulaField({ required: true, label: "DND5E.DeathSaveBonus" })
          })
        }, { label: "DND5E.DeathSave" }),
        price: new SchemaField$e({
          value: new NumberField$b({ initial: null, min: 0 }),
          denomination: new StringField$l({ required: true, blank: false, initial: "gp" })
        }),
        spell: new SchemaField$e({
          level: new NumberField$b({
            required: true, nullable: false, integer: true, min: 0, initial: 0, label: "DND5E.SpellcasterLevel"
          })
        })
      }, { label: "DND5E.Attributes" }),
      details: new SchemaField$e({
        ...DetailsField.common,
        ...DetailsField.creature,
        type: new CreatureTypeField(),
        habitat: new SchemaField$e({
          value: new ArrayField$6(new SchemaField$e({
            type: new StringField$l({ required: true }),
            subtype: new StringField$l()
          })),
          custom: new StringField$l({ required: true })
        }),
        cr: new NumberField$b({
          required: true, nullable: true, min: 0, initial: 1, label: "DND5E.ChallengeRating"
        }),
        treasure: new SchemaField$e({
          value: new SetField$c(new StringField$l())
        })
      }, { label: "DND5E.Details" }),
      resources: new SchemaField$e({
        legact: new SchemaField$e({
          max: new NumberField$b({
            required: true, nullable: false, integer: true, min: 0, initial: 0, label: "DND5E.LegendaryAction.Max"
          }),
          spent: new NumberField$b({
            required: true, nullable: false, integer: true, min: 0, initial: 0, label: "DND5E.LegendaryAction.Spent"
          })
        }, { label: "DND5E.LegendaryAction.Label" }),
        legres: new SchemaField$e({
          max: new NumberField$b({
            required: true, nullable: false, integer: true, min: 0, initial: 0, label: "DND5E.LegendaryResistance.Max"
          }),
          spent: new NumberField$b({
            required: true, nullable: false, integer: true, min: 0, initial: 0, label: "DND5E.LegendaryResistance.Spent"
          })
        }, { label: "DND5E.LegendaryResistance.Label" }),
        lair: new SchemaField$e({
          value: new BooleanField$a({required: true, label: "DND5E.LAIR.Action.Uses"}),
          initiative: new NumberField$b({
            required: true, integer: true, label: "DND5E.LAIR.Action.Initiative"
          }),
          inside: new BooleanField$a({ label: "DND5E.LAIR.Inside" })
        }, { label: "DND5E.LAIR.Action.Label" })
      }, { label: "DND5E.Resources" }),
      source: new SourceField(),
      traits: new SchemaField$e({
        ...TraitsField.common,
        ...TraitsField.creature,
        important: new BooleanField$a()
      }, { label: "DND5E.Traits" })
    });
  }

  /* -------------------------------------------- */

  /** @override */
  static get compendiumBrowserFilters() {
    return new Map([
      ["size", {
        label: "DND5E.Size",
        type: "set",
        config: {
          choices: CONFIG.DND5E.actorSizes,
          keyPath: "system.traits.size"
        }
      }],
      ["type", {
        label: "DND5E.CreatureType",
        type: "set",
        config: {
          choices: CONFIG.DND5E.creatureTypes,
          keyPath: "system.details.type.value"
        }
      }],
      ["habitat", {
        label: "DND5E.Habitat.Configuration.Label",
        type: "set",
        config: {
          choices: CONFIG.DND5E.habitats
        },
        createFilter: (filters, value, def) => {
          const { include, exclude } = Object.entries(value).reduce((d, [key, value]) => {
            if ( value === 1 ) d.include.push(key);
            else if ( value === -1 ) d.exclude.push(key);
            return d;
          }, { include: [], exclude: [] });
          if ( include.length ) filters.push({
            k: "system.details.habitat.value", o: "has", v: { k: "type", o: "in", v: include }
          });
          if ( exclude.length ) filters.push({
            o: "NOT", v: { k: "system.details.habitat.value", o: "has", v: { k: "type", o: "in", v: exclude } }
          });
        }
      }],
      ["cr", {
        label: "DND5E.ChallengeRating",
        type: "range",
        config: {
          keyPath: "system.details.cr",
          min: 0,
          max: 30
        }
      }],
      ["movement", {
        label: "DND5E.Movement",
        type: "set",
        config: {
          choices: CONFIG.DND5E.movementTypes
        },
        createFilter: (filters, value, def) => {
          for ( const [k, v] of Object.entries(value ?? {}) ) {
            if ( v === 1 ) filters.push({ k: `system.attributes.movement.${k}`, o: "gt", v: 0 });
            if ( v === -1 ) filters.push({ o: "NOT", v: { k: `system.attributes.movement.${k}`, o: "gt", v: 0 } });
          }
        }
      }]
    ]);
  }

  /* -------------------------------------------- */
  /*  Data Migration                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static _migrateData(source) {
    super._migrateData(source);
    NPCData.#migrateEnvironment(source);
    NPCData.#migrateLegendaries(source, "legact");
    NPCData.#migrateLegendaries(source, "legres");
    NPCData.#migrateSource(source);
    NPCData.#migrateSpellLevel(source);
    NPCData.#migrateTypeData(source);
    AttributesFields._migrateInitiative(source.attributes);
  }

  /* -------------------------------------------- */

  /**
   * Convert the plain string environment to a custom habitat.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateEnvironment(source) {
    const custom = source.details?.environment;
    if ( (typeof custom === "string") && !("habitat" in source.details) ) source.details.habitat = { custom };
  }

  /* -------------------------------------------- */

  /**
   * Convert legendary action & resistance `value` to `spent`.
   * @param {object} source  The candidate source data from which the model will be constructed.
   * @param {string} prop    The property to migrate.
   * @since 5.1.0
   */
  static #migrateLegendaries(source, prop) {
    const resource = source.resources?.[prop];
    if ( !resource || !("max" in resource) || !("value" in resource) || ("spent" in resource) ) return;
    source.resources[prop].spent = resource.max - resource.value;
    delete resource.value;
  }

  /* -------------------------------------------- */

  /**
   * Convert source string into custom object & move to top-level.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateSource(source) {
    let custom;
    if ( ("details" in source) && ("source" in source.details) ) {
      if ( foundry.utils.getType(source.details?.source) === "string" ) custom = source.details.source;
      else source.source = { ...(source.source ?? {}), ...source.details.source };
    }
    if ( custom ) {
      source.source ??= {};
      source.source.custom = custom;
    }
  }

  /* -------------------------------------------- */

  /**
   * Move spell level from `details.spellLevel` to `attributes.spell.level`.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateSpellLevel(source) {
    if ( source.details?.spellLevel !== undefined ) {
      source.attributes ??= {};
      source.attributes.spell ??= {};
      source.attributes.spell.level ??= source.details.spellLevel;
    }
  }

  /* -------------------------------------------- */

  /**
   * 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.label).toLowerCase())
          || (typeLc === game.i18n.localize(`${v.label}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.label).toLowerCase());
        });
        source.type.swarm = sizeMatch ? sizeMatch[0] : "tiny";
      }
      else source.type.swarm = "";
    }

    // No match found
    else {
      source.type.value = "custom";
      source.type.custom = original;
    }
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareBaseData() {
    this.details.level = 0;
    this.attributes.attunement.value = 0;

    // Determine hit dice denomination & max from hit points formula
    const [, max, denomination] = this.attributes.hp.formula?.match(/(\d*)d(\d+)/i) ?? [];
    this.attributes.hd.max = Number(max ?? 0);
    this.attributes.hd.denomination = Number(denomination ?? CONFIG.DND5E.actorSizes[this.traits.size]?.hitDie ?? 4);

    for ( const item of this.parent.items ) {
      // Class levels & hit dice
      if ( item.type === "class" ) {
        const classLevels = parseInt(item.system.levels) ?? 1;
        this.details.level += classLevels;
        this.attributes.hd.max += classLevels;
      }
    }

    const lairAdjustment = Number(this.resources.lair.value && this.resources.lair.inside);

    // Kill Experience
    this.details.xp ??= {};
    this.details.xp.value = this.parent.getCRExp(this.details.cr === null ? null : this.details.cr + lairAdjustment);

    // Legendary Resistances/Actions
    this.resources.legact.lr = true;
    this.resources.legres.lr = true;
    if ( this.resources.legact.max ) this.resources.legact.max += lairAdjustment;
    if ( this.resources.legres.max ) this.resources.legres.max += lairAdjustment;

    // Proficiency
    if ( this.details.cr === null ) this.attributes.prof = null;
    else this.attributes.prof = Proficiency.calculateMod(Math.max(this.details.cr, this.details.level, 1));

    // Spellcaster Level
    if ( this.attributes.spellcasting && !Number.isNumeric(this.attributes.spell.level) ) {
      this.attributes.spell.level = Math.max(this.details.cr, 1);
    }

    AttributesFields.prepareBaseArmorClass.call(this);
    AttributesFields.prepareBaseEncumbrance.call(this);
  }

  /* -------------------------------------------- */

  /**
   * Prepare movement & senses values derived from race item.
   */
  prepareEmbeddedData() {
    super.prepareEmbeddedData();
    if ( this.details.race instanceof Item ) {
      AttributesFields.prepareRace.call(this, this.details.race, { force: true });
      this.details.type = this.details.race.system.type;
    }
    for ( const key of Object.keys(CONFIG.DND5E.movementTypes) ) this.attributes.movement[key] ??= 0;
    for ( const key of Object.keys(CONFIG.DND5E.senses) ) this.attributes.senses[key] ??= 0;
    this.attributes.movement.units ??= defaultUnits("length");
    this.attributes.senses.units ??= defaultUnits("length");
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareDerivedData() {
    const rollData = this.parent.getRollData({ deterministic: true });
    const { originalSaves, originalSkills } = this.parent.getOriginalStats();

    this.prepareAbilities({ rollData, originalSaves });
    this.prepareSkills({ rollData, originalSkills });
    this.prepareTools({ rollData });
    AttributesFields.prepareArmorClass.call(this, rollData);
    AttributesFields.prepareConcentration.call(this, rollData);
    AttributesFields.prepareEncumbrance.call(this, rollData);
    AttributesFields.prepareExhaustionLevel.call(this);
    AttributesFields.prepareInitiative.call(this, rollData);
    AttributesFields.prepareMovement.call(this, rollData);
    AttributesFields.prepareSpellcastingAbility.call(this);
    SourceField.prepareData.call(this.source, this.parent._stats?.compendiumSource ?? this.parent.uuid);
    TraitsField.prepareLanguages.call(this);
    TraitsField.prepareResistImmune.call(this);

    // Hit Dice
    const { hd } = this.attributes;
    hd.value = Math.max(0, hd.max - hd.spent);
    hd.pct = Math.clamp(hd.max ? (hd.value / hd.max) * 100 : 0, 0, 100);

    // Hit Points
    const hpOptions = {
      advancement: Object.values(this.parent.classes).map(c => c.advancement.byType.HitPoints?.[0]).filter(a => a),
      mod: this.abilities[CONFIG.DND5E.defaultAbilities.hitPoints ?? "con"]?.mod ?? 0
    };
    AttributesFields.prepareHitPoints.call(this, this.attributes.hp, hpOptions);

    // Legendary Actions & Resistances
    const { legact, legres } = this.resources;
    legact.value = Math.clamp(legact.max - legact.spent, 0, legact.max);
    legres.value = Math.clamp(legres.max - legres.spent, 0, legres.max);
    this.resources.legact.label = this.getLegendaryActionsDescription();
    const legendaryResistanceItem = this.parent.identifiedItems.get("legendary-resistance", { type: "feat" })?.first();
    if ( legres.max && legendaryResistanceItem ) {
      const max = this._source.resources.legres.max;
      const modernRules = (this.source?.rules
        || (game.settings.get("dnd5e", "rulesVersion") === "modern" ? "2024" : "2014")) === "2024";
      legendaryResistanceItem.system.uses.label = this.resources.lair.value && modernRules ? game.i18n.format(
        "DND5E.LegendaryResistance.LairUses",  { normal: formatNumber(max), lair: formatNumber(max + 1) }
      ) : `${formatNumber(max)}/${CONFIG.DND5E.limitedUsePeriods.day?.label ?? ""}`;
    }
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preUpdate(changes, options, user) {
    if ( (await super._preUpdate(changes, options, user)) === false ) return false;
    for ( const k of ["legact", "legres"] ) {
      if ( !foundry.utils.hasProperty(changes, `system.resources.${k}.value`) ) continue;
      const spent = this.resources[k].max - changes.system.resources[k].value;
      foundry.utils.setProperty(changes, `system.resources.${k}.spent`, spent);
    }
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Level used to determine cantrip scaling.
   * @param {Item5e} spell  Spell for which to fetch the cantrip level.
   * @returns {number}
   */
  cantripLevel(spell) {
    if ( spell.system.method === "innate" ) return this.details.cr;
    return this.details.level ? this.details.level : this.attributes.spell.level;
  }

  /* -------------------------------------------- */

  /**
   * Auto-generate a description for the legendary actions block on the NPC stat block.
   * @param {string} name  Name of the actor to use in the text.
   * @returns {string}
   */
  getLegendaryActionsDescription(name=this.parent.name) {
    const max = this._source.resources.legact.max;
    if ( !max ) return "";
    const pr = getPluralRules().select(max);
    const rulesVersion = this.source?.rules
      || (game.settings.get("dnd5e", "rulesVersion") === "modern" ? "2024" : "2014");
    return game.i18n.format(`DND5E.LegendaryAction.Description${rulesVersion === "2014" ? "Legacy" : ""}`, {
      name: name.toLowerCase(),
      uses: this.resources.lair.value ? game.i18n.format("DND5E.LegendaryAction.LairUses", {
        normal: formatNumber(max), lair: formatNumber(max + 1)
      }) : formatNumber(max),
      usesNamed: game.i18n.format(`DND5E.ACTIVATION.Type.Legendary.Counted.${pr}`, { number: formatNumber(max) })
    });
  }

  /* -------------------------------------------- */

  /**
   * Create a list of gear that can be collected from this NPC.
   * @type {Item5e[]}
   */
  getGear() {
    return this.parent.items
      .filter(i => i.system.quantity && (i.system.type?.value !== "natural"))
      .sort((lhs, rhs) => lhs.name.localeCompare(rhs.name, game.i18n.lang));
  }

  /* -------------------------------------------- */

  /** @override */
  async recoverCombatUses(periods, results) {
    // Reset legendary actions at the start of a combat encounter or at the end of the creature's turn
    if ( this.resources.legact.max && (periods.includes("encounter") || periods.includes("turnEnd")) ) {
      results.actor["system.resources.legact.spent"] = 0;
    }
  }

  /* -------------------------------------------- */

  /**
   * Spend a legendary resistance to change a failed saving throw into a success.
   * @param {ChatMessage5e} message  The chat message containing the failed save.
   */
  async resistSave(message) {
    if ( this.resources.legres.value === 0 ) throw new Error("No legendary resistances remaining.");
    if ( message.flags.dnd5e?.roll?.type !== "save" ) throw new Error("Chat message must contain a save roll.");
    if ( message.flags.dnd5e?.roll?.forceSuccess ) throw new Error("Save has already been resisted.");
    await this.parent.update({ "system.resources.legres.spent": this.resources.legres.spent + 1 });
    await message.setFlag("dnd5e", "roll.forceSuccess", true);
  }

  /* -------------------------------------------- */

  /** @override */
  async toEmbed(config, options={}) {
    for ( const value of config.values ) {
      if ( value === "statblock" ) config.statblock = true;
    }
    if ( !config.statblock ) return super.toEmbed(config, options);

    const rulesVersion = getRulesVersion(config, { ...options, relativeTo: this.parent });
    const context = await this._prepareEmbedContext(rulesVersion);
    context.name = config.label || this.parent.name;
    if ( config.cite && !config.inline ) {
      config.cite = false;
      context.anchor = this.parent.toAnchor({ name: context.name }).outerHTML;
    }
    const template = document.createElement("template");
    template.innerHTML = await foundry.applications.handlebars.renderTemplate(
      "systems/dnd5e/templates/actors/embeds/npc-embed.hbs", context
    );

    /**
     * A hook event that fires after an embedded NPC stat block is rendered.
     * @function dnd5e.renderNPCStatBlock
     * @memberof hookEvents
     * @param {Actor5e} actor                   NPC being embedded.
     * @param {HTMLTemplateElement} template    Template whose children will be embedded.
     * @param {DocumentHTMLEmbedConfig} config  Configuration for embedding behavior.
     * @param {EnrichmentOptions} options       Original enrichment options.
     */
    Hooks.call("dnd5e.renderNPCStatBlock", this.parent, template, config, options);

    return template.content;
  }

  /* -------------------------------------------- */

  /**
   * Prepare the context information for the embed template rendering.
   * @param {"2014"|"2024"} rulesVersion  Version of the stat block styling to use.
   * @returns {object}
   */
  async _prepareEmbedContext(rulesVersion) {
    const formatter = game.i18n.getListFormatter({ type: "unit" });
    const prepareMeasured = (value, units, label) => label
      ? `${rulesVersion === "2024" ? label : label.toLowerCase()} ${formatLength(value, units)}`
      : formatLength(value, units);
    const prepareTrait = ({ value, custom }, trait$1) => formatter.format([
      ...Array.from(value).map(t => keyLabel(t, { trait: trait$1 })).filter(_ => _),
      ...splitSemicolons(custom ?? "")
    ].sort((lhs, rhs) => lhs.localeCompare(rhs, game.i18n.lang)));
    const o = this.parent.flags.dnd5e?.statBlockOverride ?? {};

    const prepareSpeed = () => {
      const standard = formatter.format([
        prepareMeasured(this.attributes.movement.walk, this.attributes.movement.units),
        ...Object.entries(CONFIG.DND5E.movementTypes)
          .filter(([k]) => this.attributes.movement[k] && (k !== "walk"))
          .map(([k, { label }]) => {
            let prepared = prepareMeasured(this.attributes.movement[k], this.attributes.movement.units, label);
            if ( (k === "fly") && this.attributes.movement.hover ) {
              prepared = `${prepared} (${game.i18n.localize("DND5E.MOVEMENT.Hover").toLowerCase()})`;
            }
            return prepared;
          })
      ]);
      const custom = formatter.format(splitSemicolons(this.attributes.movement.special));
      return custom ? `${standard} (${custom})` : standard;
    };

    const xp = rulesVersion === "2024"
      ? `${o.xp ?? game.i18n.format(`DND5E.ExperiencePoints.StatBlock.${
        (this.resources.lair.value) && (this.details.cr !== null) ? "Lair" : "Standard"}`, {
        value: formatNumber(this.parent.getCRExp(this.details.cr)),
        lair: formatNumber(this.parent.getCRExp(this.details.cr + 1))
      })}; ${o.pb ?? `${game.i18n.localize("DND5E.ProficiencyBonusAbbr")} ${
        formatNumber(this.attributes.prof, { signDisplay: "always" })}`}`
      : o.xp ?? game.i18n.format("DND5E.ExperiencePoints.Format", {
        value: formatNumber(this.parent.getCRExp(this.details.cr))
      });

    const context = {
      abilityTables: rulesVersion === "2024" ? Array.fromRange(3).map(_ => ({ abilities: [] })) : null,
      actionSections: {
        trait: {
          label: game.i18n.localize("DND5E.NPC.SECTIONS.Traits"),
          hideLabel: rulesVersion === "2014",
          actions: []
        },
        action: {
          label: game.i18n.localize("DND5E.NPC.SECTIONS.Actions"),
          actions: []
        },
        bonus: {
          label: game.i18n.localize("DND5E.NPC.SECTIONS.BonusActions"),
          actions: []
        },
        reaction: {
          label: game.i18n.localize("DND5E.NPC.SECTIONS.Reactions"),
          actions: []
        },
        legendary: {
          label: game.i18n.localize("DND5E.NPC.SECTIONS.LegendaryActions"),
          description: "",
          actions: []
        },
        mythic: {
          label: game.i18n.localize("DND5E.NPC.SECTIONS.MythicActions"),
          description: "",
          actions: []
        }
      },
      CONFIG: CONFIG.DND5E,
      definitions: {
        lower: [],
        upper: []
      },
      document: this.parent,
      rulesVersion,
      summary: {
        // Condition Immunities
        conditionImmunities: o.conditionImmunities ?? prepareTrait(this.traits.ci, "ci"),

        // Challenge Rating (e.g. `23 (XP 50,000; PB +7`))
        cr: `${o.cr ?? formatCR(this.details.cr, { narrow: false })} (${xp})`,

        // Gear
        gear: o.gear ?? formatter.format(
          this.getGear().map(i => i.system.quantity > 1 ? `${i.name} (${formatNumber(i.system.quantity)})` : i.name)
        ),

        // Initiative (e.g. `+0 (10)`)
        initiative: o.initiative ?? `${formatNumber(this.attributes.init.total, { signDisplay: "always" })} (${
          formatNumber(this.attributes.init.score)})`,

        // Languages (e.g. `Common, Draconic`)
        languages: o.languages ?? ([
          formatter.format(this.traits.languages.labels.languages),
          formatter.format(this.traits.languages.labels.ranged.map(r => rulesVersion === "2024" ? r : r.toLowerCase()))
        ].filterJoin("; ") || (rulesVersion === "2024" ? game.i18n.localize("None") : "—")),

        // Saves (e.g. `Dex +7, Con +15, Wis +10, Cha +12`)
        saves: formatter.format(
          Object.entries(CONFIG.DND5E.abilities)
            .filter(([k]) => this.abilities[k].saveProf.multiplier !== 0)
            .map(([k, { abbreviation }]) =>
              `${abbreviation.capitalize()} ${formatNumber(this.abilities[k].save.value, { signDisplay: "always" })}`
            )
        ),

        // Senses (e.g. `Blindsight 60 ft., Darkvision 120 ft.; Passive Perception 27`)
        senses: o.senses ?? [
          formatter.format([
            ...Object.entries(CONFIG.DND5E.senses)
              .filter(([k]) => this.attributes.senses[k])
              .map(([k, label]) => prepareMeasured(this.attributes.senses[k], this.attributes.senses.units, label)),
            ...splitSemicolons(this.attributes.senses.special)
          ].sort((lhs, rhs) => lhs.localeCompare(rhs, game.i18n.lang))),
          `${game.i18n.localize("DND5E.PassivePerception")} ${formatNumber(this.skills.prc.passive)}`
        ].filterJoin("; "),

        // Skills (e.g. `Perception +17, Stealth +7`)
        skills: o.skills ?? formatter.format(
          Object.entries(CONFIG.DND5E.skills)
            .filter(([k]) => this.skills[k].value > 0)
            .map(([k, { label }]) => `${label} ${formatNumber(this.skills[k].total, { signDisplay: "always" })}`)
        ),

        // Speed (e.g. `40 ft., Burrow 40 ft., Fly 80 ft.`)
        speed: o.speed ?? prepareSpeed(),

        // Tag (e.g. `Gargantuan Dragon, Lawful Evil`)
        tag: o.tag ?? game.i18n.format("DND5E.CreatureTag", {
          size: o.size ?? CONFIG.DND5E.actorSizes[this.traits.size]?.label ?? "",
          type: o.type ?? Actor5e.formatCreatureType(this.details.type) ?? "",
          alignment: o.alignment ?? this.details.alignment
        }).replace(/, $/, "")
      },
      system: this
    };

    for ( const type of ["vulnerabilities", "resistances", "immunities"] ) {
      if ( type in o ) {
        context.summary[type] = o[type];
        continue;
      }
      const entries = [];
      for ( const category of rulesVersion === "2024" ? ["damage", "condition"] : ["damage"] ) {
        if ( (category === "condition") && (type !== "immunities") ) continue;
        const trait = `${category[0]}${type[0]}`;
        const data = this.traits[trait];
        const { value, physical } = data.value.reduce((acc, t) => {
          if ( data.bypasses?.size && CONFIG.DND5E.damageTypes[t]?.isPhysical ) acc.physical.push(t);
          else acc.value.push(t);
          return acc;
        }, { value: [], physical: [] });
        const list = prepareTrait({ value, custom: data.custom }, trait);
        if ( list ) entries.push(list);
        if ( physical.length ) entries.push(game.i18n.format("DND5E.DamagePhysicalBypasses", {
          damageTypes: game.i18n.getListFormatter({ style: "long", type: "conjunction" }).format(
            physical.map(t => CONFIG.DND5E.damageTypes[t].label)
          ),
          bypassTypes: game.i18n.getListFormatter({ style: "long", type: "disjunction" }).format(
            Array.from(data.bypasses).map(t => CONFIG.DND5E.itemProperties[t]?.label).filter(_ => _)
          )
        }));
      }
      if ( entries.length ) context.summary[type] = entries.join("; ");
    }

    const { summary, system } = context;
    if ( rulesVersion === "2024" ) {
      for ( const [index, [key, { abbreviation }]] of Object.entries(CONFIG.DND5E.abilities).entries() ) {
        context.abilityTables[index % 3].abilities.push({ ...this.abilities[key], label: abbreviation.capitalize() });
      }

      context.definitions.upper = [
        { label: "DND5E.AC", classes: "half-width", definitions: [o.ac ?? system.attributes.ac.value] },
        { label: "DND5E.Initiative", classes: "half-width", definitions: [summary.initiative] },
        { label: "DND5E.HP", definitions: o.hp ? [o.hp] : system.attributes.hp.formula ? [
          system.attributes.hp.max, `(${system.attributes.hp.formula})`
        ] : [system.attributes.hp.max] },
        { label: "DND5E.Speed", definitions: [summary.speed] }
      ];
      context.definitions.lower = [
        summary.skills ? { label: "DND5E.Skills", definitions: [summary.skills] } : null,
        summary.vulnerabilities ? { label: "DND5E.Vulnerabilities", definitions: [summary.vulnerabilities] } : null,
        summary.resistances ? { label: "DND5E.Resistances", definitions: [summary.resistances] } : null,
        summary.immunities ? { label: "DND5E.Immunities", definitions: [summary.immunities] } : null,
        summary.gear ? { label: "DND5E.Gear", definitions: [summary.gear] } : null,
        { label: "DND5E.Senses", definitions: [summary.senses] },
        { label: "DND5E.Languages", definitions: [summary.languages] },
        { label: "DND5E.AbbreviationCR", definitions: [summary.cr] }
      ].filter(_ => _);
    }

    else {
      const lowerCase = def => {
        def.definitions = def.definitions.map(d => String(d).toLowerCase());
        return def;
      };
      context.definitions.upper = [
        { label: "DND5E.ArmorClass", definitions: o.ac ? [o.ac] : system.attributes.ac.label ? [
          system.attributes.ac.value, `(${system.attributes.ac.label})`
        ] : [system.attributes.ac.value] },
        { label: "DND5E.HitPoints", definitions: o.hp ? [o.hp] : system.attributes.hp.formula ? [
          system.attributes.hp.max, `(${system.attributes.hp.formula})`
        ] : [system.attributes.hp.max] },
        { label: "DND5E.Speed", definitions: [summary.speed] }
      ].map(d => lowerCase(d));
      context.definitions.lower = [
        summary.saves ? { label: "DND5E.ClassSaves", definitions: [summary.saves] } : null,
        summary.skills ? { label: "DND5E.Skills", definitions: [summary.skills] } : null,
        summary.vulnerabilities ? lowerCase({ label: "DND5E.DamVuln", definitions: [summary.vulnerabilities] }) : null,
        summary.resistances ? lowerCase({ label: "DND5E.DamRes", definitions: [summary.resistances] }) : null,
        summary.immunities ? lowerCase({ label: "DND5E.DamImm", definitions: [summary.immunities] }) : null,
        summary.conditionImmunities
          ? lowerCase({ label: "DND5E.TraitCIPlural.other", definitions: [summary.conditionImmunities] }) : null,
        { label: "DND5E.Senses", definitions: [summary.senses] },
        { label: "DND5E.Languages", definitions: [summary.languages] },
        { label: "DND5E.Challenge", classes: "half-width", definitions: [summary.cr] },
        { label: "DND5E.ProficiencyBonus", classes: "half-width", definitions: [
          o.pb ?? formatNumber(this.attributes.prof, { signDisplay: "always" })
        ] }
      ].filter(_ => _);
      context.summary.tag = context.summary.tag.toLowerCase().capitalize();
    }

    for ( const item of this.parent.items ) {
      if ( !["feat", "weapon"].includes(item.type) ) continue;
      const category = item.system.properties.has("trait") ? "trait"
        : (item.system.activities?.contents[0]?.activation?.type ?? "trait");
      if ( category in context.actionSections ) {
        let description = (await TextEditor$2.enrichHTML(item.system.description.value, {
          secrets: false, rollData: item.getRollData(), relativeTo: item
        }));
        if ( item.identifier === "legendary-actions" ) {
          context.actionSections.legendary.description = description;
        } else if ( item.identifier === "mythic-actions" ) {
          context.actionSections.mythic.description = description;
        } else {
          const openingTag = description.match(/^\s*(<p(?:\s[^>]+)?>)/gi)?.[0];
          if ( openingTag ) description = description.replace(openingTag, "");
          const uses = item.system.uses.label
            || (item.system.activities?.size === 1 ? item.system.activities?.contents[0]?.uses.label : undefined);
          context.actionSections[category].actions.push({
            description, openingTag,
            dataset: { id: item.id, identifier: item.identifier },
            name: uses ? `${item.name} (${uses})` : item.name,
            sort: item.sort
          });
        }
      }
    }
    for ( const [key, section] of Object.entries(context.actionSections) ) {
      if ( section.actions.length ) {
        section.actions.sort((lhs, rhs) => lhs.sort - rhs.sort);
        if ( (key === "legendary") && !section.description ) {
          section.description = `<p>${this.getLegendaryActionsDescription()}</p>`;
        }
      } else delete context.actionSections[key];
    }

    return context;
  }
}

const config$4 = {
  character: CharacterData,
  encounter: EncounterData,
  group: GroupData,
  npc: NPCData,
  vehicle: VehicleData
};

var _module$d = /*#__PURE__*/Object.freeze({
  __proto__: null,
  AttributesFields: AttributesFields,
  CharacterData: CharacterData,
  CommonTemplate: CommonTemplate,
  CreatureTemplate: CreatureTemplate,
  DamageTraitField: DamageTraitField,
  DetailsFields: DetailsField,
  EncounterData: EncounterData,
  GroupActor: GroupActor,
  GroupData: GroupData,
  GroupSystemFlags: GroupSystemFlags,
  NPCData: NPCData,
  SimpleTraitField: SimpleTraitField,
  TraitsFields: TraitsField,
  TravelField: TravelField,
  VehicleData: VehicleData,
  config: config$4
});

var _module$c = /*#__PURE__*/Object.freeze({
  __proto__: null,
  AbilityScoreImprovementConfigurationData: AbilityScoreImprovementConfigurationData,
  AbilityScoreImprovementValueData: AbilityScoreImprovementValueData,
  BaseAdvancement: BaseAdvancementData,
  ItemChoiceConfigurationData: ItemChoiceConfigurationData,
  ItemChoiceValueData: ItemChoiceValueData,
  ItemGrantConfigurationData: ItemGrantConfigurationData,
  SizeConfigurationData: SizeConfigurationData,
  SizeValueData: SizeValueData,
  SpellConfigurationData: SpellConfigurationData,
  SubclassValueData: SubclassValueData,
  TraitConfigurationData: TraitConfigurationData,
  TraitValueData: TraitValueData,
  scaleValue: scaleValue
});

var _module$b = /*#__PURE__*/Object.freeze({
  __proto__: null,
  CALENDAR_OF_GREYHAWK: CALENDAR_OF_GREYHAWK,
  CALENDAR_OF_HARPTOS: CALENDAR_OF_HARPTOS,
  CALENDAR_OF_KHORVAIRE: CALENDAR_OF_KHORVAIRE,
  CalendarData5e: CalendarData5e,
  CalendarGreyhawk: CalendarGreyhawk,
  CalendarHarptos: CalendarHarptos,
  CalendarKhorvaire: CalendarKhorvaire
});

const {
  ArrayField: ArrayField$5, DocumentUUIDField: DocumentUUIDField$1, ForeignDocumentField: ForeignDocumentField$2, ObjectField, SchemaField: SchemaField$d, StringField: StringField$k
} = foundry.data.fields;

/**
 * @import { RequestMessageSystemData } from "./_types.mjs";
 */

/**
 * Custom chat message type used for requesting an action be performed for a specific actor.
 * @extends {ChatMessageDataModel<RequestMessageSystemData>}
 * @mixes RequestMessageSystemData
 */
class RequestMessageData extends ChatMessageDataModel {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static defineSchema() {
    return {
      button: new SchemaField$d({
        icon: new StringField$k(),
        label: new StringField$k()
      }),
      data: new ObjectField(),
      handler: new StringField$k({ required: true, blank: false }),
      targets: new ArrayField$5(new SchemaField$d({
        actor: new DocumentUUIDField$1({ type: "Actor", validate: RequestMessageData.#validateActorUuid }),
        result: new ForeignDocumentField$2(foundry.documents.BaseChatMessage),
        user: new ForeignDocumentField$2(foundry.documents.BaseUser)
      }))
    };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static metadata = Object.freeze(foundry.utils.mergeObject(super.metadata, {
    actions: {
      handleRequest: RequestMessageData.#handleRequest
    },
    template: "systems/dnd5e/templates/chat/request-card.hbs"
  }, { inplace: false }));

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /**
   * Highlight successes and failures in the results, if applicable.
   * @param {HTMLElement} element  The rendered chat card.
   * @protected
   */
  _highlightSuccessFailure(element) {
    for ( const item of element.querySelectorAll(".targets [data-uuid]") ) {
      const { uuid } = item.dataset;
      const { result } = this.targets.find(t => t.actor === uuid) ?? {};
      const [roll] = result?.rolls ?? [];
      if ( !this.parent.shouldDisplayChallenge || (!roll?.isSuccess && !roll?.isFailure) ) continue;
      const status = item.querySelector(".status");
      status.classList.toggle("success", roll.isSuccess);
      status.classList.toggle("failure", roll.isFailure);
      status.append(foundry.applications.fields.createFontAwesomeIcon(roll.isSuccess ? "check" : "xmark"));
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onRender(element) {
    super._onRender(element);
    this._highlightSuccessFailure(element);
  }

  /* -------------------------------------------- */

  /** @override */
  async _prepareContext() {
    return {
      button: {
        icon: this.button.icon,
        label: game.i18n.localize(this.button.label || "DND5E.CHATMESSAGE.REQUEST.Action.Handle")
      },
      content: await foundry.applications.ux.TextEditor.implementation.enrichHTML(
        this.parent.content, { rollData: this.parent.getRollData() }
      ),
      targets: this.targets.map(t => {
        const actor = fromUuidSync(t.actor);
        if ( !actor ) return null;
        const visible = game.user.isGM || (!!t.user && (game.user === t.user)) || (!t.user && actor.isOwner);
        const { result } = t;
        const completed = result !== null;
        const total = result?.rolls[0]?.total;
        return { actor, completed, total, visible };
      }).filter(_ => _)
    };
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle clicking the button.
   * @this {RequestMessageData}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   * @returns {Promise<ChatMessage5e|void>}
   */
  static async #handleRequest(event, target) {
    const actor = fromUuidSync(target.closest("[data-uuid]").dataset.uuid);
    const result = await CONFIG.DND5E.requests[this.handler](actor, this.parent, this.data, { event });
    if ( (result instanceof ChatMessage) && !result.getFlag("dnd5e", "requestResult") ) {
      return result.setFlag("dnd5e", "requestResult", { actorUuid: actor.uuid, requestId: this.parent.id });
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle associating a newly created request result message with an actor and updating this message.
   * @param {ChatMessage5e} message  The created chat message.
   */
  static onCreateMessage(message) {
    const flag = message.getFlag("dnd5e", "requestResult");
    if ( flag && (game.users.activeGM === game.user) ) RequestMessageData.#updateRequestTargets(message, flag);
  }

  /* -------------------------------------------- */

  /**
   * Handle associating an updated request result message with an actor and updating this message.
   * @param {ChatMessage5e} message
   * @param {object} changes
   * @param {object} options
   * @param {string} userId
   */
  static onUpdateResultMessage(message, changes, options, userId) {
    const flag = foundry.utils.getProperty(changes, "flags.dnd5e.requestResult");
    if ( flag && (game.users.activeGM === game.user) ) RequestMessageData.#updateRequestTargets(message, flag);
  }

  /* -------------------------------------------- */

  /**
   * Update target information when a request result is processed.
   * @param {ChatMessage5e} message    The message fulfilling a request.
   * @param {object} result
   * @param {string} result.actorUuid  The UUID of the actor fulfilling the request.
   * @param {string} result.requestId  The ID of the original request message.
   */
  static #updateRequestTargets(message, result) {
    const actor = fromUuidSync(result.actorUuid);
    const request = game.messages.get(result.requestId);
    if ( !actor || !request ) return;

    const index = request.system.targets.findIndex(t => t.actor === result.actorUuid);
    const target = request.system.targets[index];
    if ( !target ) return;

    const targetsData = request.system.toObject().targets ?? [];
    targetsData[index].result = message.id;
    request.update({ "system.targets": targetsData });
  }

  /* -------------------------------------------- */
  /*  Validation                                  */
  /* -------------------------------------------- */

  /**
   * Ensure provided Actor UUIDs can be resolved synchronously.
   * @param {string} uuid  The UUID.
   */
  static #validateActorUuid(uuid) {
    if ( uuid.startsWith(".") || uuid.startsWith("Compendium.") ) {
      throw new Error("Request Message target UUIDs may not be relative or inside a compendium.");
    }
  }
}

const TextEditor$1 = foundry.applications.ux.TextEditor.implementation;
const { ForeignDocumentField: ForeignDocumentField$1, StringField: StringField$j } = foundry.data.fields;

/**
 * @import { RestMessageSystemData } from "./_types.mjs";
 */

/**
 * Data stored in a rest chat message.
 * @extends {ChatMessageDataModel<RestMessageSystemData>}
 * @mixes RestMessageSystemData
 */
class RestMessageData extends ChatMessageDataModel {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static defineSchema() {
    return {
      activations: new ActivationsField(),
      deltas: new ActorDeltasField(),
      request: new ForeignDocumentField$1(foundry.documents.BaseChatMessage),
      type: new StringField$j()
    };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static metadata = Object.freeze(foundry.utils.mergeObject(super.metadata, {
    template: "systems/dnd5e/templates/chat/rest-card.hbs"
  }, { inplace: false }));

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The actor for the chat message.
   * @type {Actor5e}
   */
  get actor() {
    return this.parent.getAssociatedActor();
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @override */
  async _prepareContext() {
    const context = {
      actor: this.actor,
      content: await TextEditor$1.enrichHTML(this.parent.content, { rollData: this.parent.getRollData() })
    };

    if ( context.actor?.testUserPermission(game.user, "OBSERVER") ) {
      context.activities = ActivationsField.processActivations.call(this.activations, this.actor);
      context.deltas = ActorDeltasField.processDeltas.call(this.deltas, this.actor, this.parent.rolls);
    }

    return context;
  }
}

const { DocumentIdField: DocumentIdField$3, SchemaField: SchemaField$c, SetField: SetField$b, StringField: StringField$i } = foundry.data.fields;

/**
 * @import { TurnMessageSystemData } from "./_types.mjs";
 */

/**
 * Data stored in a combat turn chat message.
 * @extends {ChatMessageDataModel<TurnMessageSystemData>}
 * @mixes TurnMessageSystemData
 */
class TurnMessageData extends ChatMessageDataModel {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static defineSchema() {
    return {
      activations: new ActivationsField(),
      deltas: new ActorDeltasField(),
      origin: new SchemaField$c({
        combat: new DocumentIdField$3({ nullable: false, required: true }),
        combatant: new DocumentIdField$3({ nullable: false, required: true })
      }),
      trigger: new SetField$b(new StringField$i())
    };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static metadata = Object.freeze(foundry.utils.mergeObject(super.metadata, {
    template: "systems/dnd5e/templates/chat/turn-card.hbs"
  }, { inplace: false }));

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The actor belonging to the combatant.
   * @type {Actor5e}
   */
  get actor() {
    return this.combatant?.actor ?? this.parent.getAssociatedActor();
  }

  /* -------------------------------------------- */

  /**
   * The combat during which this message was triggered.
   * @type {Combat5e}
   */
  get combat() {
    return game.combats.get(this.origin.combat);
  }

  /* -------------------------------------------- */

  /**
   * The combatant to whom this message applies.
   * @type {Combatant5e}
   */
  get combatant() {
    return this.combat?.combatants.get(this.origin.combatant);
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @override */
  async _prepareContext(options) {
    const context = {
      actor: this.actor,
      combat: this.combat,
      combatant: this.combatant
    };

    if ( context.actor?.isOwner ) {
      context.activities = ActivationsField.processActivations.call(this.activations, this.actor);
      context.deltas = ActorDeltasField.processDeltas.call(this.deltas, this.actor, this.parent.rolls);
    }

    return context;
  }
}

var _module$a = /*#__PURE__*/Object.freeze({
  __proto__: null,
  ActivationsField: ActivationsField,
  ActorDeltasField: ActorDeltasField,
  IndividualDeltaField: IndividualDeltaField
});

const config$3 = {
  request: RequestMessageData,
  rest: RestMessageData,
  turn: TurnMessageData
};

var _module$9 = /*#__PURE__*/Object.freeze({
  __proto__: null,
  RequestMessageData: RequestMessageData,
  RestMessageData: RestMessageData,
  TurnMessageData: TurnMessageData,
  config: config$3,
  fields: _module$a
});

/**
 * Custom actors collection.
 */
class Actors5e extends foundry.documents.collections.Actors {
  /**
   * The primary party.
   * @type {Actor5e|null}
   */
  get party() {
    return game.settings.get("dnd5e", "primaryParty")?.actor ?? null;
  }
}

var _module$8 = /*#__PURE__*/Object.freeze({
  __proto__: null,
  Actors5e: Actors5e,
  Items5e: Items5e
});

/**
 * @import {
 *   AdvancementTemplateData, ItemDescriptionTemplateData, StartingEquipmentTemplateData
 * } from "./templates/_types.mjs";
 */

/**
 * Data definition for Background items.
 * @extends {ItemDataModel<AdvancementTemplateData & ItemDescriptionTemplate & StartingEquipmentTemplate>}
 * @mixes AdvancementTemplateData
 * @mixes ItemDescriptionTemplateData
 * @mixes StartingEquipmentTemplateData
 */
class BackgroundData extends ItemDataModel$1.mixin(
  AdvancementTemplate, ItemDescriptionTemplate, StartingEquipmentTemplate
) {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.SOURCE"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static metadata = Object.freeze(foundry.utils.mergeObject(super.metadata, {
    singleton: true
  }, {inplace: false}));

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareDerivedData() {
    super.prepareDerivedData();
    this.prepareDescriptionData();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getSheetData(context) {
    context.subtitles = [{ label: game.i18n.localize(CONFIG.Item.typeLabels.background) }];
    context.singleDescription = true;
    context.parts = ["dnd5e.details-background", "dnd5e.details-starting-equipment"];
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /** @override */
  _advancementToCreate(options) {
    if ( game.settings.get("dnd5e", "rulesVersion") === "legacy" ) return [
      { type: "Trait", title: game.i18n.localize("DND5E.ADVANCEMENT.Defaults.BackgroundProficiencies") },
      { type: "ItemGrant", title: game.i18n.localize("DND5E.ADVANCEMENT.Defaults.BackgroundFeature") }
    ];

    return [
      { type: "AbilityScoreImprovement", configuration: { points: 3 } },
      { type: "Trait", title: game.i18n.localize("DND5E.ADVANCEMENT.Defaults.BackgroundProficiencies") },
      {
        type: "Trait",
        title: game.i18n.localize("DND5E.ADVANCEMENT.Defaults.ChooseLanguages"),
        configuration: { grants: ["languages:standard:common"] }
      },
      { type: "ItemGrant", title: game.i18n.localize("DND5E.ADVANCEMENT.Defaults.BackgroundFeat") }
    ];
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preCreate(data, options, user) {
    if ( (await super._preCreate(data, options, user)) === false ) return false;
    await this.preCreateAdvancement(data, options);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onCreate(data, options, userId) {
    super._onCreate(data, options, userId);
    if ( (game.user.id !== userId) || this.parent.actor?.type !== "character" ) return;
    this.parent.actor.update({"system.details.background": this.parent.id});
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preDelete(options, user) {
    if ( (await super._preDelete(options, user)) === false ) return false;
    if ( this.parent.actor?.type !== "character" ) return;
    await this.parent.actor.update({"system.details.background": null});
  }
}

const { BooleanField: BooleanField$9, NumberField: NumberField$a, SchemaField: SchemaField$b, SetField: SetField$a, StringField: StringField$h } = foundry.data.fields;

/**
 * @import { InventorySectionDescriptor } from "../../applications/components/_types.mjs";
 * @import { ConsumableItemSystemData } from "./_types.mjs";
 * @import {
 *   ActivitiesTemplateData, EquippableItemTemplateData, IdentifiableTemplateData,
 *   ItemDescriptionTemplateData, ItemTypeTemplateData, PhysicalItemTemplateData
 * } from "./templates/_types.mjs";
 */

/**
 * Data definition for Consumable items.
 * @extends {ItemDataModel<
 *   ActivitiesTemplate & ItemDescriptionTemplate & IdentifiableTemplate &
 *   ItemTypeTemplate & PhysicalItemTemplate & EquippableItemTemplate & ConsumableItemSystemData
 * >}
 * @mixes ActivitiesTemplateData
 * @mixes ItemDescriptionTemplateData
 * @mixes ItemTypeTemplateData
 * @mixes IdentifiableTemplateData
 * @mixes PhysicalItemTemplateData
 * @mixes EquippableItemTemplateData
 * @mixes ConsumableItemSystemData
 */
class ConsumableData extends ItemDataModel$1.mixin(
  ActivitiesTemplate, ItemDescriptionTemplate, IdentifiableTemplate, ItemTypeTemplate,
  PhysicalItemTemplate, EquippableItemTemplate
) {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.CONSUMABLE", "DND5E.SOURCE"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return this.mergeSchema(super.defineSchema(), {
      damage: new SchemaField$b({
        base: new DamageField(),
        replace: new BooleanField$9()
      }),
      magicalBonus: new NumberField$a({ min: 0, integer: true }),
      properties: new SetField$a(new StringField$h()),
      type: new ItemTypeField({ baseItem: false }, { label: "DND5E.ItemConsumableType" }),
      uses: new UsesField({
        autoDestroy: new BooleanField$9({ required: true })
      })
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static metadata = Object.freeze(foundry.utils.mergeObject(super.metadata, {
    hasEffects: true,
    enchantable: true
  }, {inplace: false}));

  /* -------------------------------------------- */

  /** @override */
  static get compendiumBrowserFilters() {
    return new Map([
      ["type", {
        label: "DND5E.ItemConsumableType",
        type: "set",
        config: {
          choices: CONFIG.DND5E.consumableTypes,
          keyPath: "system.type.value"
        }
      }],
      ["attunement", this.compendiumBrowserAttunementFilter],
      ...this.compendiumBrowserPhysicalItemFilters,
      ["properties", this.compendiumBrowserPropertiesFilter("consumable")]
    ]);
  }

  /* -------------------------------------------- */

  /**
   * Default configuration for this item type's inventory section.
   * @returns {InventorySectionDescriptor}
   */
  static get inventorySection() {
    return {
      id: "consumables",
      order: 300,
      label: "TYPES.Item.consumablePl",
      groups: { type: "consumable" },
      columns: ["price", "weight", "quantity", "charges", "controls"]
    };
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Properties displayed in chat.
   * @type {string[]}
   */
  get chatProperties() {
    return [
      this.type.label,
      this.hasLimitedUses ? `${this.uses.value}/${this.uses.max} ${game.i18n.localize("DND5E.Charges")}` : null,
      this.priceLabel
    ];
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  get _typeAbilityMod() {
    if ( this.type.value !== "scroll" ) return null;
    return this.parent?.actor?.system.attributes.spellcasting || "int";
  }

  /* -------------------------------------------- */

  /** @override */
  static get itemCategories() {
    return CONFIG.DND5E.consumableTypes;
  }

  /* -------------------------------------------- */

  /**
   * Does this item have base damage defined in `damage.base` to offer to an activity?
   * @type {boolean}
   */
  get offersBaseDamage() {
    return this.type.value === "ammo";
  }

  /* -------------------------------------------- */

  /**
   * The proficiency multiplier for this item.
   * @returns {number}
   */
  get proficiencyMultiplier() {
    const isProficient = this.parent?.actor?.getFlag("dnd5e", "tavernBrawlerFeat");
    return isProficient ? 1 : 0;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  get validProperties() {
    const valid = super.validProperties;
    if ( this.type.value === "ammo" ) Object.entries(CONFIG.DND5E.itemProperties).forEach(([k, v]) => {
      if ( v.isPhysical ) valid.add(k);
      valid.add("ret");
    });
    else if ( this.type.value === "scroll" ) CONFIG.DND5E.validProperties.spell
      .filter(p => p !== "material").forEach(p => valid.add(p));
    return valid;
  }

  /* -------------------------------------------- */
  /*  Data Migration                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static _migrateData(source) {
    super._migrateData(source);
    ActivitiesTemplate.migrateActivities(source);
    ConsumableData.#migrateDamage(source);
    ConsumableData.#migratePropertiesData(source);
  }

  /* -------------------------------------------- */

  /**
   * Migrate weapon damage from old parts.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateDamage(source) {
    if ( "base" in (source.damage ?? {}) ) return;
    const systemData = { system: { scaling: { mode: "none" } } };
    if ( source.damage?.parts?.[0] ) {
      source.damage.base = BaseActivityData.transformDamagePartData(systemData, source.damage.parts.shift());
    }
  }

  /* -------------------------------------------- */

  /**
   * Migrate the properties object into a set.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migratePropertiesData(source) {
    if ( foundry.utils.getType(source.properties) !== "Object" ) return;
    source.properties = filteredKeys(source.properties);
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareDerivedData() {
    super.prepareDerivedData();
    this.prepareDescriptionData();
    this.prepareIdentifiable();
    this.preparePhysicalData();
    if ( !this.type.value ) return;
    const config = CONFIG.DND5E.consumableTypes[this.type.value];
    if ( config ) {
      this.type.label = config.subtypes?.[this.type.subtype] ?? config.label;
    } else {
      this.type.label = game.i18n.localize(CONFIG.Item.typeLabels.consumable);
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareFinalData() {
    this.prepareFinalActivityData(this.parent.getRollData({ deterministic: true }));
    this.prepareFinalEquippableData();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getFavoriteData() {
    return foundry.utils.mergeObject(await super.getFavoriteData(), {
      subtitle: [this.type.label, this.parent.labels.activation],
      uses: this.hasLimitedUses ? this.getUsesData() : null,
      quantity: this.quantity
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getSheetData(context) {
    context.subtitles = [
      { label: this.type.label },
      ...this.physicalItemSheetFields
    ];

    context.parts = ["dnd5e.details-consumable", "dnd5e.field-uses"];
    context.damageTypes = Object.entries(CONFIG.DND5E.damageTypes).map(([value, { label }]) => {
      return {
        value, label,
        selected: context.source.damage.base.types.includes?.(value) ?? context.source.damage.base.types.has(value)
      };
    });
    context.denominationOptions = [
      { value: "", label: "" },
      { rule: true },
      ...CONFIG.DND5E.dieSteps.map(value => ({ value, label: `d${value}` }))
    ];
    const itemTypes = CONFIG.DND5E.consumableTypes[this._source.type.value];
    if ( itemTypes ) {
      context.itemType = itemTypes.label;
      context.itemSubtypes = itemTypes.subtypes;
    }
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preUpdate(changed, options, user) {
    if ( (await super._preUpdate(changed, options, user)) === false ) return false;
    await this.preUpdateIdentifiable(changed, options, user);
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async getCraftCost(options={}) {
    const { days, gold } = await super.getCraftCost(options);
    const { consumable, magic } = CONFIG.DND5E.crafting;
    const { rarity } = this;
    if ( !this.properties.has("mgc") || !(rarity in magic) ) return { days, gold };
    const costs = magic[rarity];
    return {
      days: Math.floor(costs.days * consumable.days),
      gold: Math.floor(costs.gold * consumable.gold)
    };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  getRollData(...options) {
    const data = super.getRollData(...options);
    const spellLevel = this.parent.getFlag("dnd5e", "spellLevel");
    if ( spellLevel ) data.item.level = spellLevel.value ?? spellLevel.base;
    return data;
  }
}

const { ArrayField: ArrayField$4, BooleanField: BooleanField$8, DocumentUUIDField, NumberField: NumberField$9, SchemaField: SchemaField$a, StringField: StringField$g } = foundry.data.fields;

/**
 * @import { FacilityItemSystemData } from "./_types.mjs";
 * @import { ActivitiesTemplateData, ItemDescriptionTemplateData } from "./templates/_types.mjs";
 */


/**
 * The data definition for Facility items.
 * @extends {ItemDataModel<ActivitiesTemplate & ItemDescriptionTemplate & FacilityItemSystemData>}
 * @mixes ActivitiesTemplateData
 * @mixes ItemDescriptionTemplateData
 * @mixes FacilityItemSystemData
 */
class FacilityData extends ItemDataModel$1.mixin(ActivitiesTemplate, ItemDescriptionTemplate) {
  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.FACILITY", "DND5E.SOURCE"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return this.mergeSchema(super.defineSchema(), {
      building: new SchemaField$a({
        built: new BooleanField$8({ required: true }),
        size: new StringField$g({ initial: "cramped", blank: false, nullable: false, required: true })
      }),
      craft: new SchemaField$a({
        item: new DocumentUUIDField({ type: "Item" }),
        quantity: new NumberField$9({ required: true, integer: true, positive: true, initial: 1, nullable: false })
      }),
      defenders: new SchemaField$a({
        value: new ArrayField$4(new DocumentUUIDField({ type: "Actor" })),
        max: new NumberField$9({ required: true, integer: true, positive: true })
      }),
      disabled: new BooleanField$8({ required: true }),
      enlargeable: new BooleanField$8({ required: true }),
      free: new BooleanField$8({ required: true }),
      hirelings: new SchemaField$a({
        value: new ArrayField$4(new DocumentUUIDField({ type: "Actor" })),
        max: new NumberField$9({ required: true, integer: true, positive: true })
      }),
      level: new NumberField$9({ required: true, integer: true, positive: true, initial: 5 }),
      order: new StringField$g({ required: true }),
      progress: new SchemaField$a({
        value: new NumberField$9({ required: true, integer: true, min: 0, nullable: false, initial: 0 }),
        max: new NumberField$9({ required: true, integer: true, positive: true }),
        order: new StringField$g({ required: true })
      }),
      size: new StringField$g({ initial: "cramped", blank: false, nullable: false, required: true }),
      trade: new SchemaField$a({
        creatures: new SchemaField$a({
          value: new ArrayField$4(new DocumentUUIDField({ type: "Actor" })),
          max: new NumberField$9({ required: true, integer: true, positive: true })
        }),
        pending: new SchemaField$a({
          creatures: new ArrayField$4(new DocumentUUIDField({ type: "Actor" })),
          operation: new StringField$g({ required: true, nullable: true, options: ["buy", "sell"], initial: null }),
          stocked: new BooleanField$8({ required: true }),
          value: new NumberField$9({ required: true, min: 0, integer: true })
        }),
        profit: new NumberField$9({ required: true, min: 0, integer: true }),
        stock: new SchemaField$a({
          stocked: new BooleanField$8({ required: true }),
          value: new NumberField$9({ required: true, min: 0, integer: true }),
          max: new NumberField$9({ required: true, integer: true, positive: true })
        })
      }),
      type: new ItemTypeField({ value: "basic", baseItem: false })
    });
  }

  /* -------------------------------------------- */

  /** @override */
  static get compendiumBrowserFilters() {
    const { basic, special } = CONFIG.DND5E.facilities.advancement;
    const min = Math.min(...Object.keys(basic), ...Object.keys(special));
    return new Map([
      ["level", {
        label: "DND5E.FACILITY.FIELDS.level.label",
        type: "range",
        config: {
          keyPath: "system.level",
          min,
          max: CONFIG.DND5E.maxLevel
        }
      }],
      ["type", {
        label: "DND5E.FACILITY.FIELDS.type.value.label",
        type: "set",
        config: {
          choices: Object.fromEntries(Object.entries(CONFIG.DND5E.facilities.types).map(([k, v]) => [k, v.label])),
          keyPath: "system.type.value"
        }
      }]
    ]);
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /**
   * Create an ephemeral Order activity.
   * @param {string} id     The static ID string for the order. Will have staticID called on it.
   * @param {string} order  The order.
   * @protected
   */
  _createOrderActivity(id, order) {
    const activity = new OrderActivity({
      order,
      _id: staticID(id),
      name: game.i18n.format("DND5E.FACILITY.Order.Issue", {
        order: game.i18n.localize(`DND5E.FACILITY.Orders.${order}.inf`)
      })
    }, { parent: this.parent });
    this.activities.set(activity.id, activity);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareBaseData() {
    super.prepareBaseData();

    if ( this.type.value === "basic" ) this.enlargeable = this.building.built;
    else this.building.built = true;

    // Activities
    if ( (this.type.value === "special") && this.order ) this._createOrderActivity("dnd5eFacOrder", this.order);
    if ( this.enlargeable ) this._createOrderActivity("dnd5eFacEnlarge", "enlarge");
    if ( !this.building.built ) this._createOrderActivity("dnd5eFacBuild", "build");

    // TODO: Allow order activities to be user-creatable and configurable to avoid having to hard-code this.
    if ( this.type.subtype === "garden" ) this._createOrderActivity("dnd5eFacChange", "change");
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareDerivedData() {
    super.prepareDerivedData();
    this.prepareDescriptionData();

    // Type
    const config = CONFIG.DND5E.facilities.types[this.type.value];
    this.type.label = config?.subtypes?.[this.type.subtype] ?? config?.label;

    // Price
    if ( this.type.value === "basic" ) {
      const { value, days } = CONFIG.DND5E.facilities.sizes[this.size];
      this.price = { value, days, denomination: "gp" };
    }

    // Squares
    this.squares = CONFIG.DND5E.facilities.sizes[this.size].squares;

    // Progress
    if ( this.progress.max ) {
      const { value, max } = this.progress;
      this.progress.value = Math.clamp(value, 0, max);
      this.progress.pct = Math.round((this.progress.value / max) * 100);
    }

    if ( this.disabled ) this.progress.order = "repair";

    // Trade
    if ( Number.isFinite(this.trade.creatures.max) ) {
      this.trade.creatures.max = this._source.trade.creatures.max - this.trade.pending.creatures.length;
    }

    // Labels
    const labels = this.parent.labels ??= {};
    if ( this.order ) labels.order = game.i18n.localize(`DND5E.FACILITY.Orders.${this.order}.present`);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareFinalData() {
    this.prepareFinalActivityData(this.parent.getRollData({ deterministic: true }));
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getSheetData(context) {
    context.singleDescription = true;
    context.subtitles = [
      { label: this.type.label },
      { label: CONFIG.DND5E.facilities.sizes[this.size].label }
    ];

    context.parts = ["dnd5e.details-facility"];
    context.facilitySubtypes = CONFIG.DND5E.facilities.types[this.type.value]?.subtypes ?? {};
    context.orders = Object.entries(CONFIG.DND5E.facilities.orders).reduce((obj, [value, config]) => {
      const { label, basic, hidden } = config;
      if ( hidden ) return obj;
      // TODO: More hard-coding that we can potentially avoid.
      if ( value === "build" ) {
        if ( !this.building.built ) obj.executable.push({ value, label });
        return obj;
      }
      if ( value === "change" ) {
        if ( this.type.subtype === "garden" ) obj.executable.push({ value, label });
        return obj;
      }
      if ( this.type.value === "basic" ) {
        if ( !this.building.built ) return obj;
        if ( basic ) obj.executable.push({ value, label });
      } else if ( (this.type.value === "special") && !basic ) {
        obj.available.push({ value, label });
        if ( (value === this.order) || (value === "maintain") ) obj.executable.push({ value, label });
      }
      return obj;
    }, { available: [], executable: [] });

    if ( (this.type.value === "special") && ((this.order === "craft") || (this.order === "harvest")) ) {
      context.canCraft = true;
      context.isHarvesting = this.order === "harvest";
      const crafting = await fromUuid(this.craft.item);
      if ( crafting ) context.craft = {
        img: crafting.img,
        name: crafting.name,
        contentLink: crafting.toAnchor().outerHTML
      };
    }
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preCreate(data, options, user) {
    if ( (await super._preCreate(data, options, user)) === false ) return false;
    const actor = this.parent?.parent;
    if ( !actor ) return;
    const { basic } = CONFIG.DND5E.facilities.advancement;
    const [, available = 0] = Object.entries(basic).reverse()
      .find(([level]) => level <= actor.system.details.level) ?? [];
    const existing = actor.itemTypes.facility.filter(f => f.system.type.value === "basic").length;
    const free = available - existing;
    if ( free > 0 ) this.updateSource({ "building.built": true });
  }
}

const { BooleanField: BooleanField$7, NumberField: NumberField$8, SchemaField: SchemaField$9, SetField: SetField$9, StringField: StringField$f } = foundry.data.fields;

/**
 * @import { FeatItemSystemData } from "./_types.mjs";
 * @import {
 *   ActivitiesTemplateData, AdvancementTemplateData, ItemDescriptionTemplateData, ItemTypeTemplateData
 * } from "./templates/_types.mjs";
 */

/**
 * Data definition for Feature items.
 * @extends {ItemDataModel<
 *   ActivitiesTemplate & AdvancementTemplate & ItemDescriptionTemplate & ItemTypeTemplate & FeatItemSystemData
 * >}
 * @mixes ActivitiesTemplateData
 * @mixes AdvancementTemplateData
 * @mixes ItemDescriptionTemplateData
 * @mixes ItemTypeTemplateData
 * @mixes FeatItemSystemData
 */
class FeatData extends ItemDataModel$1.mixin(
  ActivitiesTemplate, AdvancementTemplate, ItemDescriptionTemplate, ItemTypeTemplate
) {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.FEATURE", "DND5E.ENCHANTMENT", "DND5E.Prerequisites", "DND5E.SOURCE"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return this.mergeSchema(super.defineSchema(), {
      cover: new NumberField$8({ min: 0, max: 1 }),
      crewed: new BooleanField$7(),
      enchant: new SchemaField$9({
        max: new FormulaField({ deterministic: true }),
        period: new StringField$f()
      }),
      prerequisites: new SchemaField$9({
        items: new SetField$9(new IdentifierField({ allowType: true })),
        level: new NumberField$8({ integer: true, min: 0 }),
        repeatable: new BooleanField$7()
      }),
      properties: new SetField$9(new StringField$f()),
      requirements: new StringField$f({ required: true, nullable: true }),
      type: new ItemTypeField({ baseItem: false })
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static metadata = Object.freeze(foundry.utils.mergeObject(super.metadata, {
    enchantable: true,
    hasEffects: true
  }, { inplace: false }));

  /* -------------------------------------------- */

  /** @override */
  static get compendiumBrowserFilters() {
    return new Map([
      ["category", {
        label: "DND5E.ITEM.Category.Label",
        type: "set",
        config: {
          choices: CONFIG.DND5E.featureTypes,
          keyPath: "system.type.value"
        }
      }],
      ["subtype", {
        label: "DND5E.ItemFeatureType",
        type: "set",
        config: {
          choices: filters => {
            const { anyPositive, anyNegative } = Object.values(filters?.additional?.category ?? {}).reduce((o, v) => {
              o.anyPositive ||= v > 0;
              o.anyNegative ||= v < 0;
              return o;
            }, { anyPositive: false, anyNegative: false });
            return Object.entries(CONFIG.DND5E.featureTypes).reduce((obj, [type, config]) => {
              if ( anyPositive && (filters.additional.category[type] !== 1) ) return obj;
              if ( anyNegative && (filters.additional.category[type] < 0) ) return obj;
              for ( const [key, label] of Object.entries(config.subtypes ?? {}) ) obj[key] = label;
              return obj;
            }, {})
          },
          keyPath: "system.type.subtype"
        }
      }],
      ["properties", this.compendiumBrowserPropertiesFilter("feat")],
      ["abilityScoreImprovement", {
        label: "DND5E.ADVANCEMENT.AbilityScoreImprovement.Title",
        type: "set",
        config: {
          choices: CONFIG.DND5E.abilities
        },
        createFilter: (filters, value, def) => {
          const { include, exclude } = Object.entries(value).reduce((d, [key, value]) => {
            if ( value === 1 ) d.include.push(key);
            else if ( value === -1 ) d.exclude.push(key);
            return d;
          }, { include: [], exclude: [] });

          const makeFilter = values => ({
            o: "OR", v: [
              ...values.map(ability => ({
                k: "system.advancement", o: "has", v: { k: `configuration.fixed.${ability}`, o: "gt", v: 0 }
              })),
              {
                k: "system.advancement", o: "has", v: {
                  o: "AND", v: [
                    { k: "configuration.points", o: "gt", v: 0 },
                    { o: "NOT", v: { k: "configuration.locked", o: "hasall", v: values } }
                  ]
                }
              }
            ]
          });

          if ( include.length ) filters.push(makeFilter(include));
          if ( exclude.length ) filters.push({ o: "NOT", v: makeFilter(exclude) });
        }
      }]
    ]);
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  get advancementClassLinked() {
    return this.type.value !== "feat";
  }

  /* -------------------------------------------- */

  /**
   * Properties displayed in chat.
   * @type {string[]}
   */
  get chatProperties() {
    return [this.requirements];
  }

  /* -------------------------------------------- */

  /**
   * Properties displayed on the item card.
   * @type {string[]}
   */
  get cardProperties() {
    return [this.requirements];
  }

  /* -------------------------------------------- */

  /**
   * Does this feature represent a group of individual enchantments (e.g. the "Infuse Item" feature stores data about
   * all of the character's infusions).
   * @type {boolean}
   */
  get isEnchantmentSource() {
    return CONFIG.DND5E.featureTypes[this.type?.value]?.subtypes?.[this.type?.subtype]
      && (this.type?.subtype in CONFIG.DND5E.featureTypes.enchantment.subtypes);
  }

  /* -------------------------------------------- */

  /**
   * The proficiency multiplier for this item.
   * @returns {number}
   */
  get proficiencyMultiplier() {
    return 1;
  }

  /* -------------------------------------------- */
  /*  Data Migration                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static _migrateData(source) {
    super._migrateData(source);
    FeatData.#migrateEnchantment(source);
    ActivitiesTemplate.migrateActivities(source);
    FeatData.#migrateType(source);
    FeatData.#migrateRecharge(source);
  }

  /* -------------------------------------------- */

  /**
   * Migrate enchantment data format.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateEnchantment(source) {
    if ( foundry.utils.getType(source.enchantment?.items) !== "Object" ) return;
    const { items } = source.enchantment;
    source.enchant ??= {};
    if ( "max" in items ) source.enchant.max = items.max;
    if ( "period" in items ) source.enchant.period = items.period;
    delete source.enchantment.items;
  }

  /* -------------------------------------------- */

  /**
   * 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;
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareDerivedData() {
    super.prepareDerivedData();
    this.prepareDescriptionData();

    if ( this.type.value ) {
      const config = CONFIG.DND5E.featureTypes[this.type.value];
      if ( config ) this.type.label = config.subtypes?.[this.type.subtype] ?? null;
      else this.type.label = game.i18n.localize(CONFIG.Item.typeLabels.feat);
    }

    let label;
    const activation = this.activities.contents[0]?.activation.type;
    if ( activation === "legendary" ) label = game.i18n.localize("DND5E.LegendaryAction.Label");
    if ( activation === "mythic" ) label = game.i18n.localize("DND5E.MythicActionLabel");
    else if ( activation === "lair" ) label = game.i18n.localize("DND5E.LAIR.Action.Label");
    else if ( activation === "action" && this.hasAttack ) label = game.i18n.localize("DND5E.Attack");
    else if ( activation ) label = game.i18n.localize("DND5E.Action");
    else label = game.i18n.localize("DND5E.Passive");
    this.parent.labels ??= {};
    this.parent.labels.featType = label;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareFinalData() {
    this.prepareFinalActivityData(this.parent.getRollData({ deterministic: true }));
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getFavoriteData() {
    return foundry.utils.mergeObject(await super.getFavoriteData(), {
      subtitle: [this.parent.labels.activation, this.parent.labels.recovery],
      uses: this.hasLimitedUses ? this.getUsesData() : null
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getSheetData(context) {
    context.subtitles = [
      { label: this.type.label },
      { label: this.parent.labels.featType },
      { label: this.requirements, value: this._source.requirements, field: this.schema.getField("requirements"),
        placeholder: "DND5E.Requirements" }
    ];

    context.parts = ["dnd5e.details-feat", "dnd5e.field-uses"];
    const itemTypes = CONFIG.DND5E.featureTypes[this._source.type.value];
    if ( itemTypes ) {
      context.itemType = itemTypes.label;
      context.itemSubtypes = itemTypes.subtypes;
    }
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Validate the prerequisites specified on this item.
   * @param {Actor5e} actor                        Actor against which the prerequisites should be checked.
   * @param {object} [options={}]
   * @param {Item5e[]} [options.added]             Items that are pending addition to the Actor.
   * @param {number} [options.level]               Level to validate. Falls back to character level.
   * @param {Item5e[]} [options.removed]           Items that are pending removal from the Actor.
   * @param {boolean} [options.showMessage=false]  Show a UI message if the validation fails.
   * @param {boolean} [options.throwError=false]   Throw an error if validation fails.
   * @returns {true|string[]}  True if the item is valid or a list of invalid descriptions if validation failed.
   */
  validatePrerequisites(actor, {
    added=[], level=actor.system?.details?.level, removed=[], showMessage=false, throwError=false
  }={}) {
    const messages = [];

    // Check to ensure the item doesn't already exist on actor if it is not repeatable
    if ( !this.prerequisites.repeatable && actor.sourcedItems?.get(this.parent.uuid)?.size ) {
      messages.push(game.i18n.localize("DND5E.Prerequisites.Warning.NotRepeatable"));
    }

    // If a feature has item pre-requisites, make sure the other items exist on the actor
    const pendingAddition = new Set(added.map(i => i.system.identifier));
    const pendingRemoval = new Set(removed.map(i => i.system.identifier));
    const someExist = !this.prerequisites.items.size || Array.from(this.prerequisites.items).some(i => {
      return (actor.identifiedItems.get(i)?.size || pendingAddition.has(i)) && !pendingRemoval.has(i);
    });
    if ( !someExist ) {
      messages.push(game.i18n.format("DND5E.Prerequisites.Warning.MissingItem", {
        items: game.i18n.getListFormatter({ type: "disjunction" }).format(Array.from(this.prerequisites.items))
      }));
    }

    // If a feature has a level pre-requisite, make sure it is less than or equal to current level
    if ( (this.prerequisites?.level ?? -Infinity) > (level ?? Infinity) ) {
      messages.push(game.i18n.format("DND5E.Prerequisites.Warning.InvalidLevel", {
        level: this.prerequisites.level
      }));
    }

    if ( !messages.length ) return true;

    if ( showMessage || throwError ) {
      const message = game.i18n.format("DND5E.Prerequisites.Warning.Message", {
        actor: actor.name,
        requirements: game.i18n.getListFormatter().format(messages),
        type: game.i18n.localize(CONFIG.Item.typeLabels[this.parent.type]).toLowerCase()
      });
      if ( showMessage ) ui.notifications.warn(message);
      if ( throwError ) throw new Error(message);
    }

    return messages;
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preCreate(data, options, user) {
    if ( (await super._preCreate(data, options, user)) === false ) return false;

    // Set type as "Monster Feature" if created directly on a NPC
    if ( (this.parent.actor?.type === "npc") && !foundry.utils.hasProperty(data, "system.type.value") ) {
      this.updateSource({ "type.value": "monster" });
    }
  }
}

const { SetField: SetField$8, StringField: StringField$e } = foundry.data.fields;

/**
 * @import { InventorySectionDescriptor } from "../../applications/components/_types.mjs";
 * @import { LootItemSystemData } from "./_types.mjs";
 * @import {
 *   IdentifiableTemplateData, ItemDescriptionTemplateData, ItemTypeTemplateData, PhysicalItemTemplateData
 * } from "./templates/_types.mjs";
 */

/**
 * Data definition for Loot items.
 * @extends {ItemDataModel<
 *   ItemDescriptionTemplate & IdentifiableTemplate & ItemTypeTemplate & PhysicalItemTemplate & LootItemSystemData
 * >}
 * @mixes ItemDescriptionTemplateData
 * @mixes ItemTypeTemplateData
 * @mixes IdentifiableTemplateData
 * @mixes PhysicalItemTemplateData
 * @mixes LootItemSystemData
 */
class LootData extends ItemDataModel$1.mixin(
  ItemDescriptionTemplate, IdentifiableTemplate, ItemTypeTemplate, PhysicalItemTemplate
) {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.SOURCE"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return this.mergeSchema(super.defineSchema(), {
      properties: new SetField$8(new StringField$e(), { label: "DND5E.ItemLootProperties" }),
      type: new ItemTypeField({ baseItem: false }, { label: "DND5E.ItemLootType" })
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static metadata = Object.freeze(foundry.utils.mergeObject(super.metadata, {
    enchantable: true
  }, {inplace: false}));

  /* -------------------------------------------- */

  /** @override */
  static get compendiumBrowserFilters() {
    return new Map([
      ["type", {
        label: "DND5E.ItemLootType",
        type: "set",
        config: {
          choices: CONFIG.DND5E.lootTypes,
          keyPath: "system.type.value"
        }
      }],
      ...this.compendiumBrowserPhysicalItemFilters,
      ["properties", this.compendiumBrowserPropertiesFilter("loot")]
    ]);
  }

  /* -------------------------------------------- */

  /**
   * Default configuration for this item type's inventory section.
   * @returns {InventorySectionDescriptor}
   */
  static get inventorySection() {
    return {
      id: "loot",
      order: 600,
      label: "TYPES.Item.lootPl",
      groups: { type: "loot" },
      columns: ["price", "weight", "quantity", "charges", "controls"]
    };
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Properties displayed in chat.
   * @type {string[]}
   */
  get chatProperties() {
    return [
      this.type.label,
      this.weight ? `${this.weight.value} ${game.i18n.localize("DND5E.AbbreviationLbs")}` : null,
      this.priceLabel
    ];
  }

  /* -------------------------------------------- */

  /** @override */
  static get itemCategories() {
    return CONFIG.DND5E.lootTypes;
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareDerivedData() {
    super.prepareDerivedData();
    this.prepareDescriptionData();
    this.prepareIdentifiable();
    this.preparePhysicalData();
    this.type.label = CONFIG.DND5E.lootTypes[this.type.value]?.label ?? game.i18n.localize(CONFIG.Item.typeLabels.loot);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getSheetData(context) {
    context.subtitles = [
      { label: this.type.label },
      ...this.physicalItemSheetFields
    ];

    context.parts = ["dnd5e.details-loot"];
    const itemTypes = CONFIG.DND5E.lootTypes[this._source.type.value];
    if ( itemTypes ) {
      context.itemType = itemTypes.label;
      context.itemSubtypes = itemTypes.subtypes;
    }
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preUpdate(changed, options, user) {
    if ( (await super._preUpdate(changed, options, user)) === false ) return false;
    await this.preUpdateIdentifiable(changed, options, user);
  }
}

var _module$7 = /*#__PURE__*/Object.freeze({
  __proto__: null,
  ActivationField: ActivationField,
  CreatureTypeField: CreatureTypeField,
  CurrencyTemplate: CurrencyTemplate,
  DamageData: DamageData,
  DamageField: DamageField,
  DurationField: DurationField,
  MovementField: MovementField,
  RangeField: RangeField,
  RollConfigField: RollConfigField,
  SensesField: SensesField,
  SourceField: SourceField,
  TargetField: TargetField,
  UsesField: UsesField
});

/**
 * @import { RaceItemSystemData } from "./_types.mjs";
 * @import { AdvancementTemplateData, ItemDescriptionTemplateData } from "./templates/_types.mjs";
 */

/**
 * Data definition for Race items.
 * @extends {ItemDataModel<AdvancementTemplate, ItemDescriptionTemplate & RaceItemSystemData>}
 * @mixes AdvancementTemplateData
 * @mixes ItemDescriptionTemplateData
 * @mixes RaceItemSystemData
 */
class RaceData extends ItemDataModel$1.mixin(AdvancementTemplate, ItemDescriptionTemplate) {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.SOURCE"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return this.mergeSchema(super.defineSchema(), {
      movement: new MovementField({ bonus: false, special: false }, { initialUnits: defaultUnits("length") }),
      senses: new SensesField({}, { initialUnits: defaultUnits("length") }),
      type: new CreatureTypeField({ swarm: false }, { initial: { value: "humanoid" } })
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static metadata = Object.freeze(foundry.utils.mergeObject(super.metadata, {
    singleton: true
  }, {inplace: false}));

  /* -------------------------------------------- */

  /** @override */
  static get compendiumBrowserFilters() {
    return new Map([
      ["hasDarkvision", {
        label: "DND5E.CompendiumBrowser.Filters.HasDarkvision",
        type: "boolean",
        createFilter: (filters, value, def) => {
          if ( value === 0 ) return;
          const filter = { k: "system.senses.darkvision", o: "gt", v: 0 };
          if ( value === 1 ) filters.push(filter);
          else filters.push({ o: "NOT", v: filter });
        }
      }]
    ]);
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Sheet labels for a race's movement.
   * @returns {Object<string>}
   */
  get movementLabels() {
    const units = this.movement.units || defaultUnits("length");
    return Object.entries(CONFIG.DND5E.movementTypes).reduce((obj, [k, { label }]) => {
      const value = this.movement[k];
      if ( value ) obj[k] = `${label} ${formatLength(value, units)}`;
      return obj;
    }, {});
  }

  /* -------------------------------------------- */

  /**
   * Sheet labels for a race's senses.
   * @returns {Object<string>}
   */
  get sensesLabels() {
    const units = this.senses.units || defaultUnits("length");
    return Object.entries(CONFIG.DND5E.senses).reduce((arr, [k, label]) => {
      const value = this.senses[k];
      if ( value ) arr.push(`${label} ${formatLength(value, units)}`);
      return arr;
    }, []).concat(splitSemicolons(this.senses.special));
  }

  /* -------------------------------------------- */

  /**
   * Sheet label for a race's creature type.
   * @returns {Object<string>}
   */
  get typeLabel() {
    return Actor5e.formatCreatureType(this.type);
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareDerivedData() {
    super.prepareDerivedData();
    this.prepareDescriptionData();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getSheetData(context) {
    context.subtitles = [{ label: game.i18n.localize(CONFIG.Item.typeLabels.race) }];
    context.singleDescription = true;

    context.parts = ["dnd5e.details-species"];
    context.info = [{
      label: "DND5E.CreatureType",
      classes: "info-sm",
      value: this.typeLabel,
      config: "type",
      tooltip: "DND5E.CreatureTypeTitle"
    },
    {
      label: "DND5E.Movement",
      classes: "info-sm info-grid",
      config: "movement",
      tooltip: "DND5E.MOVEMENT.Action.Configure",
      value: Object.entries(CONFIG.DND5E.movementTypes).reduce((str, [k, { label }]) => {
        const value = this.movement[k];
        if ( !value ) return str;
        return `${str}
          <span class="key">${label}</span>
          <span class="value">${value}</span>
        `;
      }, "")
    },
    {
      label: "DND5E.Senses",
      classes: "info-sm info-grid",
      config: "senses",
      tooltip: "DND5E.SensesConfig",
      value: Object.entries(CONFIG.DND5E.senses).reduce((str, [k, label]) => {
        const value = this.senses[k];
        if ( !value ) return str;
        return `${str}
          <span class="key">${label}</span>
          <span class="value">${value}</span>
        `;
      }, "")
    }];
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /** @override */
  _advancementToCreate(options) {
    if ( game.settings.get("dnd5e", "rulesVersion") === "legacy" ) return [
      { type: "AbilityScoreImprovement" },
      { type: "Size" },
      { type: "Trait", configuration: { grants: ["languages:standard:common"] } }
    ];

    return [{ type: "Size" }];
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preCreate(data, options, user) {
    if ( (await super._preCreate(data, options, user)) === false ) return false;
    await this.preCreateAdvancement(data, options);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onCreate(data, options, userId) {
    super._onCreate(data, options, userId);
    if ( (game.user.id !== userId) || !["character", "npc"].includes(this.parent.actor?.type) ) return;
    this.parent.actor.update({ "system.details.race": this.parent.id });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preDelete(options, user) {
    if ( (await super._preDelete(options, user)) === false ) return false;
    if ( !["character", "npc"].includes(this.parent.actor?.type) ) return;
    await this.parent.actor.update({ "system.details.race": null });
  }
}

/**
 * @import { SubclassItemSystemData } from "./_types.mjs";
 * @import { ItemDescriptionTemplateData } from "./templates/_types.mjs";
 * @import { AdvancementTemplateData, ItemDescriptionTemplateData } from "./templates/_types.mjs";
 */

/**
 * Data definition for Subclass items.
 * @extends {ItemDataModel<AdvancementTemplate & ItemDescriptionTemplate & SubclassItemSystemData>}
 * @mixes AdvancementTemplateData
 * @mixes ItemDescriptionTemplateData
 * @mixes SubclassItemSystemData
 */
class SubclassData extends ItemDataModel$1.mixin(AdvancementTemplate, ItemDescriptionTemplate) {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.SOURCE"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return this.mergeSchema(super.defineSchema(), {
      classIdentifier: new IdentifierField({
        required: true, label: "DND5E.ClassIdentifier", hint: "DND5E.ClassIdentifierHint"
      }),
      spellcasting: new SpellcastingField()
    });
  }

  /* -------------------------------------------- */

  /** @override */
  static get compendiumBrowserFilters() {
    return new Map([
      ["class", {
        label: "TYPES.Item.class",
        type: "set",
        config: {
          choices: dnd5e.registry.classes.choices,
          keyPath: "system.classIdentifier"
        }
      }],
      ["hasSpellcasting", {
        label: "DND5E.CompendiumBrowser.Filters.HasSpellcasting",
        type: "boolean",
        createFilter: (filters, value, def) => {
          if ( value === 0 ) return;
          const filter = { k: "system.spellcasting.progression", v: "none" };
          if ( value === -1 ) filters.push(filter);
          else filters.push({ o: "NOT", v: filter });
        }
      }]
    ]);
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareBaseData() {
    super.prepareBaseData();
    this.spellcasting.preparation.value = 0;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareDerivedData() {
    super.prepareDerivedData();
    this.prepareDescriptionData();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareFinalData() {
    SpellcastingField.prepareData.call(this, this.parent.getRollData({ deterministic: true }));
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getSheetData(context) {
    context.subtitles = [{ label: game.i18n.localize(CONFIG.Item.typeLabels.subclass) }];
    context.singleDescription = true;
    context.parts = ["dnd5e.details-subclass", "dnd5e.details-spellcasting"];
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onCreate(data, options, userId) {
    super._onCreate(data, options, userId);
    const actor = this.parent.actor;
    if ( !actor || (userId !== game.user.id) ) return;
    if ( !actor.system.attributes?.spellcasting && this.parent.spellcasting?.ability ) {
      actor.update({ "system.attributes.spellcasting": this.parent.spellcasting.ability });
    }
  }
}

const { NumberField: NumberField$7, SetField: SetField$7, StringField: StringField$d } = foundry.data.fields;

/**
 * @import { InventorySectionDescriptor } from "../../applications/components/_types.mjs";
 * @import { ToolItemSystemData } from "./_types.mjs";
 * @import {
 *   ActivitiesTemplateData, EquippableItemTemplateData, IdentifiableTemplateData,
 *   ItemDescriptionTemplateData, ItemTypeTemplateData, PhysicalItemTemplateData
 * } from "./templates/_types.mjs";
 */

/**
 * Data definition for Tool items.
 * @extends {ItemDataModel<
 *   ActivitiesTemplate & ItemDescriptionTemplate & IdentifiableTemplate & ItemTypeTemplate &
 *   PhysicalItemTemplate & EquippableItemTemplate & ToolItemSystemData
 * >}
 * @mixes ActivitiesTemplateData
 * @mixes ItemDescriptionTemplateData
 * @mixes ItemTypeTemplateData
 * @mixes IdentifiableTemplateData
 * @mixes PhysicalItemTemplateData
 * @mixes EquippableItemTemplateData
 * @mixes ToolItemSystemData
 */
class ToolData extends ItemDataModel$1.mixin(
  ActivitiesTemplate, ItemDescriptionTemplate, IdentifiableTemplate, ItemTypeTemplate,
  PhysicalItemTemplate, EquippableItemTemplate
) {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.SOURCE"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return this.mergeSchema(super.defineSchema(), {
      ability: new StringField$d({ required: true, blank: true, label: "DND5E.DefaultAbilityCheck" }),
      bonus: new FormulaField({ required: true, label: "DND5E.ItemToolBonus" }),
      chatFlavor: new StringField$d({ required: true, label: "DND5E.ChatFlavor" }),
      proficient: new NumberField$7({
        required: true, initial: null, min: 0, max: 2, step: 0.5, label: "DND5E.ItemToolProficiency"
      }),
      properties: new SetField$7(new StringField$d(), { label: "DND5E.ItemToolProperties" }),
      type: new ItemTypeField({ subtype: false }, { label: "DND5E.ItemToolType" })
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static metadata = Object.freeze(foundry.utils.mergeObject(super.metadata, {
    hasEffects: true,
    enchantable: true
  }, {inplace: false}));

  /* -------------------------------------------- */

  /** @override */
  static get compendiumBrowserFilters() {
    return new Map([
      ["type", {
        label: "DND5E.ItemToolType",
        type: "set",
        config: {
          choices: CONFIG.DND5E.toolTypes,
          keyPath: "system.type.value"
        }
      }],
      ["attunement", this.compendiumBrowserAttunementFilter],
      ...this.compendiumBrowserPhysicalItemFilters,
      ["properties", this.compendiumBrowserPropertiesFilter("tool")]
    ]);
  }

  /* -------------------------------------------- */

  /**
   * Default configuration for this item type's inventory section.
   * @returns {InventorySectionDescriptor}
   */
  static get inventorySection() {
    return {
      id: "tool",
      order: 400,
      label: "TYPES.Item.toolPl",
      groups: { type: "tool" },
      columns: ["price", "weight", "quantity", "charges", "controls"]
    };
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Properties displayed in chat.
   * @type {string[]}
   */
  get chatProperties() {
    return [CONFIG.DND5E.abilities[this.ability]?.label];
  }

  /* -------------------------------------------- */

  /**
   * Properties displayed on the item card.
   * @type {string[]}
   */
  get cardProperties() {
    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";
  }

  /* -------------------------------------------- */

  /** @override */
  static get itemCategories() {
    return CONFIG.DND5E.toolTypes;
  }

  /* -------------------------------------------- */

  /**
   * The proficiency multiplier for this item.
   * @returns {number}
   */
  get proficiencyMultiplier() {
    if ( Number.isFinite(this.proficient) ) return this.proficient;
    const actor = this.parent.actor;
    if ( !actor ) return 0;
    if ( actor.type === "npc" ) return 1;
    const baseItemProf = actor.system.tools?.[this.type.baseItem];
    const categoryProf = actor.system.tools?.[this.type.value];
    return Math.max(baseItemProf?.value ?? 0, categoryProf?.value ?? 0);
  }

  /* -------------------------------------------- */
  /*  Data Migration                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static _migrateData(source) {
    super._migrateData(source);
    ActivitiesTemplate.migrateActivities(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];
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareDerivedData() {
    super.prepareDerivedData();
    this.prepareDescriptionData();
    this.prepareIdentifiable();
    this.preparePhysicalData();
    this.type.label = CONFIG.DND5E.toolTypes[this.type.value] ?? game.i18n.localize(CONFIG.Item.typeLabels.tool);
    this.type.identifier = CONFIG.DND5E.tools[this.type.baseItem]?.id;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareFinalData() {
    this.prepareFinalActivityData(this.parent.getRollData({ deterministic: true }));
    this.prepareFinalEquippableData();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getFavoriteData() {
    return foundry.utils.mergeObject(await super.getFavoriteData(), {
      subtitle: this.type.label,
      modifier: this.parent.parent?.system.tools?.[this.type.baseItem]?.total
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getSheetData(context) {
    context.subtitles = [
      { label: this.type.label },
      ...this.physicalItemSheetFields
    ];
    context.parts = ["dnd5e.details-tool", "dnd5e.field-uses"];
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preCreate(data, options, user) {
    if ( (await super._preCreate(data, options, user)) === false ) return false;
    if ( this.activities.size ) return;

    const activityData = new CONFIG.DND5E.activityTypes.check.documentClass({}, { parent: this.parent }).toObject();
    this.parent.updateSource({ [`system.activities.${activityData._id}`]: activityData });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preUpdate(changed, options, user) {
    if ( (await super._preUpdate(changed, options, user)) === false ) return false;
    await this.preUpdateIdentifiable(changed, options, user);
  }
}

const { NumberField: NumberField$6, SchemaField: SchemaField$8, SetField: SetField$6, StringField: StringField$c } = foundry.data.fields;

/**
 * @import { InventorySectionDescriptor } from "../../applications/components/_types.mjs";
 * @import { WeaponItemSystemData } from "./_types.mjs";
 * @import {
 *   ActivitiesTemplateData, EquippableItemTemplateData, IdentifiableTemplateData,
 *   ItemDescriptionTemplateData, ItemTypeTemplateData, MountableTemplateData, PhysicalItemTemplateData
 * } from "./templates/_types.mjs";
 */

/**
 * Data definition for Weapon items.
 * @extends {ItemDataModel<
 *   ActivitiesTemplate & ItemDescriptionTemplate & IdentifiableTemplate & ItemTypeTemplate &
 *   PhysicalItemTemplate & EquippableItemTemplate & MountableTemplate & WeaponItemSystemData
 * >}
 * @mixes ActivitiesTemplateData
 * @mixes ItemDescriptionTemplateData
 * @mixes ItemTypeTemplateData
 * @mixes IdentifiableTemplateData
 * @mixes PhysicalItemTemplateData
 * @mixes EquippableItemTemplateData
 * @mixes MountableTemplateData
 * @mixes WeaponItemSystemData
 */
class WeaponData extends ItemDataModel$1.mixin(
  ActivitiesTemplate, ItemDescriptionTemplate, IdentifiableTemplate, ItemTypeTemplate,
  PhysicalItemTemplate, EquippableItemTemplate, MountableTemplate
) {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.WEAPON", "DND5E.VEHICLE.MOUNTABLE", "DND5E.RANGE", "DND5E.SOURCE"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return this.mergeSchema(super.defineSchema(), {
      ammunition: new SchemaField$8({
        type: new StringField$c()
      }),
      armor: new SchemaField$8({
        value: new NumberField$6({ integer: true, min: 0 })
      }),
      damage: new SchemaField$8({
        base: new DamageField(),
        versatile: new DamageField()
      }),
      magicalBonus: new NumberField$6({ min: 0, integer: true, label: "DND5E.MagicalBonus" }),
      mastery: new StringField$c(),
      properties: new SetField$6(new StringField$c(), { label: "DND5E.ItemWeaponProperties" }),
      proficient: new NumberField$6({
        required: true, min: 0, max: 1, integer: true, initial: null, label: "DND5E.ProficiencyLevel"
      }),
      range: new SchemaField$8({
        value: new NumberField$6({ min: 0 }),
        long: new NumberField$6({ min: 0 }),
        reach: new NumberField$6({ min: 0 }),
        units: new StringField$c({ required: true, blank: false, initial: () => defaultUnits("length") })
      }),
      type: new ItemTypeField({ value: "simpleM", subtype: false }, {label: "DND5E.ItemWeaponType"})
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static metadata = Object.freeze(foundry.utils.mergeObject(super.metadata, {
    hasEffects: true,
    enchantable: true
  }, {inplace: false}));

  /* -------------------------------------------- */

  /** @override */
  static get compendiumBrowserFilters() {
    return new Map([
      ["type", {
        label: "DND5E.ItemWeaponType",
        type: "set",
        config: {
          choices: CONFIG.DND5E.weaponTypes,
          keyPath: "system.type.value"
        }
      }],
      ["mastery", {
        label: "DND5E.WEAPON.Mastery.Label",
        type: "set",
        config: {
          choices: CONFIG.DND5E.weaponMasteries,
          keyPath: "system.mastery"
        }
      }],
      ["attunement", this.compendiumBrowserAttunementFilter],
      ...this.compendiumBrowserPhysicalItemFilters,
      ["properties", this.compendiumBrowserPropertiesFilter("weapon")]
    ]);
  }

  /* -------------------------------------------- */

  /**
   * Default configuration for this item type's inventory section.
   * @returns {InventorySectionDescriptor}
   */
  static get inventorySection() {
    return {
      id: "weapons",
      order: 100,
      label: "TYPES.Item.weaponPl",
      groups: { type: "weapon" },
      columns: ["price", "weight", "quantity", "charges", "controls"]
    };
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Ammunition that can be used with this weapon.
   * @type {FormSelectOption[]}
   */
  get ammunitionOptions() {
    if ( !this.parent.actor || !this.properties.has("amm") ) return [];
    return this.parent.actor.itemTypes.consumable
      .filter(i => (i.system.type.value === "ammo")
        && (!this.ammunition?.type || (i.system.type.subtype === this.ammunition.type)))
      .map(item => ({
        item,
        value: item.id,
        label: `${item.name} (${item.system.quantity})`,
        disabled: !item.system.quantity
      }))
      .sort((lhs, rhs) => lhs.label.localeCompare(rhs.label, game.i18n.lang));
  }

  /* -------------------------------------------- */

  /**
   * Attack classification of this weapon.
   * @type {"weapon"|"unarmed"}
   */
  get attackClassification() {
    return CONFIG.DND5E.weaponClassificationMap[this.type.value] ?? "weapon";
  }

  /* -------------------------------------------- */

  /** @override */
  get attackModes() {
    const modes = [];

    // Thrown ranged weapons will just display the "Thrown" mode
    if ( !(this.properties.has("thr") && (this.attackType === "ranged")) ) {
      // Weapons without the "Two-Handed" property or with the "Versatile" property will have One-Handed attack
      if ( this.isVersatile || !this.properties.has("two") ) modes.push({
        value: "oneHanded", label: CONFIG.DND5E.attackModes.oneHanded.label
      });

      // Weapons with the "Two-Handed" property or with the "Versatile" property will have Two-Handed attack
      if ( this.isVersatile || this.properties.has("two") ) modes.push({
        value: "twoHanded", label: CONFIG.DND5E.attackModes.twoHanded.label
      });
    }

    const isLight = this.properties.has("lgt") || (this.parent.actor?.getFlag("dnd5e", "enhancedDualWielding")
      && ((this.attackType === "melee") && !this.properties.has("two")));

    // Weapons with the "Light" property will have Offhand attack
    // If player has the "Enhanced Dual Wielding" flag, then allow any melee weapon without the "Two-Handed" property
    if ( isLight ) modes.push({
      value: "offhand", label: CONFIG.DND5E.attackModes.offhand.label
    });

    // Weapons with the "Thrown" property will have Thrown attack
    if ( this.properties.has("thr") ) {
      if ( modes.length ) modes.push({ rule: true });
      modes.push({ value: "thrown", label: CONFIG.DND5E.attackModes.thrown.label });

      // Weapons with the "Thrown" & "Light" properties will have an Offhand Throw attack
      if ( isLight ) modes.push({
        value: "thrown-offhand", label: CONFIG.DND5E.attackModes["thrown-offhand"].label
      });
    }

    else if ( !this.attackType && ((this.range.value ?? 0) > (this.range.reach ?? 0)) ) {
      if ( modes.length ) modes.push({ rule: true });
      modes.push({ value: "ranged", label: CONFIG.DND5E.attackModes.ranged.label });
    }

    return modes;
  }

  /* -------------------------------------------- */

  /**
   * Attack type offered by this weapon.
   * @type {"melee"|"ranged"|null}
   */
  get attackType() {
    return CONFIG.DND5E.weaponTypeMap[this.type.value] ?? null;
  }

  /* -------------------------------------------- */

  /** @override */
  get availableAbilities() {
    const melee = CONFIG.DND5E.defaultAbilities.meleeAttack;
    const ranged = CONFIG.DND5E.defaultAbilities.rangedAttack;
    if ( this.properties.has("fin") || (this.type.value === "natural") ) return new Set([melee, ranged]);
    if ( !this.attackType ) return null;
    return new Set([this.attackType === "melee" ? melee : ranged]);
  }

  /* -------------------------------------------- */

  /**
   * Properties displayed in chat.
   * @type {string[]}
   */
  get chatProperties() {
    return [
      this.type.label,
      CONFIG.DND5E.weaponMasteries[this.mastery]?.label,
      this.isMountable ? (this.parent.labels?.armor ?? null) : null
    ];
  }

  /* -------------------------------------------- */

  /**
   * Properties displayed on the item card.
   * @type {string[]}
   */
  get cardProperties() {
    return [
      this.isMountable ? (this.parent.labels?.armor ?? null) : null
    ];
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  get _typeAbilityMod() {
    const availableAbilities = this.availableAbilities;
    if ( !availableAbilities ) return null;
    if ( availableAbilities.size === 1 ) return availableAbilities.first();
    const abilities = this.parent?.actor?.system.abilities ?? {};
    return availableAbilities.reduce((largest, ability) =>
      (abilities[ability]?.mod ?? -Infinity) > (abilities[largest]?.mod ?? -Infinity) ? ability : largest
    , availableAbilities.first());
  }

  /* -------------------------------------------- */

  /** @override */
  get criticalThreshold() {
    return this.parent?.actor?.flags.dnd5e?.weaponCriticalThreshold ?? Infinity;
  }

  /* -------------------------------------------- */

  /**
   * Is the range value relevant to this weapon?
   * @type {boolean}
   */
  get hasRange() {
    return (this.attackType === "ranged") || this.properties.has("thr")
      || ((this.type.value === "natural") && this.range.value);
  }

  /* -------------------------------------------- */

  /**
   * 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.type.value === "siege";
  }

  /* -------------------------------------------- */

  /** @override */
  static get itemCategories() {
    return CONFIG.DND5E.weaponTypes;
  }

  /* -------------------------------------------- */

  /**
   * Does the Weapon implement a versatile damage roll as part of its usage?
   * @type {boolean}
   */
  get isVersatile() {
    return this.properties.has("ver");
  }

  /* -------------------------------------------- */

  /**
   * Mastery options that can be used when attacking with this weapon.
   * @type {FormSelectOption[]|null}
   */
  get masteryOptions() {
    if ( !this.parent.actor?.system.traits?.weaponProf?.mastery?.value?.has?.(this.type.baseItem) || !this.mastery ) {
      return null;
    }
    const extras = [];
    for ( const mastery of this.parent.actor.system.traits.weaponProf.mastery.bonus ?? [] ) {
      if ( mastery === this.mastery ) continue;
      extras.push({ value: mastery, label: CONFIG.DND5E.weaponMasteries[mastery]?.label ?? mastery });
    }
    return [{
      value: this.mastery,
      label: CONFIG.DND5E.weaponMasteries[this.mastery]?.label ?? this.mastery,
      rule: !!extras.length
    }, ...extras];
  }

  /* -------------------------------------------- */

  /**
   * Does this item have base damage defined in `damage.base` to offer to an activity?
   * @type {boolean}
   */
  get offersBaseDamage() {
    return true;
  }

  /* -------------------------------------------- */

  /**
   * The proficiency multiplier for this item.
   * @returns {number}
   */
  get proficiencyMultiplier() {
    if ( Number.isFinite(this.proficient) ) return this.proficient;
    const actor = this.parent.actor;
    if ( !actor ) return 0;
    if ( actor.type === "npc" ) return 1; // NPCs are always considered proficient with any weapon in their stat block.
    const config = CONFIG.DND5E.weaponProficienciesMap;
    const itemProf = config[this.type.value];
    const actorProfs = actor.system.traits?.weaponProf?.value ?? new Set();
    const natural = this.type.value === "natural";
    const improvised = (this.type.value === "improv") && !!actor.getFlag("dnd5e", "tavernBrawlerFeat");
    const isProficient = natural || improvised || actorProfs.has(itemProf) || actorProfs.has(this.type.baseItem);
    return Number(isProficient);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  get tooltipSubtitle() {
    return [...super.tooltipSubtitle, CONFIG.DND5E.weaponMasteries[this.mastery]?.label];
  }

  /* -------------------------------------------- */

  /**
   * Attack types that can be used with this item by default.
   * @type {Set<string>}
   */
  get validAttackTypes() {
    const types = new Set();
    const attackType = this.attackType;
    if ( (attackType === "melee") || (attackType === null) ) types.add("melee");
    if ( (attackType === "ranged") || this.properties.has("thr")
      || ((attackType === null) && this.range.value) ) types.add("ranged");
    return types;
  }

  /* -------------------------------------------- */
  /*  Data Migration                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static _migrateData(source) {
    super._migrateData(source);
    ActivitiesTemplate.migrateActivities(source);
    WeaponData.#migrateDamage(source);
    WeaponData.#migratePropertiesData(source);
    WeaponData.#migrateProficient(source);
    WeaponData.#migrateReach(source);
  }

  /* -------------------------------------------- */

  /**
   * Migrate weapon damage from old parts.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateDamage(source) {
    if ( "base" in (source.damage ?? {}) ) return;
    const systemData = { system: { scaling: { mode: "none" } } };
    if ( source.damage?.parts?.[0] ) {
      source.damage.base = BaseActivityData.transformDamagePartData(systemData, source.damage.parts.shift());
      if ( source.damage.base.bonus === "@mod" ) source.damage.base.bonus = "";
    }
    if ( foundry.utils.getType(source.damage?.versatile) === "string" ) {
      source.damage.versatile = BaseActivityData.transformDamagePartData(systemData, [source.damage?.versatile, ""]);
      if ( source.damage.versatile.bonus === "@mod" ) source.damage.versatile.bonus = "";
    }
  }

  /* -------------------------------------------- */

  /**
   * Migrate the properties object into a set.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migratePropertiesData(source) {
    if ( foundry.utils.getType(source.properties) !== "Object" ) return;
    source.properties = filteredKeys(source.properties);
  }

  /* -------------------------------------------- */

  /**
   * Migrate the proficient field to convert boolean values.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateProficient(source) {
    if ( typeof source.proficient === "boolean" ) source.proficient = Number(source.proficient);
  }

  /* -------------------------------------------- */

  /**
   * Migrate the range value to the reach field for melee weapons without the thrown property.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateReach(source) {
    if ( !source.properties || !source.range?.value || !source.type?.value
      || (source.range?.reach !== undefined) ) return;
    if ( (CONFIG.DND5E.weaponTypeMap[source.type.value] !== "melee") || source.properties.includes("thr") ) return;
    // Range of `0` or greater than `10` is always included, and so is range longer than `5` without reach property
    if ( (source.range.value === 0) || (source.range.value > 10)
      || (!source.properties.includes("rch") && (source.range.value > 5)) ) {
      source.range.reach = source.range.value;
    }
    source.range.value = null;
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareDerivedData() {
    super.prepareDerivedData();
    this.prepareDescriptionData();
    this.prepareIdentifiable();
    this.preparePhysicalData();
    this.prepareMountableData();
    this.type.label = CONFIG.DND5E.weaponTypes[this.type.value] ?? game.i18n.localize(CONFIG.Item.typeLabels.weapon);
    this.type.identifier = CONFIG.DND5E.weaponIds[this.type.baseItem];

    const labels = this.parent.labels ??= {};
    labels.armor = this.armor.value ? `${this.armor.value} ${game.i18n.localize("DND5E.AC")}` : "";
    labels.damage = this.damage.base.formula;
    labels.damageTypes = game.i18n.getListFormatter({ style: "narrow" }).format(
      Array.from(this.damage.base.types).map(t => CONFIG.DND5E.damageTypes[t]?.label).filter(t => t)
    );

    if ( this.attackType === "ranged" ) this.range.reach = null;
    else if ( this.range.reach === null ) {
      this.range.reach = convertLength(this.properties.has("rch") ? 10 : 5, "ft", this.range.units, { strict: false });
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareFinalData() {
    this.prepareFinalActivityData(this.parent.getRollData({ deterministic: true }));
    this.prepareFinalEquippableData();

    const labels = this.parent.labels ??= {};
    let { value, long, reach, units } = this.range;
    units ??= defaultUnits("length");
    if ( this.hasRange ) labels.range = !long || (long === value) ? formatLength(value, units)
      : `${formatNumber(value)}/${formatLength(long, units)}`;
    if ( reach ) labels.reach = game.i18n.format("DND5E.RANGE.Formatted.Reach", { reach: formatLength(reach, units) });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getFavoriteData() {
    return foundry.utils.mergeObject(await super.getFavoriteData(), {
      subtitle: CONFIG.DND5E.itemActionTypes[this.activities.contents[0]?.actionType],
      modifier: this.parent.labels.modifier,
      range: this.range
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getSheetData(context) {
    context.subtitles = [
      { label: game.i18n.localize(CONFIG.Item.typeLabels.weapon) },
      { label: this.type.label },
      ...this.physicalItemSheetFields
    ];

    context.info = [{
      label: "DND5E.ToHit",
      classes: "info-lg",
      value: dnd5e.utils.formatModifier(parseInt(this.parent.labels.modifier))
    }];
    if ( this.parent.labels.damages?.length ) {
      const config = { ...CONFIG.DND5E.damageTypes, ...CONFIG.DND5E.healingTypes };
      context.info.push({ value: this.parent.labels.damages.reduce((str, { formula, damageType, firstDamage }) => {
        if ( !firstDamage ) return str;
        const type = config[damageType];
        return `${str}
          <span class="formula">${formula}</span>
          ${type ? `<span class="damage-type" data-tooltip aria-label="${type.label}">
            <dnd5e-icon src="${type.icon}"></dnd5e-icon>
          </span>` : ""}
        `;
      }, ""), classes: "info-grid damage" });
    }

    context.parts = ["dnd5e.details-weapon", "dnd5e.field-uses"];
    context.damageTypes = Object.entries(CONFIG.DND5E.damageTypes).map(([value, { label }]) => {
      return {
        value, label,
        selected: context.source.damage.base.types.includes?.(value) ?? context.source.damage.base.types.has(value)
      };
    });
    const makeDenominationOptions = placeholder => [
      { value: "", label: placeholder ? `d${placeholder}` : "" },
      { rule: true },
      ...CONFIG.DND5E.dieSteps.map(value => ({ value, label: `d${value}` }))
    ];
    context.denominationOptions = {
      base: makeDenominationOptions(),
      versatile: makeDenominationOptions(this.damage.base.denomination ? this.damage.base.steppedDenomination() : "")
    };
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preCreate(data, options, user) {
    if ( (await super._preCreate(data, options, user)) === false ) return false;
    await this.preCreateEquipped(data, options, user);
    if ( this.activities.size ) return;

    const activityData = new CONFIG.DND5E.activityTypes.attack.documentClass({}, { parent: this.parent }).toObject();
    this.parent.updateSource({ [`system.activities.${activityData._id}`]: activityData });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preUpdate(changed, options, user) {
    if ( (await super._preUpdate(changed, options, user)) === false ) return false;
    await this.preUpdateIdentifiable(changed, options, user);
  }
}

const { BooleanField: BooleanField$6, EmbeddedDataField: EmbeddedDataField$1, SchemaField: SchemaField$7, StringField: StringField$b } = foundry.data.fields;

/**
 * A field for storing enchantment data.
 */
class EnchantmentField extends EmbeddedDataField$1 {
  /**
   * Construct an enchantment field.
   * @param {object} [options={}]  Options to configure this field's behavior.
   */
  constructor(options={}) {
    foundry.utils.logCompatibilityWarning(
      "The `EnchantmentField` field has been deprecated in favor of the Enchant activity.",
      { since: "DnD5e 5.2", until: "DnD5e 5.3", once: true }
    );
    super(EnchantmentData, foundry.utils.mergeObject({ required: false, nullable: true, initial: null }, options));
  }
}

/**
 * Data stored in "enchantment" flag on enchantment active effects.
 *
 * @typedef {object} EnchantmentProfile
 * @property {object} level
 * @property {number} level.min        Minimum level at which this profile can be used.
 * @property {number} level.max        Maximum level at which this profile can be used.
 * @property {object} riders
 * @property {string[]} riders.effect  IDs of other effects on this item that will be added with this enchantment.
 * @property {string[]} riders.item    UUIDs of items that will be added with this enchantment.
 */

/**
 * Data model for enchantment configuration.
 *
 * @property {string} classIdentifier             Class identifier that will be used to determine applicable level.
 * @property {object} items
 * @property {string} items.max                   Maximum number of items that can have this enchantment.
 * @property {string} items.period                Frequency at which the enchantment be swapped.
 * @property {object} restrictions
 * @property {boolean} restrictions.allowMagical  Allow enchantments to be applied to items that are already magical.
 * @property {string} restrictions.type           Item type to which this enchantment can be applied.
 */
class EnchantmentData extends foundry.abstract.DataModel {
  constructor(...args) {
    foundry.utils.logCompatibilityWarning(
      "The `EnchantmentData` data model has been deprecated in favor of the Enchant activity.",
      { since: "DnD5e 5.2", until: "DnD5e 5.3", once: true }
    );
    super(...args);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return {
      classIdentifier: new IdentifierField(),
      items: new SchemaField$7({
        max: new FormulaField({deterministic: true}),
        period: new StringField$b()
      }),
      restrictions: new SchemaField$7({
        allowMagical: new BooleanField$6(),
        type: new StringField$b()
      })
    };
  }
}

/**
 * Error to throw when an item cannot be enchanted.
 */
class EnchantmentError extends Error {
  constructor(...args) {
    foundry.utils.logCompatibilityWarning(
      "`dnd5e.dataModels.item.EnchantmentError` has been replaced with `dnd5e.documents.activity.EnchantmentError.",
      { since: "DnD5e 5.2", until: "DnD5e 5.3", once: true }
    );
    super(...args);
    this.name = "EnchantmentError";
  }
}

const {
  ArrayField: ArrayField$3, BooleanField: BooleanField$5, DocumentIdField: DocumentIdField$2, EmbeddedDataField, NumberField: NumberField$5, SchemaField: SchemaField$6, SetField: SetField$5, StringField: StringField$a
} = foundry.data.fields;

/**
 * A field for storing summons data.
 *
 * @param {object} [options={}]  Options to configure this field's behavior.
 */
class SummonsField extends EmbeddedDataField {
  constructor(options={}) {
    foundry.utils.logCompatibilityWarning(
      "The `SummonsField` field has been deprecated in favor of the Summons activity.",
      { since: "DnD5e 5.2", until: "DnD5e 5.3", once: true }
    );
    super(SummonsData, foundry.utils.mergeObject({ required: false, nullable: true, initial: null }, options));
  }
}

/**
 * Information for a single summoned creature.
 *
 * @typedef {object} SummonsProfile
 * @property {string} _id         Unique ID for this profile.
 * @property {string} count       Formula for the number of creatures to summon.
 * @property {string} cr          Formula for the CR of summoned creatures if in CR mode.
 * @property {object} level
 * @property {number} level.min   Minimum level at which this profile can be used.
 * @property {number} level.max   Maximum level at which this profile can be used.
 * @property {string} name        Display name for this profile if it differs from actor's name.
 * @property {Set<string>} types  Types of summoned creatures if in CR mode.
 * @property {string} uuid        UUID of the actor to summon if in default mode.
 */

/**
 * Data model for summons configuration.
 *
 * @property {object} bonuses
 * @property {string} bonuses.ac            Formula for armor class bonus on summoned actor.
 * @property {string} bonuses.hd            Formula for bonus hit dice to add to each summoned NPC.
 * @property {string} bonuses.hp            Formula for bonus hit points to add to each summoned actor.
 * @property {string} bonuses.attackDamage  Formula for bonus added to damage for attacks.
 * @property {string} bonuses.saveDamage    Formula for bonus added to damage for saving throws.
 * @property {string} bonuses.healing       Formula for bonus added to healing.
 * @property {string} classIdentifier       Class identifier that will be used to determine applicable level.
 * @property {Set<string>} creatureSizes    Set of creature sizes that will be set on summoned creature.
 * @property {Set<string>} creatureTypes    Set of creature types that will be set on summoned creature.
 * @property {object} match
 * @property {boolean} match.attacks        Match the to hit values on summoned actor's attack to the summoner.
 * @property {boolean} match.proficiency    Match proficiency on summoned actor to the summoner.
 * @property {boolean} match.saves          Match the save DC on summoned actor's abilities to the summoner.
 * @property {""|"cr"} mode                 Method of determining what type of creature is summoned.
 * @property {SummonsProfile[]} profiles    Information on creatures that can be summoned.
 * @property {boolean} prompt               Should the player be prompted to place the summons?
 */
class SummonsData extends foundry.abstract.DataModel {
  constructor(...args) {
    foundry.utils.logCompatibilityWarning(
      "The `SummonsData` data model has been deprecated in favor of the Summon activity.",
      { since: "DnD5e 5.2", until: "DnD5e 5.3", once: true }
    );
    super(...args);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return {
      bonuses: new SchemaField$6({
        ac: new FormulaField({
          label: "DND5E.Summoning.Bonuses.ArmorClass.Label", hint: "DND5E.Summoning.Bonuses.ArmorClass.hint"
        }),
        hd: new FormulaField({
          label: "DND5E.Summoning.Bonuses.HitDice.Label", hint: "DND5E.Summoning.Bonuses.HitDice.hint"
        }),
        hp: new FormulaField({
          label: "DND5E.Summoning.Bonuses.HitPoints.Label", hint: "DND5E.Summoning.Bonuses.HitPoints.hint"
        }),
        attackDamage: new FormulaField({
          label: "DND5E.Summoning.Bonuses.Attack.Label", hint: "DND5E.Summoning.Bonuses.Attack.Hint"
        }),
        saveDamage: new FormulaField({
          label: "DND5E.Summoning.Bonuses.Saves.Label", hint: "DND5E.Summoning.Bonuses.Saves.Hint"
        }),
        healing: new FormulaField({
          label: "DND5E.Summoning.Bonuses.Healing.Label", hint: "DND5E.Summoning.Bonuses.Healing.Hint"
        })
      }),
      classIdentifier: new IdentifierField(),
      creatureSizes: new SetField$5(new StringField$a(), {
        label: "DND5E.Summoning.CreatureSizes.Label", hint: "DND5E.Summoning.CreatureSizes.Hint"
      }),
      creatureTypes: new SetField$5(new StringField$a(), {
        label: "DND5E.Summoning.CreatureTypes.Label", hint: "DND5E.Summoning.CreatureTypes.Hint"
      }),
      match: new SchemaField$6({
        attacks: new BooleanField$5({
          label: "DND5E.Summoning.Match.Attacks.Label", hint: "DND5E.Summoning.Match.Attacks.Hint"
        }),
        proficiency: new BooleanField$5({
          label: "DND5E.Summoning.Match.Proficiency.Label", hint: "DND5E.Summoning.Match.Proficiency.Hint"
        }),
        saves: new BooleanField$5({
          label: "DND5E.Summoning.Match.Saves.Label", hint: "DND5E.Summoning.Match.Saves.Hint"
        })
      }),
      mode: new StringField$a({label: "DND5E.Summoning.Mode.Label", hint: "DND5E.Summoning.Mode.Hint"}),
      profiles: new ArrayField$3(new SchemaField$6({
        _id: new DocumentIdField$2({initial: () => foundry.utils.randomID()}),
        count: new FormulaField(),
        cr: new FormulaField({deterministic: true}),
        level: new SchemaField$6({
          min: new NumberField$5({integer: true, min: 0}),
          max: new NumberField$5({integer: true, min: 0})
        }),
        name: new StringField$a(),
        types: new SetField$5(new StringField$a()),
        uuid: new StringField$a()
      })),
      prompt: new BooleanField$5({
        initial: true, label: "DND5E.Summoning.Prompt.Label", hint: "DND5E.Summoning.Prompt.Hint"
      })
    };
  }
}

const config$2 = {
  background: BackgroundData,
  container: ContainerData,
  class: ClassData,
  consumable: ConsumableData,
  equipment: EquipmentData,
  facility: FacilityData,
  feat: FeatData,
  loot: LootData,
  race: RaceData,
  spell: SpellData,
  subclass: SubclassData,
  tool: ToolData,
  weapon: WeaponData
};

var _module$6 = /*#__PURE__*/Object.freeze({
  __proto__: null,
  ActivitiesTemplate: ActivitiesTemplate,
  BackgroundData: BackgroundData,
  ClassData: ClassData,
  ConsumableData: ConsumableData,
  ContainerData: ContainerData,
  EnchantmentData: EnchantmentData,
  EnchantmentError: EnchantmentError,
  EnchantmentField: EnchantmentField,
  EquipmentData: EquipmentData,
  EquippableItemTemplate: EquippableItemTemplate,
  FacilityData: FacilityData,
  FeatData: FeatData,
  IdentifiableTemplate: IdentifiableTemplate,
  ItemDescriptionTemplate: ItemDescriptionTemplate,
  ItemTypeField: ItemTypeField,
  ItemTypeTemplate: ItemTypeTemplate,
  LootData: LootData,
  MountableTemplate: MountableTemplate,
  PhysicalItemTemplate: PhysicalItemTemplate,
  RaceData: RaceData,
  SpellData: SpellData,
  SpellcastingField: SpellcastingField,
  SubclassData: SubclassData,
  SummonsData: SummonsData,
  SummonsField: SummonsField,
  ToolData: ToolData,
  WeaponData: WeaponData,
  config: config$2,
  startingEquipment: startingEquipment
});

const { HTMLField: HTMLField$3, SchemaField: SchemaField$5, SetField: SetField$4, StringField: StringField$9 } = foundry.data.fields;

/**
 * @import { ClassJournalSystemPageData } from "./_types.mjs";
 */

/**
 * Data definition for Class Summary journal entry pages.
 * @extends {foundry.abstract.TypeDataModel<ClassJournalSystemPageData>}
 * @mixes ClassJournalSystemPageData
 */
class ClassJournalPageData extends foundry.abstract.TypeDataModel {

  /** @inheritDoc */
  static LOCALIZATION_PREFIXES = ["JOURNALENTRYPAGE.DND5E.Class"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return {
      item: new StringField$9({ required: true }),
      description: new SchemaField$5({
        value: new HTMLField$3({ textSearch: true }),
        additionalHitPoints: new HTMLField$3({ textSearch: true }),
        additionalTraits: new HTMLField$3({ textSearch: true }),
        additionalEquipment: new HTMLField$3({ textSearch: true }),
        subclass: new HTMLField$3({ textSearch: true })
      }),
      style: new StringField$9(),
      subclassHeader: new StringField$9({ textSearch: true }),
      subclassItems: new SetField$4(new StringField$9())
    };
  }
}

const { StringField: StringField$8 } = foundry.data.fields;

/**
 * @import { MapJournalPageSystemData } from "./_types.mjs";
 */

/**
 * Data definition for Map Location journal entry pages.
 * @extends {foundry.abstract.TypeDataModel<MapJournalPageSystemData>}
 * @mixes MapJournalPageSystemData
 */
class MapLocationJournalPageData extends foundry.abstract.TypeDataModel {

  /** @inheritDoc */
  static defineSchema() {
    return {
      code: new StringField$8()
    };
  }

  /* -------------------------------------------- */

  /**
   * Adjust the number of this entry in the table of contents.
   * @param {number} number  Current position number.
   * @returns {{ number: string, adjustment: number }|void}
   */
  adjustTOCNumbering(number) {
    if ( !this.code ) return;
    return { number: this.code, adjustment: -1 };
  }

  /* -------------------------------------------- */

  /**
   * Create a control icon for rendering this page on a scene.
   * @param {object} options  Options passed through to ControlIcon construction.
   * @returns {PIXI.Container|void}
   */
  getControlIcon(options) {
    if ( !this.code ) return;
    const { icon: IconClass, ...style } = foundry.utils.mergeObject(
      CONFIG.DND5E.mapLocationMarker.default,
      CONFIG.DND5E.mapLocationMarker[this.parent.getFlag("dnd5e", "mapMarkerStyle")] ?? {},
      {inplace: false}
    );
    return new IconClass({code: this.code, ...options, ...style});
  }

  /* -------------------------------------------- */

  /** @override */
  async toEmbed(config, options={}) {
    return this.parent._embedTextPage(config, options);
  }
}

const TextEditor = foundry.applications.ux.TextEditor.implementation;
const { HTMLField: HTMLField$2, StringField: StringField$7 } = foundry.data.fields;

/**
 * @import { RuleJournalPageSystemData } from "./_types.mjs";
 */

/**
 * Data definition for Rule journal entry pages.
 * @extends {foundry.abstract.TypeDataModel<RuleJournalPageSystemData>}
 * @mixes RuleJournalPageSystemData
 */
class RuleJournalPageData extends foundry.abstract.TypeDataModel {

  /** @inheritDoc */
  static defineSchema() {
    return {
      tooltip: new HTMLField$2({textSearch: true, label: "DND5E.Rule.Tooltip"}),
      type: new StringField$7({blank: false, initial: "rule", label: "DND5E.Rule.Type.Label"})
    };
  }

  /* -------------------------------------------- */

  /**
   * Render a rich tooltip for this page.
   * @param {EnrichmentOptions} [enrichmentOptions={}]  Options for text enrichment.
   * @returns {{content: string, classes: string[]}}
   */
  async richTooltip(enrichmentOptions={}) {
    const context = {
      page: this.parent,
      type: CONFIG.DND5E.ruleTypes[this.type].label,
      content: await TextEditor.enrichHTML(this.tooltip || this.parent.text.content, {
        secrets: false, relativeTo: this.parent, ...enrichmentOptions
      })
    };
    return {
      content: await foundry.applications.handlebars.renderTemplate(
        "systems/dnd5e/templates/journal/page-rule-tooltip.hbs", context
      ),
      classes: ["dnd5e-tooltip", "rule-tooltip", "dnd5e2", "themed", "theme-light"]
    };
  }

  /* -------------------------------------------- */

  /** @override */
  async toEmbed(config, options={}) {
    return this.parent._embedTextPage(config, options);
  }
}

const { ArrayField: ArrayField$2, DocumentIdField: DocumentIdField$1, HTMLField: HTMLField$1, NumberField: NumberField$4, SchemaField: SchemaField$4, SetField: SetField$3, StringField: StringField$6 } = foundry.data.fields;

/**
 * @import { SpellsJournalPageSystemData } from "./_types.mjs";
 */

/**
 * Data model for spell list data.
 * @extends {foundry.abstract.TypeDataModel<SpellsJournalPageSystemData>}
 * @mixes SpellsJournalPageSystemData
 */
class SpellListJournalPageData extends foundry.abstract.TypeDataModel {
  /** @override */
  static defineSchema() {
    return {
      type: new StringField$6({
        initial: "class", label: "JOURNALENTRYPAGE.DND5E.SpellList.Type.Label"
      }),
      identifier: new IdentifierField({ label: "DND5E.Identifier" }),
      grouping: new StringField$6({
        initial: "level", choices: this.GROUPING_MODES,
        label: "JOURNALENTRYPAGE.DND5E.SpellList.Grouping.Label",
        hint: "JOURNALENTRYPAGE.DND5E.SpellList.Grouping.Hint"
      }),
      description: new SchemaField$4({
        value: new HTMLField$1({ textSearch: true, label: "DND5E.Description" })
      }),
      spells: new SetField$3(new StringField$6(), { label: "DND5E.ItemTypeSpellPl" }),
      unlinkedSpells: new ArrayField$2(new SchemaField$4({
        _id: new DocumentIdField$1({ initial: () => foundry.utils.randomID() }),
        identifier: new IdentifierField({ label: "DND5E.Identifier" }),
        name: new StringField$6({ required: true, label: "Name" }),
        system: new SchemaField$4({
          level: new NumberField$4({ min: 0, integer: true, label: "DND5E.Level" }),
          school: new StringField$6({ label: "DND5E.School" })
        }),
        source: new SourceField({license: false, revision: false, rules: false, uuid: new StringField$6()})
      }), { label: "JOURNALENTRYPAGE.DND5E.SpellList.UnlinkedSpells.Label" })
    };
  }

  /* -------------------------------------------- */

  /**
   * Different ways in which spells can be grouped on the sheet.
   * @enum {string}
   */
  static GROUPING_MODES = {
    none: "JOURNALENTRYPAGE.DND5E.SpellList.Grouping.None",
    alphabetical: "JOURNALENTRYPAGE.DND5E.SpellList.Grouping.Alphabetical",
    level: "JOURNALENTRYPAGE.DND5E.SpellList.Grouping.Level",
    school: "JOURNALENTRYPAGE.DND5E.SpellList.Grouping.School"
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareDerivedData() {
    this.unlinkedSpells.forEach(s => {
      s.identifier ||= formatIdentifier(s.name);
      SourceField.prepareData.call(s.source, s.source?.uuid);
    });
  }

  /* -------------------------------------------- */

  /** @override */
  async toEmbed(config, options={}) {
    for ( const value of config.values ) {
      if ( value === "table" ) config.table = true;
      else if ( value in this.constructor.GROUPING_MODES ) config.grouping = value;
    }
    if ( config.table ) config.grouping ??= "level";

    const sheet = new JournalSpellListPageSheet({
      displayAsTable: config.table,
      document: this.parent,
      embedRendering: true,
      grouping: config.grouping,
      mode: "view",
      window: { frame: false, positioned: false }
    });
    await sheet.render({ force: true });
    config.classes = config.classes ? `spells ${config.classes ?? ""}` : "spells";
    return sheet.element.children;
  }
}

const { HTMLField, SchemaField: SchemaField$3, StringField: StringField$5 } = foundry.data.fields;

/**
 * @import { SubclassJournalPageSystemData } from "./_types.mjs";
 */

/**
 * Data definition for Subclass Summary journal entry pages.
 * @extends {foundry.abstract.TypeDataModel<SubclassJournalPageSystemData>}
 * @mixes SubclassJournalPageSystemData
 */
class SubclassJournalPageData extends foundry.abstract.TypeDataModel {

  /** @inheritDoc */
  static LOCALIZATION_PREFIXES = ["JOURNALENTRYPAGE.DND5E.Subclass"];

  /* -------------------------------------------- */
  /** @inheritDoc */
  static defineSchema() {
    return {
      item: new StringField$5({ required: true }),
      description: new SchemaField$3({
        value: new HTMLField({ textSearch: true })
      }),
      style: new StringField$5()
    };
  }
}

const config$1 = {
  class: ClassJournalPageData,
  map: MapLocationJournalPageData,
  rule: RuleJournalPageData,
  spells: SpellListJournalPageData,
  subclass: SubclassJournalPageData
};

var _module$5 = /*#__PURE__*/Object.freeze({
  __proto__: null,
  ClassJournalPageData: ClassJournalPageData,
  MapLocationJournalPageData: MapLocationJournalPageData,
  RuleJournalPageData: RuleJournalPageData,
  SpellListJournalPageData: SpellListJournalPageData,
  SubclassJournalPageData: SubclassJournalPageData,
  config: config$1
});

const { BooleanField: BooleanField$4, NumberField: NumberField$3, SetField: SetField$2, StringField: StringField$4 } = foundry.data.fields;

/**
 * @import { DifficultTerrainRegionBehaviorSystemData } from "./_types.mjs";
 */

/**
 * The data model for a region behavior that represents an area of difficult terrain.
 * @extends {foundry.data.regionBehaviors.RegionBehaviorType<DifficultTerrainRegionBehaviorSystemData>}
 * @mixes DifficultTerrainRegionBehaviorSystemData
 */
class DifficultTerrainRegionBehaviorType extends foundry.data.regionBehaviors.RegionBehaviorType {

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.REGIONBEHAVIORS.DIFFICULTTERRAIN"];

  /* ---------------------------------------- */

  /** @override */
  static defineSchema() {
    const dispositions = { ...foundry.applications.sheets.TokenConfig.TOKEN_DISPOSITIONS };
    delete dispositions[CONST.TOKEN_DISPOSITIONS.SECRET];
    return {
      magical: new BooleanField$4(),
      types: new SetField$2(new StringField$4()),
      ignoredDispositions: new SetField$2(new NumberField$3({ choices: dispositions }))
    };
  }

  /* ---------------------------------------- */

  /**
   * Called when the difficult terrain behavior is viewed.
   * @param {RegionBehaviorViewedEvent} event
   * @this {DifficultTerrainRegionBehaviorType}
   */
  static async #onBehaviorViewed(event) {
    canvas.tokens.recalculatePlannedMovementPaths();
  }

  /* ---------------------------------------- */

  /**
   * Called when the difficult terrain behavior is unviewed.
   * @param {RegionBehaviorUnviewedEvent} event
   * @this {DifficultTerrainRegionBehaviorType}
   */
  static async #onBehaviorUnviewed(event) {
    canvas.tokens.recalculatePlannedMovementPaths();
  }

  /* ---------------------------------------- */

  /**
   * Called when the boundary of an event has changed.
   * @param {RegionRegionBoundryEvent} event
   * @this {DifficultTerrainRegionBehaviorType}
   */
  static async #onRegionBoundary(event) {
    if ( !this.behavior.viewed ) return;
    canvas.tokens.recalculatePlannedMovementPaths();
  }

  /* ---------------------------------------- */

  /** @override */
  static events = {
    [CONST.REGION_EVENTS.BEHAVIOR_VIEWED]: this.#onBehaviorViewed,
    [CONST.REGION_EVENTS.BEHAVIOR_UNVIEWED]: this.#onBehaviorUnviewed,
    [CONST.REGION_EVENTS.REGION_BOUNDARY]: this.#onRegionBoundary
  };

  /* ---------------------------------------- */

  /** @inheritDoc */
  _onUpdate(changed, options, userId) {
    super._onUpdate(changed, options, userId);
    if ( ("system" in changed) && !this.behavior.viewed ) return;
    canvas.tokens.recalculatePlannedMovementPaths();
  }

  /* ---------------------------------------- */

  /** @override */
  _getTerrainEffects(token, segment) {
    const ignoredTypes = token.actor?.system.attributes?.movement?.ignoredDifficultTerrain ?? new Set();
    if ( this.ignoredDispositions.has(token.disposition) || ignoredTypes.has("all")
      || (this.types.size && !this.types.difference(ignoredTypes).size)
      || (ignoredTypes.has("magical") && this.magical)
      || (ignoredTypes.has("nonmagical") && !this.magical) ) return [];
    return [{ name: "difficultTerrain" }];
  }
}

const {
  AngleField, ArrayField: ArrayField$1, BooleanField: BooleanField$3, DocumentIdField, NumberField: NumberField$2, SchemaField: SchemaField$2, SetField: SetField$1, StringField: StringField$3
} = foundry.data.fields;

const { Ray } = foundry.canvas.geometry;

/**
 * @import {
 *   Degrees, Point, Radians, RotateAreaDirectionMode, RotateAreaRegionBehaviorSystemData, RotateAreaSpeedMode, Size
 * } from "./_types.mjs";
 */

/**
 * The data model for a region behavior that rotates tokens in its area around a center point along with any
 * other specified placeables.
 * @extends {foundry.data.regionBehaviors.RegionBehaviorType<RotateAreaRegionBehaviorSystemData>}
 * @mixes RotateAreaRegionBehaviorSystemData
 */
class RotateAreaRegionBehaviorType extends foundry.data.regionBehaviors.RegionBehaviorType {

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.REGIONBEHAVIORS.ROTATEAREA"];

  /* ---------------------------------------- */

  /** @override */
  static defineSchema() {
    return {
      time: new SchemaField$2({
        value: new NumberField$2({ required: true, initial: 1000, min: 0, integer: true }),
        mode: new StringField$3({
          required: true, initial: "fixed", choices: RotateAreaRegionBehaviorType.SPEED_MODES
        })
      }),
      tiles: new SchemaField$2({
        ids: new SetField$1(new DocumentIdField())
      }),
      walls: new SchemaField$2({
        ids: new SetField$1(new DocumentIdField()),
        link: new BooleanField$3({ initial: true })
      }),
      lights: new SchemaField$2({
        ids: new SetField$1(new DocumentIdField())
      }),
      regions: new SchemaField$2({
        ids: new SetField$1(new DocumentIdField())
      }),
      sounds: new SchemaField$2({
        ids: new SetField$1(new DocumentIdField())
      }),
      directionMode: new StringField$3({
        required: true, initial: "short", choices: RotateAreaRegionBehaviorType.DIRECTION_MODES
      }),
      positions: new ArrayField$1(new SchemaField$2({
        angle: new NumberField$2({ required: true, nullable: false, initial: 0, min: -360, max: 360 })
      }), { initial: [{ angle: 0 }] }),
      status: new SchemaField$2({
        angle: new AngleField({ required: true, initial: 0, hidden: true }),
        position: new NumberField$2({ initial: 0, integer: true, min: 0, hidden: true }),
        rotating: new BooleanField$3({ hidden: true })
      })
    };
  }

  /* ---------------------------------------- */

  /**
   * Modes for determining rotation direction when moving to the next point.
   * @type {Record<RotateAreaDirectionMode, string>}
   */
  static DIRECTION_MODES = Object.seal({
    cw: "DND5E.REGIONBEHAVIORS.ROTATEAREA.DirectionMode.Clockwise",
    ccw: "DND5E.REGIONBEHAVIORS.ROTATEAREA.DirectionMode.CounterClockwise",
    short: "DND5E.REGIONBEHAVIORS.ROTATEAREA.DirectionMode.Shortest",
    long: "DND5E.REGIONBEHAVIORS.ROTATEAREA.DirectionMode.Longest"
  });

  /* ---------------------------------------- */

  /**
   * Modes for mapping rotation time to final speed.
   * @type {Record<RotateAreaSpeedMode, string>}
   */
  static SPEED_MODES = Object.seal({
    fixed: "DND5E.REGIONBEHAVIORS.ROTATEAREA.SpeedMode.Fixed",
    variable: "DND5E.REGIONBEHAVIORS.ROTATEAREA.SpeedMode.Variable"
  });

  /* ---------------------------------------- */
  /*  Rotation                                */
  /* ---------------------------------------- */

  /**
   * Rotate to the next position.
   * @param {boolean} [reverse=false]  Rotate to previous position instead of next one.
   * @returns {Promise<boolean>}       Resolves once rotation is complete.
   */
  async rotate(reverse=false) {
    let position = this.status.position + (reverse ? -1 : 1);
    if ( position >= this.positions.length ) position = 0;
    else if ( position < 0 ) position = this.positions.length - 1;
    return this.rotateTo({ position });
  }

  /* ---------------------------------------- */

  /**
   * Trigger the rotator to rotate to a angle or specific position. Either the angle or position must be provided.
   * @param {object} options
   * @param {number} [options.angle]     Final angle.
   * @param {string} [options.position]  Position index to rotate to.
   * @returns {Promise}                  Resolves once rotation is complete.
   */
  async rotateTo({ angle: targetAngle, position }) {
    if ( this.status.rotating ) return false;

    const newPosition = this.positions[position];
    if ( (position !== undefined) && !newPosition ) {
      console.warn(`Invalid position \`${position}\` when attempting to rotate \`${this.parent.uuid}\`.`);
      return false;
    }

    targetAngle ??= newPosition?.angle;
    if ( targetAngle === undefined ) {
      console.warn(`No target angle or position index provided when attempting to rotate \`${this.parent.uuid}\`.`);
      return false;
    }

    // Determine rotation angle, time, and pivot point
    const angle = this.#travelAngle(this.status.angle, targetAngle);
    const radians = Math.toRadians(angle);
    const duration = Math.max(this.time.mode === "fixed" ? this.time.value
      : this.time.value * (Math.abs(angle) / 90), 500);
    const pivot = RotateAreaRegionBehaviorType.#shapeCenter(this.region.shapes[0]);

    const calculateRotationUpdate = placeable => {
      if ( !placeable ) return null;
      const center = RotateAreaRegionBehaviorType.#placeableCenter(placeable);
      const rotation = RotateAreaRegionBehaviorType.#placeableRotation(placeable);
      const size = RotateAreaRegionBehaviorType.#placeableSize(placeable);
      return {
        _id: placeable.id,
        ...RotateAreaRegionBehaviorType.#calculatePosition(radians, pivot, center, size),
        rotation: rotation !== undefined ? rotation + angle : rotation
      };
    };

    // Prepare update data for each rotated document type
    const docs = this.#getAnimatables();
    const updates = {
      tiles: docs.tiles.map(t => calculateRotationUpdate(t)),
      tokens: docs.tokens.map(t => calculateRotationUpdate(t)),
      lights: docs.lights.map(l => calculateRotationUpdate(l)),
      regions: docs.regions.map(r => RotateAreaRegionBehaviorType.#rotateRegionShapes(r, angle, radians, pivot)),
      sounds: docs.sounds.map(s => calculateRotationUpdate(s)),
      walls: docs.walls.map(wall => {
        const first = RotateAreaRegionBehaviorType.#calculatePosition(radians, pivot, {
          x: wall.c[0], y: wall.c[1]
        });
        const second = RotateAreaRegionBehaviorType.#calculatePosition(radians, pivot, {
          x: wall.c[2], y: wall.c[3]
        });
        return { _id: wall.id, c: [first.x, first.y, second.x, second.y] };
      })
    };

    // Update status to indicate rotation is occurring and trigger visible animation
    await this.parent.update(
      { "system.status": { angle: targetAngle, position, rotating: true } },
      { dnd5e: { rotateArea: { angle, duration, pivot } } }
    );

    // Wait for the visible animation to complete before performing document updates
    await new Promise(resolve => setTimeout(resolve, duration));

    // Update all rotated documents
    await Promise.all([
      this.parent.update({ "system.status.rotating": false }),
      this.scene.updateEmbeddedDocuments("AmbientLight", updates.lights),
      this.scene.updateEmbeddedDocuments("AmbientSound", updates.sounds),
      this.scene.updateEmbeddedDocuments("Region", updates.regions),
      this.scene.updateEmbeddedDocuments("Tile", updates.tiles),
      this.scene.updateEmbeddedDocuments("Token", updates.tokens, {
        animate: false,
        movement: updates.tokens.reduce((obj, { _id }) => {
          obj[_id] = {
            constrainOptions: { ignoreWalls: true, ignoreCost: true },
            showRuler: false
          };
          return obj;
        }, {})
      }),
      this.scene.updateEmbeddedDocuments("Wall", updates.walls)
    ]);
  }

  /* ---------------------------------------- */
  /*  Helpers                                 */
  /* ---------------------------------------- */

  /**
   * Calculate the final position based on a rotation.
   * @param {Radians} angle  Rotation amount in radians.
   * @param {Point} pivot    Center point for the rotation.
   * @param {Point} center   Center point for the object being rotated.
   * @param {Size} [size]    Size of the placeable being rotated.
   * @returns {Point}
   */
  static #calculatePosition(angle, pivot, center, size={ width: 0, height: 0 }) {
    const vector = new Ray(pivot, center).shiftAngle(angle);
    return { x: vector.B.x - (size.width / 2), y: vector.B.y - (size.height / 2) };
  }

  /* ---------------------------------------- */

  /**
   * Retrieve and object with canvas documents for anything rotated.
   * @returns {Record<string, CanvasDocument>}
   */
  #getAnimatables() {
    const animatables = {
      tiles: Array.from(this.tiles.ids).map(t => this.scene.tiles.get(t)).filter(_ => _),
      tokens: Array.from(this.region.tokens),
      lights: Array.from(this.lights.ids).map(l => this.scene.lights.get(l)).filter(_ => _),
      regions: [this.region.id, ...this.regions.ids].map(r => this.scene.regions.get(r)).filter(_ => _),
      sounds: Array.from(this.sounds.ids).map(l => this.scene.sounds.get(l)).filter(_ => _),
      walls: Array.from(this.walls.ids).map(w => this.scene.walls.get(w)).filter(_ => _)
    };
    if ( this.walls.link ) {
      animatables.walls = animatables.walls.flatMap(w => w.object.getLinkedSegments().walls.map(w => w.document));
    }
    return animatables;
  }

  /* ---------------------------------------- */

  /**
   * Center of the placeable used for position adjustment.
   * @param {CanvasDocument|PlaceableObject} doc
   * @returns {Point}
   */
  static #placeableCenter(doc) {
    if ( doc instanceof TokenDocument ) return doc.getCenterPoint();
    if ( doc instanceof foundry.abstract.Document ) {
      if ( doc.object ) doc = doc.object;
      else {
        const size = this.#placeableSize(doc);
        return { x: doc.x + (size.width / 2), y: doc.y + (size.height / 2) };
      }
    }
    if ( "center" in doc ) return doc.center;
    return { x: doc.x, y: doc.y };
  }

  /* ---------------------------------------- */

  /**
   * Current rotation value of a placeable.
   * @param {CanvasDocument|PlaceableObject} doc
   * @returns {Degrees}
   */
  static #placeableRotation(doc) {
    if ( "rotation" in (doc.document ?? {}) ) return doc.document.rotation;
    if ( "rotation" in doc ) return doc.rotation;
    return doc.object?.rotation;
  }

  /* ---------------------------------------- */

  /**
   * Size of the placeable used for position adjustment.
   * @param {CanvasDocument|PlaceableObject} doc
   * @returns {Size}
   */
  static #placeableSize(doc) {
    if ( !(doc instanceof foundry.abstract.Document) ) doc = doc.document;
    if ( doc instanceof TileDocument ) return { width: doc.width, height: doc.height };
    if ( doc instanceof TokenDocument ) return doc.getSize();
    else return { width: 0, height: 0 };
  }

  /* ---------------------------------------- */

  /**
   * Create updates needed to rotate a region's shapes.
   * @param {Region} region    The region to rotate.
   * @param {Degrees} angle    Rotation amount in degrees.
   * @param {Radians} radians  Rotation amount in radians.
   * @param {Point} pivot      Center point for the rotation.
   * @returns {object}         Update data for the region.
   */
  static #rotateRegionShapes(region, angle, radians, pivot) {
    const shapes = region.toObject().shapes;
    for ( const shape of shapes ) {
      const { x, y, width, height } = shape;
      switch ( shape.type ) {
        case foundry.data.RectangleShapeData.TYPE:
          Object.assign(shape, this.#calculatePosition(
            radians, pivot, { x: x + (width / 2), y: y + (height / 2) }, { width, height }
          ));
          break;
        case foundry.data.CircleShapeData.TYPE:
        case foundry.data.EllipseShapeData.TYPE:
          Object.assign(shape, this.#calculatePosition(radians, pivot, { x, y }));
          break;
        case foundry.data.PolygonShapeData.TYPE:
          const iterator = Iterator.from(shape.points);
          shape.points = [];
          let frag;
          while ( !(frag = iterator.next()).done ) {
            const { x, y } = this.#calculatePosition(radians, pivot, { x: frag.value, y: iterator.next().value });
            shape.points.push(x, y);
          }
          break;
      }
      shape.rotation += angle;
    }
    return { _id: region.id, shapes };
  }

  /* ---------------------------------------- */

  /**
   * Center of a specific region shape.
   * @param {ShapeData} shape
   * @returns {Point}
   */
  static #shapeCenter(shape) {
    switch ( shape.type ) {
      case foundry.data.RectangleShapeData.TYPE:
        return { x: shape.x + (shape.width / 2), y: shape.y + (shape.width / 2) };
      case foundry.data.CircleShapeData.TYPE:
      case foundry.data.EllipseShapeData.TYPE:
        return { x: shape.x, y: shape.y };
      case foundry.data.PolygonShapeData.TYPE:
        return foundry.utils.polygonCentroid(shape.points);
      default:
        throw new Error(`Unrecognized shape type: ${shape.type}.`);
    }
  }

  /* ---------------------------------------- */

  /**
   * Determine the direction to rotate and the final rotation angle based on the direction mode.
   * @param {Degrees} start  Starting angle.
   * @param {Degrees} end    Ending angle.
   * @returns {Degrees}
   */
  #travelAngle(start, end) {
    if ( start < 0 ) start += 360;
    if ( end < 0 ) end += 360;
    let cw = (end - start + 360) % 360;
    let ccw = ((start - end + 360) % 360) * -1;
    switch ( this.directionMode ) {
      case "cw": return cw;
      case "ccw": return ccw;
      case "short": return Math.abs(cw) < Math.abs(ccw) ? cw : ccw;
      case "long": return Math.abs(cw) > Math.abs(ccw) ? cw : ccw;
    }
  }

  /* ---------------------------------------- */
  /*  Hooks                                   */
  /* ---------------------------------------- */

  /**
   * Animate rotation in response to changes to behavior.
   * @param {object} changes
   * @param {object} options
   */
  async updateRotatateArea(changes, options) {
    const animationDetails = foundry.utils.getProperty(options, "dnd5e.rotateArea");
    if ( animationDetails && (canvas.scene === this.scene) ) {
      const { angle, duration, pivot } = animationDetails;
      this.#animateRotation(angle, pivot, duration);
    }
  }

  /* ---------------------------------------- */

  /**
   * Handle animating the rotation of the region.
   * @param {Degrees} angle
   * @param {Point} pivot
   * @param {number} duration
   */
  #animateRotation(angle, pivot, duration) {
    const docs = this.#getAnimatables();
    const animatables = [...docs.lights, ...docs.sounds, ...docs.tiles, ...docs.tokens]
      .reduce((map, doc) => {
        map.set(doc, {
          center: RotateAreaRegionBehaviorType.#placeableCenter(doc),
          rotation: RotateAreaRegionBehaviorType.#placeableRotation(doc),
          size: RotateAreaRegionBehaviorType.#placeableSize(doc)
        });
        return map;
      }, new Map());
    docs.walls.forEach(wall => animatables.set(wall, { points: Array.from(wall.c) }));

    foundry.canvas.animation.CanvasAnimation.animate([], {
      duration,
      easing: foundry.canvas.animation.CanvasAnimation.easeInOutCosine,
      priority: PIXI.UPDATE_PRIORITY.OBJECTS + 1,
      ontick: (e, a) => RotateAreaRegionBehaviorType.#animateFrame(animatables, angle, pivot, e, a)
    });
  }

  /* ---------------------------------------- */

  /**
   * Handle manually updating animation on tokens per-frame to ensure vision animates.
   * @param {Map<CanvasDocument, { center: Point, points: number[], rotation: Degrees, size: Size }>} animatables
   * @param {Degrees} angle
   * @param {Point} pivot
   * @param {number} elapsedMS               The incremental time in MS which has elapsed (uncapped).
   * @param {CanvasAnimationData} animation  The animation which is being performed.
   */
  static #animateFrame(animatables, angle, pivot, elapsedMS, animation) {
    const pt = animation.time >= animation.duration ? 1 : animation.time / animation.duration;

    if ( pt <= 1 ) {
      const pa = animation.easing?.(pt) ?? pt;
      const pr = Math.toRadians(angle * pa);
      for ( const [doc, { center, points, rotation, size }] of animatables.entries() ) {
        if ( doc instanceof WallDocument ) {
          const first = RotateAreaRegionBehaviorType.#calculatePosition(pr, pivot, { x: points[0], y: points[1] });
          const second = RotateAreaRegionBehaviorType.#calculatePosition(pr, pivot, { x: points[2], y: points[3] });
          doc.c = [first.x, first.y, second.x, second.y];
          doc.object.renderFlags.set({ refreshLine: true });
          if ( game.settings.get("core", "visionAnimation") ) {
            doc.object.initializeEdge();
            canvas.perception.update({
              refreshEdges: true, initializeLighting: true, initializeVision: true, initializeSounds: true
            });
          }
        } else {
          const updates = RotateAreaRegionBehaviorType.#calculatePosition(pr, pivot, center, size);
          updates.rotation = rotation + (angle * pa);
          Object.assign(doc, updates);
          if ( doc instanceof AmbientLightDocument ) doc.object.initializeLightSource();
          else if ( doc instanceof AmbientSoundDocument ) doc.object.initializeSoundSource();
          else if ( doc instanceof TileDocument ) doc.object.renderFlags.set({ refreshTransform: true });
          else if ( doc instanceof TokenDocument ) doc.object._onAnimationUpdate(updates);
        }
      }
    }
  }
}

Hooks.on("updateRegionBehavior", (doc, changes, options) => doc.system?.updateRotatateArea?.(changes, options));

const config = {
  "dnd5e.difficultTerrain": DifficultTerrainRegionBehaviorType,
  "dnd5e.rotateArea": RotateAreaRegionBehaviorType
};

const icons = {
  "dnd5e.difficultTerrain": "fa-solid fa-hill-rockslide",
  "dnd5e.rotateArea": "fa-solid fa-arrows-spin"
};

var _module$4 = /*#__PURE__*/Object.freeze({
  __proto__: null,
  DifficultTerrainRegionBehaviorType: DifficultTerrainRegionBehaviorType,
  RotateAreaRegionBehaviorType: RotateAreaRegionBehaviorType,
  config: config,
  icons: icons
});

var _module$3 = /*#__PURE__*/Object.freeze({
  __proto__: null,
  BastionSetting: BastionSetting,
  CalendarConfigSetting: CalendarConfigSetting,
  CalendarPreferencesSetting: CalendarPreferencesSetting,
  PrimaryPartySetting: PrimaryPartySetting,
  TransformationSetting: TransformationSetting
});

const {
  ArrayField, BooleanField: BooleanField$2, FilePathField, NumberField: NumberField$1, SchemaField: SchemaField$1, StringField: StringField$2, TypedObjectField
} = foundry.data.fields;

/**
 * @import { SpellcastingDescription } from "../../documents/_types.mjs";
 * @import {
 *   MultiLevelSpellcasting, SingleLevelSpellcastingData, SlotSpellcastingData, SpellcastingModelData
 * } from "./_types.mjs";
 */

/**
 * A DataModel that represents a spellcasting method.
 * @extends {foundry.abstract.DataModel<SpellcastingModelData>}
 * @mixes SpellcastingModelData
 */
class SpellcastingModel extends foundry.abstract.DataModel {
  constructor(data={}, { key, ...options }={}) {
    super(data, options);
    this.#key = key;
  }

  /* -------------------------------------------- */

  /** @override */
  static defineSchema() {
    return {
      img: new FilePathField({
        required: true, categories: ["IMAGE"], initial: "icons/magic/unholy/silhouette-robe-evil-power.webp"
      }),
      label: new StringField$2({ required: true, initial: () => game.i18n.localize("DND5E.SPELLCASTING.Unlabeled") }),
      order: new NumberField$1({ required: true, integer: true, nullable: false, initial: 0 }),
      type: new StringField$2({ required: true, readonly: true, initial: () => this.TYPE })
    };
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Available spellcasting methods.
   * @type {Record<string, typeof SpellcastingModel>}
   */
  static get TYPES() {
    Object.defineProperty(this, "TYPES", {
      value: {
        base: this,
        [SingleLevelSpellcasting.TYPE]: SingleLevelSpellcasting,
        [MultiLevelSpellcasting.TYPE]: MultiLevelSpellcasting
      },
      writable: false,
      configurable: false
    });
    return this.TYPES;
  }

  /* -------------------------------------------- */

  /**
   * The spellcasting method type.
   * @type {string}
   */
  static get TYPE() {
    return "none";
  }

  /* -------------------------------------------- */

  /**
   * The internal key of the spellcasting method.
   * @type {string}
   */
  get key() {
    return this.#key;
  }

  #key;

  /* -------------------------------------------- */
  /*  Public API                                  */
  /* -------------------------------------------- */

  /**
   * Initialize data models from global config.
   */
  static fromConfig() {
    const { spellcasting } = CONFIG.DND5E;

    // Map progressions to spellcasting for faster lookup.
    CONFIG.DND5E.spellProgression = { none: { label: game.i18n.localize("DND5E.SpellNone") } };

    // Initialize models.
    Object.entries(spellcasting).forEach(([key, config]) => {
      const Model = this.TYPES[config.type ?? "base"];
      if ( !Model ) return delete spellcasting[key];
      try {
        spellcasting[key] = new Model(config, { key });
      } catch(e) {
        console.error(`Failed to instantiate model for spellcasting method '${key}'`, e);
        return delete spellcasting[key];
      }
      Object.entries(config.progression ?? {}).forEach(([k, v]) => {
        if ( k in CONFIG.DND5E.spellProgression ) console.warn(`Duplicate spell progression key '${k}' detected.`);
        CONFIG.DND5E.spellProgression[k] = { ...v, type: key };
      });
    });
  }

  /* -------------------------------------------- */

  /**
   * Get the human-readable label for this spellcasting method.
   * @param {object} [options]
   * @param {number} [options.level]                  The spell slot level.
   * @param {"long"|"short"} [options.format="long"]  The verbosity level.
   * @returns {string}
   */
  getLabel(options={}) {
    return this.label;
  }
}

/* -------------------------------------------- */

/**
 * An abstract class that defines spellcasting methods that provide spell slots.
 * @extends {SpellcastingModel<SlotSpellcastingData>}
 * @mixes SlotSpellcastingData
 */
class SlotSpellcasting extends SpellcastingModel {
  /** @inheritDoc */
  static defineSchema() {
    return {
      ...super.defineSchema(),
      cantrips: new BooleanField$2(),
      exclusive: new SchemaField$1({
        slots: new BooleanField$2(),
        spells: new BooleanField$2()
      }),
      prepares: new BooleanField$2(),
      progression: new TypedObjectField(new SchemaField$1({
        divisor: new NumberField$1({ required: true, nullable: false, integer: true, positive: true, initial: 1 }),
        label: new StringField$2({ required: true, initial: () => game.i18n.localize("DND5E.SPELLCASTING.Unlabeled") }),
        roundUp: new BooleanField$2()
      }))
    };
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Are spell slots recovered on a long rest?
   * @type {boolean}
   */
  get isLR() {
    return this.recovery.has("long");
  }

  /* -------------------------------------------- */

  /**
   * Are spell slots recovered on a short rest?
   * @type {boolean}
   */
  get isSR() {
    return this.recovery.has("short");
  }

  /* -------------------------------------------- */

  /**
   * Rest types that fully restore the spell slots of this spellcasting method.
   * @type {Set<string>}
   */
  get recovery() {
    if ( this.#recovery ) return this.#recovery;
    return this.#recovery = Object.entries(CONFIG.DND5E.restTypes).reduce((acc, [k, v]) => {
      if ( v.recoverSpellSlotTypes?.has(this.key) ) acc.add(k);
      return acc;
    }, new Set());
  }

  #recovery;

  /* -------------------------------------------- */

  /**
   * Whether this spellcasting method provides spell slots.
   * @type {boolean}
   */
  get slots() {
    return true;
  }

  /* -------------------------------------------- */
  /*  Public API                                  */
  /* -------------------------------------------- */

  /**
   * Calculate the slots made available by this spellcasting method at the given character level.
   * @param {number} level              The character level, adjusted for different progression speeds and
   *                                    multi-classing.
   * @returns {Record<number, number>}  A mapping of slot level to the number of slots.
   * @abstract
   */
  calculateSlots(level) {
    return {};
  }

  /* -------------------------------------------- */

  /**
   * Contribute to the actor's spellcasting progression for a spellcasting method that provides slots.
   * @param {object} progression                      Spellcasting progression data. *Will be mutated.*
   * @param {Actor5e|void} actor                      Actor for whom the data is being prepared, if any.
   * @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.
   */
  computeProgression(progression, actor, cls, spellcasting, count) {
    const prog = this.progression?.[spellcasting?.progression];
    if ( !prog ) return;
    const rounding = prog.roundUp ? Math.ceil : Math.floor;
    progression[this.key] += rounding(spellcasting.levels / (prog.divisor ?? 1));
    // Single-classed, non-full progression rounds up, rather than down.
    if ( (count === 1) && (prog.divisor > 1) && progression[this.key] ) {
      progression[this.key] = Math.ceil(spellcasting.levels / prog.divisor);
    }
  }

  /* -------------------------------------------- */

  /**
   * When being considered as a single-classed caster, retrieve the available levels which can be prepared via this
   * spellcasting method, in ascending order.
   * @param {Actor5e} actor  The caster.
   * @returns {number[]}
   */
  getAvailableLevels(actor) {
    const spells = foundry.utils.getProperty(actor, `system.spells.${this.getSpellSlotKey()}`);
    return spells?.max ? [spells.level] : [];
  }

  /* -------------------------------------------- */

  /**
   * Get the internal actor model key for spell slots provided by the spellcasting method.
   * @param {number} [level]  The spell slot level.
   * @returns {string}
   */
  getSpellSlotKey(level) {
    if ( level === 0 ) return "spell0";
    return this.key;
  }

  /* -------------------------------------------- */

  /**
   * Prepare slots provided by this spellcasting method.
   * @param {object} spells        The `data.spells` object within actor's data. *Will be mutated.*
   * @param {Actor5e|null} actor   Actor for whom the data is being prepared, if any.
   * @param {object} progression   Spellcasting progression data.
   * @abstract
   */
  prepareSlots(spells, actor, progression) {}
}

/* -------------------------------------------- */

/**
 * A spellcasting model that represents spellcasting methods that provide spell slots that are all the same level.
 * @extends {SlotSpellcasting<SingleLevelSpellcastingData>}
 * @mixes SingleLevelSpellcastingData
 */
class SingleLevelSpellcasting extends SlotSpellcasting {
  /** @inheritDoc */
  static defineSchema() {
    return {
      ...super.defineSchema(),
      table: new TypedObjectField(new SchemaField$1({
        slots: new NumberField$1({ required: true, nullable: false, integer: true, positive: true, initial: 1 }),
        level: new NumberField$1({ required: true, nullable: false, integer: true, positive: true, initial: 1 })
      }), { validateKey: SingleLevelSpellcasting.#validateTableKey })
    };
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  static get TYPE() {
    return "single";
  }

  /* -------------------------------------------- */

  /**
   * Whether this spellcasting method only provides a single level of spell slots.
   * @returns {boolean}
   */
  get isSingleLevel() {
    return true;
  }

  /* -------------------------------------------- */
  /*  Validation                                  */
  /* -------------------------------------------- */

  /**
   * Ensure spell slot table keys are numeric.
   * @param {any} k  The key.
   * @returns {boolean}
   */
  static #validateTableKey(k) {
    return Number.isNumeric(k) && (Number(k) > 0);
  }

  /* -------------------------------------------- */
  /*  Public API                                  */
  /* -------------------------------------------- */

  /** @override */
  calculateSlots(level) {
    const [, slots] = Object.entries(this.table).reverse().find(([l]) => Number(l) <= level) ?? [];
    const available = {};
    if ( slots ) available[slots.level] = slots.slots;
    return available;
  }

  /* -------------------------------------------- */

  /** @override */
  getLabel({ level, format }={}) {
    if ( !(level in CONFIG.DND5E.spellLevels) ) return this.label;
    const short = format === "short";
    return [this.label, short ? "" : "—", short ? "" : CONFIG.DND5E.spellLevels[level]].filterJoin(" ");
  }

  /* -------------------------------------------- */

  /** @override */
  prepareSlots(spells, actor, progression) {
    let level = Math.clamp(progression[this.key], 0, CONFIG.DND5E.maxLevel);
    const slot = spells[this.key] ??= { value: 0 };
    slot.type = this.key;
    slot.label = this.label;
    const override = Number.isNumeric(slot.override) ? Math.max(parseInt(slot.override), 0) : null;
    if ( (level === 0) && (actor.type === "npc") && (override !== null) ) level = actor.system.attributes.spell.level;
    const slots = this.calculateSlots(level);
    if ( foundry.utils.isEmpty(slots) && !override ) return;
    const [[slotLevel, slotAmount]=[]] = Object.entries(slots);
    slot.max = Number.isFinite(override) ? override : (slotAmount || 0);
    slot.level = slot.max ? slotLevel ? parseInt(slotLevel) : 1 : 0;
    slot.value = Math.clamp(slot.value, 0, slot.max) || 0;
  }
}

/* -------------------------------------------- */

/**
 * A spellcasting model that represents spellcasting methods that provide slots of different levels.
 * @extends {SlotSpellcasting<MultiLevelSpellcasting>}
 * @mixes MultiLevelSpellcasting
 */
class MultiLevelSpellcasting extends SlotSpellcasting {
  /** @inheritDoc */
  static defineSchema() {
    return {
      ...super.defineSchema(),
      table: new ArrayField(new ArrayField(new NumberField$1({
        required: true, nullable: false, integer: true, positive: true, initial: 1
      })))
    };
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  static get TYPE() {
    return "multi";
  }

  /* -------------------------------------------- */
  /*  Public API                                  */
  /* -------------------------------------------- */

  /** @override */
  calculateSlots(level) {
    const slots = this.table[Math.min(level, this.table.length) - 1] ?? [];
    return Object.fromEntries(slots.map((n, i) => [i + 1, n]));
  }

  /* -------------------------------------------- */

  /** @override */
  getAvailableLevels(actor) {
    return Array.fromRange(Object.keys(CONFIG.DND5E.spellLevels).length - 1, 1).reduce((arr, l) => {
      const spells = foundry.utils.getProperty(actor, `system.spells.${this.getSpellSlotKey(l)}`);
      if ( spells?.max ) arr.push(l);
      return arr;
    }, []);
  }

  /* -------------------------------------------- */

  /** @override */
  getLabel({ level }={}) {
    return game.i18n.localize(`DND5E.SPELLCASTING.SLOTS.${this.getSpellSlotKey(level)}`);
  }

  /* -------------------------------------------- */

  /** @override */
  getSpellSlotKey(level) {
    return `${this.key}${level}`;
  }

  /* -------------------------------------------- */

  /** @override */
  prepareSlots(spells, actor, progression) {
    const slots = this.calculateSlots(progression[this.key]);
    for ( const level of Array.fromRange(Object.keys(CONFIG.DND5E.spellLevels).length - 1, 1) ) {
      const slot = spells[this.getSpellSlotKey(level)] ??= { value: 0 };
      slot.label = this.getLabel({ level });
      slot.level = level;
      slot.max = Number.isNumeric(slot.override) ? Math.max(parseInt(slot.override), 0) : slots[level] ?? 0;
      slot.type = this.key;
    }
  }
}

var _module$2 = /*#__PURE__*/Object.freeze({
  __proto__: null,
  MultiLevelSpellcasting: MultiLevelSpellcasting,
  SingleLevelSpellcasting: SingleLevelSpellcasting,
  SlotSpellcasting: SlotSpellcasting,
  SpellcastingModel: SpellcastingModel
});

const { BooleanField: BooleanField$1 } = foundry.data.fields;

/**
 * @import { SystemTerrainData } from "./_types.mjs";
 */

/**
 * Extension of terrain data with support for 5e concepts.
 * @extends {foundry.data.TerrainData<SystemTerrainData>}
 * @mixes SystemTerrainData
 */
class TerrainData5e extends foundry.data.TerrainData {

  /** @inheritDoc */
  static defineSchema() {
    return {
      ...super.defineSchema(),
      difficultTerrain: new BooleanField$1()
    };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static getMovementCostFunction(token, options) {
    return token.actor?.system.isCreature
      ? super.getMovementCostFunction(token, options)
      : (_from, _to, distance) => distance;
  }

  /* -------------------------------------------- */

  /** @override */
  static resolveTerrainEffects(effects) {
    const noAutomation = game.settings.get("dnd5e", "movementAutomation") === "none";
    let data = super.resolveTerrainEffects(effects);
    if ( noAutomation || !effects.some(e => e.name === "difficultTerrain") ) return data;
    if ( !data ) return new this({ difficulty: 2, difficultTerrain: true });

    let difficulty = data.difficulty + 1;
    if ( !Number.isFinite(difficulty) ) difficulty = null;
    data.updateSource({ difficulty, difficultTerrain: true });
    return data;
  }

  /* -------------------------------------------- */

  /** @override */
  equals(other) {
    if ( !(other instanceof TerrainData5e) ) return false;
    return (this.difficulty === other.difficulty)
      && (this.difficultTerrain === other.difficultTerrain);
  }
}

const { BooleanField, ForeignDocumentField, NumberField, SchemaField, SetField, StringField: StringField$1 } = foundry.data.fields;

/**
 * @import { UserSystemFlagsData } from "./_types.mjs";
 */

/**
 * A custom model to validate system flags on User Documents.
 * @extends {foundry.abstract.DataModel<UserSystemFlagsData>}
 * @mixes UserSystemFlagsData
 */
class UserSystemFlags extends foundry.abstract.DataModel {
  /** @override */
  static defineSchema() {
    return {
      awardDestinations: new SetField(
        new ForeignDocumentField(foundry.documents.BaseActor, { idOnly: true }), { required: false }
      ),
      creation: new SchemaField({
        scrollExplanation: new StringField$1({initial: "reference"})
      }),
      sheetPrefs: new MappingField(new SchemaField({
        width: new NumberField({ integer: true, positive: true }),
        height: new NumberField({ integer: true, positive: true }),
        tabs: new MappingField(new SchemaField({
          collapseSidebar: new BooleanField({ required: false }),
          group: new StringField$1({ required: false }),
          sort: new StringField$1({ required: false, initial: "m", choices: [...foundry.documents.BaseFolder.SORTING_MODES, "p"] })
        }), { required: false })
      }))
    };
  }
}

var _module$1 = /*#__PURE__*/Object.freeze({
  __proto__: null,
  UserSystemFlags: UserSystemFlags
});

var dataModels = /*#__PURE__*/Object.freeze({
  __proto__: null,
  ActorDataModel: ActorDataModel,
  ItemDataModel: ItemDataModel,
  SparseDataModel: SparseDataModel,
  SystemDataModel: SystemDataModel,
  TerrainData5e: TerrainData5e,
  abstract: _module$g,
  activeEffect: _module$f,
  activity: _module$e,
  actor: _module$d,
  advancement: _module$c,
  calendar: _module$b,
  chatMessage: _module$9,
  collection: _module$8,
  fields: _module$y,
  item: _module$6,
  journal: _module$5,
  regionBehavior: _module$4,
  settings: _module$3,
  shared: _module$7,
  spellcasting: _module$2,
  user: _module$1
});

const { Die } = foundry.dice.terms;

/**
 * Primary die used when performing a D20 roll.
 */
class D20Die extends Die {
  constructor({ number=1, faces=20, ...args }={}) {
    super({ number, faces, ...args });
  }

  /* -------------------------------------------- */

  /**
   * Critical success target if no critical failure is set in options.
   * @type {number}
   */
  static CRITICAL_SUCCESS_TOTAL = 20;

  /* -------------------------------------------- */

  /**
   * Critical failure target if no critical failure is set in options.
   * @type {number}
   */
  static CRITICAL_FAILURE_TOTAL = 1;

  /* -------------------------------------------- */

  /**
   * Is the result of this roll a critical success? Returns `undefined` if roll isn't evaluated.
   * @type {boolean|void}
   */
  get isCriticalSuccess() {
    if ( !this.isValid || !this._evaluated ) return;
    if ( !Number.isNumeric(this.options.criticalSuccess) ) return false;
    return this.total >= this.options.criticalSuccess;
  }

  /* -------------------------------------------- */

  /**
   * Is the result of this roll a critical failure? Returns `undefined` if roll isn't evaluated.
   * @type {boolean|void}
   */
  get isCriticalFailure() {
    if ( !this.isValid || !this._evaluated ) return;
    if ( !Number.isNumeric(this.options.criticalFailure) ) return false;
    return this.total <= this.options.criticalFailure;
  }

  /* -------------------------------------------- */

  /**
   * Is this a valid challenge die?
   * @type {boolean}
   */
  get isValid() {
    return this.faces === 20;
  }

  /* -------------------------------------------- */
  /*  Die Modification                            */
  /* -------------------------------------------- */

  /**
   * Apply advantage mode to this die.
   * @param {number} advantageMode  Advantage mode to apply.
   */
  applyAdvantage(advantageMode) {
    this.options.advantageMode = advantageMode;
    this.modifiers.findSplice(m => ["kh", "kl"].includes(m));
    if ( advantageMode === CONFIG.Dice.D20Roll.ADV_MODE.NORMAL ) this.number = 1;
    else {
      const isAdvantage = advantageMode === CONFIG.Dice.D20Roll.ADV_MODE.ADVANTAGE;
      this.number = (isAdvantage && this.options.elvenAccuracy) ? 3 : 2;
      this.modifiers.push(isAdvantage ? "kh" : "kl");
    }
  }

  /* -------------------------------------------- */

  /**
   * Set or unset the specified flag on this die.
   * @param {string} flag      Flag to apply.
   * @param {boolean} enabled  Is the flag enabled?
   */
  applyFlag(flag, enabled) {
    this.options[flag] = enabled;

    // Halfling Lucky, re-roll a natural 1 once
    if ( flag === "halflingLucky" ) {
      const index = this.modifiers.findIndex(m => m === "r1=1");
      if ( enabled && (index === -1) ) this.modifiers.push("r1=1");
      else if ( !enabled && (index !== -1) ) this.modifiers.splice(index, 1);
    }
  }

  /* -------------------------------------------- */

  /**
   * Apply a minimum or maximum value to this die.
   * @param {object} values
   * @param {number} [values.minimum]
   * @param {number} [values.maximum]
   */
  applyRange(values) {
    for ( let [key, value] of Object.entries(values) ) {
      if ( !Number.isFinite(value) ) value = undefined;
      this.options[key] = value;
      const mod = key.substring(0, 3);
      this.modifiers.findSplice(m => m.startsWith(mod));
      if ( value ) this.modifiers.push(`${mod}${value}`);
    }
  }
}

/**
 * @import {
 *   BasicRollDialogConfiguration, BasicRollMessageConfiguration, D20RollOptions, D20RollProcessConfiguration
 * } from "./_types.mjs";
 */

/**
 * A type of Roll specific to a d20-based check, save, or attack roll in the 5e system.
 * @extends {BasicRoll}
 */
class D20Roll extends BasicRoll {
  /**
   * @param {string} formula          The string formula to parse.
   * @param {object} data             The data object against which to parse attributes within the formula.
   * @param {D20RollOptions} options  Additional options that describe the d20 roll.
   */
  constructor(formula, data, options) {
    super(formula, data, options);
    this.#createD20Die();
    if ( !this.options.configured ) this.configureModifiers();
  }

  /* -------------------------------------------- */

  /**
   * Advantage mode of a 5e d20 roll
   * @enum {number}
   */
  static ADV_MODE = {
    NORMAL: 0,
    ADVANTAGE: 1,
    DISADVANTAGE: -1
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  static DefaultConfigurationDialog = D20RollConfigurationDialog;

  /* -------------------------------------------- */
  /*  Static Construction                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static fromConfig(config, process) {
    const formula = [new CONFIG.Dice.D20Die().formula].concat(config.parts ?? []).join(" + ");
    config.options.criticalSuccess ??= CONFIG.Dice.D20Die.CRITICAL_SUCCESS_TOTAL;
    config.options.criticalFailure ??= CONFIG.Dice.D20Die.CRITICAL_FAILURE_TOTAL;
    config.options.elvenAccuracy ??= process.elvenAccuracy;
    config.options.halflingLucky ??= process.halflingLucky;
    config.options.reliableTalent ??= process.reliableTalent;
    config.options.target ??= process.target;
    return new this(formula, config.data, config.options);
  }

  /* -------------------------------------------- */

  /**
   * 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;
  }

  /* -------------------------------------------- */

  /**
   * Determines whether the roll should be fast forwarded and what the default advantage mode should be.
   * @param {D20RollProcessConfiguration} config     Roll configuration data.
   * @param {BasicRollDialogConfiguration} dialog    Data for the roll configuration dialog.
   * @param {BasicRollMessageConfiguration} message  Configuration data that guides roll message creation.
   */
  static applyKeybindings(config, dialog, message) {
    const keys = {
      normal: areKeysPressed(config.event, "skipDialogNormal"),
      advantage: areKeysPressed(config.event, "skipDialogAdvantage"),
      disadvantage: areKeysPressed(config.event, "skipDialogDisadvantage")
    };

    // Should the roll configuration dialog be displayed?
    dialog.configure ??= !Object.values(keys).some(k => k);

    // Determine advantage mode
    for ( const roll of config.rolls ?? [] ) {
      if ( keys.normal && keys.advantage ) roll.options.advantageMode = this.ADV_MODE.ADVANTAGE;
      else if ( keys.normal && keys.disadvantage ) roll.options.advantageMode = this.ADV_MODE.DISADVANTAGE;
      else {
        const advantage = roll.options.advantage || config.advantage || keys.advantage;
        const disadvantage = roll.options.disadvantage || config.disadvantage || keys.disadvantage;
        if ( advantage && !disadvantage ) roll.options.advantageMode = this.ADV_MODE.ADVANTAGE;
        else if ( !advantage && disadvantage ) roll.options.advantageMode = this.ADV_MODE.DISADVANTAGE;
        else roll.options.advantageMode = this.ADV_MODE.NORMAL;
      }
    }
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The primary die used in this d20 roll.
   * @type {D20Die|void}
   */
  get d20() {
    if ( !(this.terms[0] instanceof foundry.dice.terms.Die) ) return;
    if ( !(this.terms[0] instanceof CONFIG.Dice.D20Die) ) this.#createD20Die();
    return this.terms[0];
  }

  /* -------------------------------------------- */

  /**
   * Set the d20 for this roll.
   */
  set d20(die) {
    if ( !(die instanceof CONFIG.Dice.D20Die) ) throw new Error(
      `D20 die must be an instance of ${CONFIG.Dice.D20Die.name}, instead a ${die.constructor.name} was provided.`
    );
    this.terms[0] = die;
  }

  /* -------------------------------------------- */

  /**
   * A convenience reference for whether this D20Roll has advantage.
   * @type {boolean}
   */
  get hasAdvantage() {
    return this.options.advantageMode === this.constructor.ADV_MODE.ADVANTAGE;
  }

  /* -------------------------------------------- */

  /**
   * A convenience reference for whether this D20Roll has disadvantage.
   * @type {boolean}
   */
  get hasDisadvantage() {
    return this.options.advantageMode === this.constructor.ADV_MODE.DISADVANTAGE;
  }

  /* -------------------------------------------- */

  /**
   * Is this roll a critical success? Returns undefined if roll isn't evaluated.
   * @type {boolean|void}
   */
  get isCritical() {
    return this.d20.isCriticalSuccess;
  }

  /* -------------------------------------------- */

  /**
   * Is this roll a critical failure? Returns undefined if roll isn't evaluated.
   * @type {boolean|void}
   */
  get isFumble() {
    return this.d20.isCriticalFailure;
  }

  /* -------------------------------------------- */

  /**
   * Does this roll start with a d20?
   * @type {boolean}
   */
  get validD20Roll() {
    return (this.d20 instanceof CONFIG.Dice.D20Die) && this.d20.isValid;
  }

  /* -------------------------------------------- */
  /*  Chat Messages                               */
  /* -------------------------------------------- */

  /** @override */
  static _prepareMessageData(rolls, messageData) {
    let advantage = true;
    let disadvantage = true;

    const rtLabel = game.i18n.localize("DND5E.FlagsReliableTalent");
    for ( const roll of rolls ) {
      if ( !roll.validD20Roll ) continue;
      if ( !roll.hasAdvantage ) advantage = false;
      if ( !roll.hasDisadvantage ) disadvantage = false;
      if ( roll.options.reliableTalent && roll.d20.results.every(r => !r.active || (r.result < 10)) ) {
        roll.d20.options.flavor = roll.d20.options.flavor ? `${roll.d20.options.flavor} (${rtLabel})` : rtLabel;
      }
    }

    messageData.flavor ??= "";
    if ( advantage ) messageData.flavor += ` (${game.i18n.localize("DND5E.Advantage")})`;
    else if ( disadvantage ) messageData.flavor += ` (${game.i18n.localize("DND5E.Disadvantage")})`;
  }

  /* -------------------------------------------- */
  /*  Roll Configuration                          */
  /* -------------------------------------------- */

  /**
   * Apply optional modifiers which customize the behavior of the d20term
   * @private
   */
  configureModifiers() {
    if ( !this.validD20Roll ) return;

    if ( this.options.advantageMode === undefined ) {
      const { advantage, disadvantage } = this.options;
      if ( advantage && !disadvantage ) this.options.advantageMode = this.constructor.ADV_MODE.ADVANTAGE;
      else if ( !advantage && disadvantage ) this.options.advantageMode = this.constructor.ADV_MODE.DISADVANTAGE;
      else this.options.advantageMode = this.constructor.ADV_MODE.NORMAL;
    }

    // Determine minimum, taking reliable talent into account
    let minimum = this.options.minimum;
    if ( this.options.reliableTalent ) minimum = Math.max(minimum ?? -Infinity, 10);

    // Directly modify the d20
    this.d20.applyFlag("elvenAccuracy", this.options.elvenAccuracy === true);
    this.d20.applyFlag("halflingLucky", this.options.halflingLucky === true);
    this.d20.applyAdvantage(this.options.advantageMode);
    this.d20.applyRange({ minimum, maximum: this.options.maximum });

    // Assign critical and fumble thresholds
    if ( this.options.criticalSuccess ) this.d20.options.criticalSuccess = this.options.criticalSuccess;
    if ( this.options.criticalFailure ) this.d20.options.criticalFailure = this.options.criticalFailure;
    if ( this.options.target ) this.d20.options.target = this.options.target;

    // Re-compile the underlying formula
    this.resetFormula();

    // Mark configuration as complete
    this.options.configured = true;
  }

  /* -------------------------------------------- */

  /**
   * Ensure the d20 die for this roll is actually a D20Die instance.
   */
  #createD20Die() {
    if ( this.terms[0] instanceof CONFIG.Dice.D20Die ) return;
    if ( !(this.terms[0] instanceof foundry.dice.terms.Die) ) return;
    const { number, faces, ...data } = this.terms[0];
    this.terms[0] = new CONFIG.Dice.D20Die({ ...data, number, faces });
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /** @override */
  static mergeOptions(original={}, other={}) {
    const merged = super.mergeOptions(original, other);
    merged.advantage = original.advantage || other.advantage;
    merged.disadvantage = original.disadvantage || other.disadvantage;
    merged.maximum = Math.min(original.maximum ?? Infinity, other.maximum ?? Infinity);
    merged.minimum = Math.max(original.minimum ?? -Infinity, other.minimum ?? -Infinity);
    return merged;
  }
}

var dice = /*#__PURE__*/Object.freeze({
  __proto__: null,
  BasicRoll: BasicRoll,
  D20Die: D20Die,
  D20Roll: D20Roll,
  DamageRoll: DamageRoll,
  aggregateDamageRolls: aggregateDamageRolls,
  simplifyRollFormula: simplifyRollFormula
});

/**
 * Extend the base Adventure class to implement system-specific logic.
 * @extends {Adventure}
 */
class Adventure5e extends foundry.documents.Adventure {
  /** @inheritDoc */
  static migrateData(source) {
    if ( source.actors?.length ) {
      for ( const a of source.actors ) {
        // Migrate encounter groups to their own Actor type.
        if ( (a.type === "group") && (a.system?.type?.value === "encounter") ) a.type = "encounter";
      }
    }
    return super.migrateData(source);
  }
}

/**
 * Extended version of Combat to trigger events on combat start & turn changes.
 */
class Combat5e extends Combat {

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Expansion state for groups within this combat.
   * @type {Set<string>}
   */
  expandedGroups = new Set();

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async startCombat() {
    await super.startCombat();
    this._recoverUses({ encounter: true });
    return this;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async endCombat() {
    await super.endCombat();
    this._recoverUses({ turn: true, turnEnd: true, turnStart: true });
    return this;
  }

  /* -------------------------------------------- */

  /** @override */
  async rollAll(options) {
    const ids = new Set();
    for ( let combatant of this.combatants ) {
      if ( combatant.group ) combatant = combatant.group.activeCombatant;
      if ( combatant.isOwner && (combatant.initiative === null) ) ids.add(combatant.id);
    }
    return this.rollInitiative(Array.from(ids), options);
  }

  /* -------------------------------------------- */

  /** @override */
  async rollNPC(options={}) {
    const ids = new Set();
    for ( let combatant of this.combatants ) {
      if ( combatant.group ) combatant = combatant.group.activeCombatant;
      if ( combatant.isOwner && combatant.isNPC && (combatant.initiative === null) ) ids.add(combatant.id);
    }
    return this.rollInitiative(Array.from(ids), options);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async rollInitiative(ids, options={}) {
    await super.rollInitiative(ids, options);
    for ( const id of ids ) await this._recoverUses({ initiative: this.combatants.get(id) });
    return this;
  }

  /* -------------------------------------------- */

  /** @override */
  _sortCombatants(a, b) {
    // Initiative takes top priority
    if ( a.initiative !== b.initiative ) return super._sortCombatants(a, b);

    // Separate out combatants with different base actors
    if ( !a.token?.baseActor || !b.token?.baseActor || (a.token?.baseActor !== b.token?.baseActor) ) {
      const name = c => `${c.token?.baseActor?.name ?? ""}.${c.token?.baseActor?.id ?? ""}`;
      return name(a).localeCompare(name(b), game.i18n.lang);
    }

    // Otherwise sort based on combatant name
    return a.name.localeCompare(b.name, game.i18n.lang);
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onUpdate(changed, options, userId) {
    super._onUpdate(changed, options, userId);
    if ( this.current.combatantId !== this.previous.combatantId ) {
      this.combatants.get(this.previous.combatantId)?.refreshDynamicRing();
      this.combatants.get(this.current.combatantId)?.refreshDynamicRing();
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDelete(options, userId) {
    // TODO: Workaround for https://github.com/foundryvtt/foundryvtt/issues/13495
    this.turn = null;
    super._onDelete(options, userId);
    this.combatants.get(this.current.combatantId)?.refreshDynamicRing();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onEndTurn(combatant) {
    await super._onEndTurn(combatant);
    this._recoverUses({ turnEnd: combatant });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onStartTurn(combatant) {
    await super._onStartTurn(combatant);
    this._recoverUses({ turn: true, turnStart: combatant });
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Determine which group each combatant should be added to, or if a new group should be created.
   * @returns {Map<string, { combatants: Combatant5e[], expanded: boolean }>}
   */
  createGroups() {
    const groups = new Map();
    for ( const combatant of this.combatants ) {
      const key = combatant.getGroupingKey();
      if ( key === null ) continue;
      if ( !groups.has(key) ) groups.set(key, { combatants: [], expanded: this.expandedGroups.has(key) });
      groups.get(key).combatants.push(combatant);
    }

    for ( const [key, { combatants }] of groups.entries() ) {
      if ( combatants.length <= 1 ) groups.delete(key);
    }

    return groups;
  }

  /* -------------------------------------------- */

  /**
   * Reset combat specific uses.
   * @param {object} types  Which types of recovery to handle, and whether they should be performed on all combatants
   *                        or only the combatant specified.
   * @protected
   */
  async _recoverUses(types) {
    for ( const combatant of this.combatants ) {
      if ( combatant.isDefeated ) continue;
      const periods = Object.entries(types).filter(([, v]) => (v === true) || (v === combatant)).map(([k]) => k);
      if ( periods.length ) await combatant.recoverCombatUses(periods);
    }
  }
}

/**
 * @import { ActorDeltasData } from "../data/chat-message/fields/_types.mjs";
 * @import { CombatRecoveryResults } from "./_types.mjs";
 */

/**
 * Custom combatant with custom initiative roll handling.
 */
class Combatant5e extends Combatant {
  /**
   * Create a chat message representing actor changes and displaying possible actions for this turn.
   * @param {object} [data={}]
   * @param {ActorDeltasData} [data.deltas]
   * @param {string[]} [data.periods]
   * @param {BasicRoll[]} [data.rolls]
   * @returns {ChatMessage5e|void}
   */
  async createTurnMessage({ deltas, periods, rolls }={}) {
    if ( !this.actor ) return;

    const messageConfig = {
      create: false,
      data: {
        rolls,
        speaker: ChatMessage.getSpeaker({ actor: this.actor, token: this.token }),
        system: {
          deltas, periods,
          activations: ActivationsField.getActivations(this.actor, periods),
          origin: {
            combat: this.combat.id,
            combatant: this.id
          }
        },
        type: "turn",
        whisper: game.users.filter(u => this.actor.testUserPermission(u, "OWNER"))
      }
    };

    if ( !foundry.utils.isEmpty(messageConfig.data.system.deltas)
      || !foundry.utils.isEmpty(messageConfig.data.system.activations) ) messageConfig.create = true;

    /**
     * A hook event that fires before a combat state change chat message is created.
     * @function dnd5e.preCreateCombatMessage
     * @memberof hookEvents
     * @param {Combatant5e} combatant         Combatant for which the message will be created.
     * @param {object} messageConfig
     * @param {boolean} messageConfig.create  Should the chat message be posted?
     * @param {object} messageConfig.data     Data for the created chat message.
     */
    Hooks.callAll("dnd5e.preCreateCombatMessage", this, messageConfig);

    if ( messageConfig.create ) return ChatMessage.implementation.create(messageConfig.data);
  }

  /* -------------------------------------------- */

  /**
   * Key for the group to which this combatant should belong, or `null` if it can't be grouped.
   * @returns {string|null}
   */
  getGroupingKey() {
    if ( this.group ) return this.group.id;
    if ( this.token?.actorLink || !this.token?.baseActor || (this.initiative === null) ) return null;
    return `${Math.floor(this.initiative).paddedString(4)}:${this.token.disposition}:${this.token.baseActor.id}`;
  }

  /* -------------------------------------------- */

  /** @override */
  getInitiativeRoll(formula) {
    if ( !this.actor ) return new CONFIG.Dice.D20Roll(formula ?? "1d20", {});
    return this.actor.getInitiativeRoll();
  }

  /* -------------------------------------------- */

  /**
   * Reset combat-related uses.
   * @param {string[]} periods  Which recovery periods should be considered.
   */
  async recoverCombatUses(periods) {
    /**
     * A hook event that fires before combat-related recovery changes.
     * @function dnd5e.preCombatRecovery
     * @memberof hookEvents
     * @param {Combatant5e} combatant  Combatant that is being recovered.
     * @param {string[]} periods       Periods to be recovered.
     * @returns {boolean}              Explicitly return `false` to prevent recovery from being performed.
     */
    if ( Hooks.call("dnd5e.preCombatRecovery", this, periods) === false ) return;

    const results = { actor: {}, delete: [], item: [], rolls: [] };
    await this.actor?.system.recoverCombatUses?.(periods, results);

    for ( const item of this.actor?.items ?? [] ) {
      if ( (item.dependentOrigin?.active === false)
        || (foundry.utils.getType(item.system.recoverUses) !== "function") ) continue;
      const rollData = item.getRollData();
      const { updates, rolls, destroy } = await item.system.recoverUses(Array.from(periods), rollData);
      if ( destroy ) {
        results.delete.push(item.id);
      } else if ( !foundry.utils.isEmpty(updates) ) {
        const updateTarget = results.item.find(i => i._id === item.id);
        if ( updateTarget ) foundry.utils.mergeObject(updateTarget, updates);
        else results.item.push({ _id: item.id, ...updates });
      }
      results.rolls.push(...rolls);
    }

    /**
     * A hook event that fires after combat-related recovery changes have been prepared, but before they have been
     * applied to the actor.
     * @function dnd5e.combatRecovery
     * @memberof hookEvents
     * @param {Combatant5e} combatant          Combatant that is being recovered.
     * @param {string[]} periods               Periods that were recovered.
     * @param {CombatRecoveryResults} results  Update that will be applied to the actor and its items.
     * @returns {boolean}  Explicitly return `false` to prevent updates from being performed.
     */
    if ( Hooks.call("dnd5e.combatRecovery", this, periods, results) === false ) return;

    const deltas = ActorDeltasField.getDeltas(this.actor, results);

    if ( !foundry.utils.isEmpty(results.actor) ) await this.actor.update(results.actor);
    if ( results.delete.length ) await this.actor.deleteEmbeddedDocuments("Item", results.delete);
    if ( results.item.length ) await this.actor.updateEmbeddedDocuments("Item", results.item);

    const message = await this.createTurnMessage({ deltas, periods, rolls: results.rolls });

    /**
     * A hook event that fires after combat-related recovery changes have been applied.
     * @function dnd5e.postCombatRecovery
     * @memberof hookEvents
     * @param {Combatant5e} combatant       Combatant that is being recovered.
     * @param {string[]} periods            Periods that were recovered.
     * @param {ChatMessage5e|void} message  Chat message created, if any.
     */
    Hooks.callAll("dnd5e.postCombatRecovery", this, periods, message);
  }

  /* -------------------------------------------- */

  /**
   * Trigger this combatant's dynamic token to refresh.
   */
  refreshDynamicRing() {
    if ( !this.token?.hasDynamicRing ) return;
    this.token.object?.renderFlags.set({ refreshRingVisuals: true });
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDelete(options, userId) {
    requestAnimationFrame(() => this.refreshDynamicRing());
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static async _onUpdateOperation(documents, operation, user) {
    await super._onUpdateOperation(documents, operation, user);
    if ( (user.id !== game.userId) || !operation.parent ) return;
    const updates = {};
    for ( let i = 0; i < operation.updates.length; i++ ) {
      const update = operation.updates[i];
      const combatant = documents[i];
      if ( combatant.group ) updates[combatant.group.id] = update.initiative;
    }
    if ( foundry.utils.isEmpty(updates) ) return;
    await operation.parent.updateEmbeddedDocuments("CombatantGroup", Object.entries(updates).map(([id, init]) => {
      return { _id: id, initiative: init };
    }));
    operation.parent.setupTurns();
    ui.combat.render();
  }
}

/**
 * Custom CombatantGroup implementation.
 */
class CombatantGroup5e extends (foundry.documents.CombatantGroup ?? class {}) {
  /**
   * Nominate a Combatant that will perform operations on behalf of the group.
   * @returns {Combatant5e|null}
   */
  get activeCombatant() {
    if ( !this.members.size ) return null;
    let nominated;
    for ( const candidate of this.members ) {
      if ( !nominated || (nominated.id.compare(candidate.id) > 0) ) nominated = candidate;
    }
    return nominated;
  }
}

/**
 * Custom implementation of journal entry pages for providing roll data.
 */
class JournalEntryPage5e extends JournalEntryPage {
  /**
   * Return a data object regarding this page and from the containing journal entry.
   * @returns {object}
   */
  getRollData() {
    const { name, flags, system } = this;
    return {
      name: this.parent.name,
      flags: this.parent.flags,
      page: { ...system, name, flags }
    };
  }
}

/**
 * Extend the base TokenDocument class to implement system-specific HP bar logic.
 */
class TokenDocument5e extends SystemFlagsMixin(TokenDocument) {

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Is the dynamic token ring enabled?
   * @type {boolean}
   */
  get hasDynamicRing() {
    return this.ring.enabled;
  }

  /* -------------------------------------------- */
  /*  Data Migration                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _initializeSource(data, options={}) {
    if ( data instanceof foundry.abstract.DataModel ) data = data.toObject();

    // Migrate backpack -> container.
    for ( const item of data.delta?.items ?? [] ) {
      // This will be correctly flagged as needing a source migration when the synthetic actor is created, but we need
      // to also change the type in the raw ActorDelta to avoid spurious console warnings.
      if ( item.type === "backpack" ) item.type = "container";
    }
    return super._initializeSource(data, options);
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  getBarAttribute(barName, options={}) {
    const attribute = options.alternative || this[barName]?.attribute;
    if ( attribute?.startsWith(".") ) {
      const item = fromUuidSync(attribute, { relative: this.actor });
      const { value, max } = item?.system.uses ?? { value: 0, max: 0 };
      if ( max ) return { attribute, value, max, type: "bar", editable: true };
    }

    const data = super.getBarAttribute(barName, options);
    if ( data?.attribute === "attributes.hp" ) {
      const hp = this.actor.system.attributes.hp || {};
      data.value += (hp.temp || 0);
      data.max = Math.max(0, hp.effectiveMax);
    } else if ( ["resources.legact", "resources.legres"].includes(data?.attribute) ) {
      data.editable = true;
    }
    return data;
  }

  /* -------------------------------------------- */

  /**
   * Get an Array of attribute choices which are suitable for being consumed by an item usage.
   * @param {object} data  The actor data.
   * @returns {string[]}
   */
  static getConsumedAttributes(data) {
    return CONFIG.DND5E.consumableResources;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static getTrackedAttributeChoices(attributes) {
    const groups = super.getTrackedAttributeChoices(attributes);
    const i18n = {
      abilities: game.i18n.localize("DND5E.AbilityScorePl"),
      movement: game.i18n.localize("DND5E.MOVEMENT.FIELDS.speeds.label"),
      senses: game.i18n.localize("DND5E.Senses"),
      skills: game.i18n.localize("DND5E.SkillPassives"),
      slots: game.i18n.localize("JOURNALENTRYPAGE.DND5E.Class.SpellSlots")
    };
    for ( const entry of groups ) {
      const { value } = entry;
      if ( value.startsWith("abilities.") ) entry.group = i18n.abilities;
      else if ( value.startsWith("attributes.movement.") ) entry.group = i18n.movement;
      else if ( value.startsWith("attributes.senses.") ) entry.group = i18n.senses;
      else if ( value.startsWith("skills.") ) entry.group = i18n.skills;
      else if ( value.startsWith("spells.") ) entry.group = i18n.slots;
    }
    return groups;
  }

  /* -------------------------------------------- */

  /** @override */
  prepareData() {
    super.prepareData();
    if ( !this.hasDynamicRing ) return;
    let size = this.baseActor?.system.traits?.size;
    if ( !this.actorLink ) {
      const deltaSize = this.delta.system.traits?.size;
      if ( deltaSize ) size = deltaSize;
    }
    if ( !size ) return;
    const dts = CONFIG.DND5E.actorSizes[size].dynamicTokenScale ?? 1;
    this.texture.scaleX = this._source.texture.scaleX * dts;
    this.texture.scaleY = this._source.texture.scaleY * dts;
  }

  /* -------------------------------------------- */
  /*  Movement                                    */
  /* -------------------------------------------- */

  /**
   * Set up the system's movement action customization.
   */
  static registerMovementActions() {
    for ( const type of Object.keys(CONFIG.DND5E.movementTypes) ) {
      const actionConfig = CONFIG.Token.movement.actions[type];
      if ( !actionConfig ) continue;
      actionConfig.getAnimationOptions = token => {
        const actorMovement = token?.actor?.system.attributes?.movement ?? {};
        if ( !(type in actorMovement) || actorMovement[type] ) return {};
        return { movementSpeed: CONFIG.Token.movement.defaultSpeed / 2 };
      };
      actionConfig.getCostFunction = (...args) => this.getMovementActionCostFunction(type, ...args);
    }
    CONFIG.Token.movement.actions.crawl.getCostFunction = token => {
      const noAutomation = game.settings.get("dnd5e", "movementAutomation") === "none";
      const { actor } = token;
      const actorMovement = actor?.system.attributes?.movement;
      const hasMovement = actorMovement !== undefined;
      return noAutomation || !actor?.system.isCreature || !hasMovement
        ? cost => cost
        : (cost, _from, _to, distance) => cost + distance;
    };
  }

  /* -------------------------------------------- */

  /**
   * Return the movement action cost function for a specific movement type.
   * @param {string} type
   * @param {TokenDocument5e} token
   * @param {TokenMeasureMovementPathOptions} options
   * @returns {TokenMovementActionCostFunction}
   */
  static getMovementActionCostFunction(type, token, options) {
    const noAutomation = game.settings.get("dnd5e", "movementAutomation") === "none";
    const { actor } = token;
    const actorMovement = actor?.system.attributes?.movement;
    const walkFallback = CONFIG.DND5E.movementTypes[type]?.walkFallback;
    const hasMovement = actorMovement !== undefined;
    const speed = actorMovement?.[type];
    return noAutomation || !actor?.system.isCreature || !hasMovement || speed || (!speed && !walkFallback)
      ? cost => cost
      : (cost, _from, _to, distance) => cost + distance;
  }

  /* -------------------------------------------- */
  /*  Ring Animations                             */
  /* -------------------------------------------- */

  /**
   * Determine if any rings colors should be forced based on current status.
   * @returns {{[ring]: number, [background]: number}}
   */
  getRingColors() {
    const colors = {};
    if ( this.hasStatusEffect(CONFIG.specialStatusEffects.DEFEATED) ) {
      colors.ring = CONFIG.DND5E.tokenRingColors.defeated;
    }
    return colors;
  }

  /* -------------------------------------------- */

  /**
   * Determine what ring effects should be applied on top of any set by flags.
   * @returns {string[]}
   */
  getRingEffects() {
    const e = foundry.canvas.placeables.tokens.TokenRing.effects;
    const effects = [];
    if ( this.hasStatusEffect(CONFIG.specialStatusEffects.INVISIBLE) ) effects.push(e.INVISIBILITY);
    else if ( this === game.combat?.combatant?.token ) effects.push(e.RING_GRADIENT);
    return effects;
  }

  /* -------------------------------------------- */

  /**
   * Flash the token ring based on damage, healing, or temp HP.
   * @param {string} type     The key to determine the type of flashing.
   */
  flashRing(type) {
    if ( !this.rendered ) return;
    const color = CONFIG.DND5E.tokenRingColors[type];
    if ( !color ) return;
    const options = {};
    if ( type === "damage" ) {
      options.duration = 500;
      options.easing = foundry.canvas.placeables.tokens.TokenRing.easeTwoPeaks;
    }
    this.object.ring?.flashColor(Color.from(color), options);
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preCreate(data, options, user) {
    if ( (await super._preCreate(data, options, user)) === false ) return false;

    if ( (this.actor?.type === "npc") && !this.actorLink
      && foundry.utils.getProperty(this.actor, "system.attributes.hp.formula")?.trim().length ) {
      const autoRoll = options.dnd5e?.autoRollNPCHP ?? game.settings.get("dnd5e", "autoRollNPCHP");
      if ( autoRoll === "no" ) return;
      const roll = await this.actor.rollNPCHitPoints({ chatMessage: autoRoll === "yes" });
      this.delta.updateSource({
        "system.attributes.hp": {
          max: roll.total,
          value: roll.total
        }
      });
    }
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDelete(options, userId) {
    super._onDelete(options, userId);

    const origin = this.actor?.getFlag("dnd5e", "summon.origin");
    if ( origin ) {
      const { collection, primaryId } = foundry.utils.parseUuid(origin);
      dnd5e.registry.summons.untrack(collection?.get?.(primaryId)?.uuid, this.actor.uuid);
    }
  }
}

/**
 * Extend the basic User implementation.
 * @extends {User}
 */
class User5e extends SystemFlagsMixin(User) {
  /** @inheritDoc */
  get _systemFlagsDataModel() {
    return UserSystemFlags;
  }
}

/**
 * @import { BastionTurnItem, BastionTurnResult } from "../_types.mjs";
 */

/**
 * A singleton class that manages global Bastion activity.
 */
class Bastion {
  /**
   * The template for the chat card summary of a bastion attack.
   * @type {string}
   */
  static ATTACK_TEMPLATE = "systems/dnd5e/templates/chat/bastion-attack-summary.hbs";

  /**
   * The template for the chat card summary of a bastion turn.
   * @type {string}
   */
  static TURN_TEMPLATE = "systems/dnd5e/templates/chat/bastion-turn-summary.hbs";

  /* -------------------------------------------- */
  /*  Public API                                  */
  /* -------------------------------------------- */

  /**
   * Advance all bastions by a turn.
   * @returns {Promise<void>}
   */
  async advanceAllBastions() {
    // TODO: Should this advance game.time?
    const { duration } = game.settings.get("dnd5e", "bastionConfiguration");
    const haveBastions = game.actors.filter(a => (a.type === "character") && a.itemTypes.facility.length);
    for ( const actor of haveBastions ) await this.advanceAllFacilities(actor, { duration });
  }

  /* -------------------------------------------- */

  /**
   * Advance all the facilities of a given Actor by one bastion turn.
   * @param {Actor5e} actor                   The actor.
   * @param {object} [options]
   * @param {number} [options.duration=7]     The number of days the bastion turn spanned.
   * @param {boolean} [options.summary=true]  Print a chat message summary of the turn.
   * @returns {Promise<void>}
   */
  async advanceAllFacilities(actor, { duration=7, summary=true }={}) {
    const results = { orders: [], items: [], gold: 0 };
    for ( const facility of actor.itemTypes.facility ) {
      const { order, gold, items } = await this.advanceTurn(facility, { duration });
      if ( !order || (order === "maintain") ) continue;
      if ( gold ) results.gold += gold;
      if ( items ) results.items.push(...items);
      results.orders.push({ id: facility.id, order });
    }

    if ( summary ) {
      results.gold = { value: results.gold, claimed: false };
      const content = await this.#renderTurnSummary(actor, results);
      await ChatMessage.implementation.create({
        content,
        speaker: ChatMessage.implementation.getSpeaker({ actor }),
        flags: { dnd5e: { bastion: results } }
      });
    }
  }

  /* -------------------------------------------- */

  /**
   * Advance the given facility by one bastion turn.
   * @param {Item5e} facility              The facility.
   * @param {object} [options]
   * @param {number} [options.duration=7]  The number of days the bastion turn spanned.
   * @returns {Promise<BastionTurnResult>}
   */
  async advanceTurn(facility, { duration=7 }={}) {
    const { disabled, progress, type } = facility.system;

    // Case 1 - No order in progress.
    if ( !progress.max && !disabled ) {
      await facility.update({ "system.progress.order": "" });
      if ( type.value === "basic" ) return {}; // Basic facilities do nothing.
      return { order: "maintain" }; // Special facilities are considered to have been issued the maintain order.
    }

    const newProgress = Math.min(progress.value + duration, progress.max);

    // Case 2 - Order incomplete. Ongoing progress.
    if ( (newProgress < progress.max) && !disabled ) {
      await facility.update({ "system.progress.value": newProgress });
      return {};
    }

    // Case 3 - Order complete.
    const updates = { "system.progress": { value: 0, max: null, order: "" } };
    const { gold, items } = this.#evaluateOrder(facility, progress.order, updates);
    await facility.update(updates);
    return { gold, items, order: progress.order };
  }

  /* -------------------------------------------- */

  /**
   * Resolve a bastion attack against a given Actor's bastion.
   * @param {Actor5e} actor   The Actor.
   * @param {string} formula  The attack formula.
   * @param {object} [options]
   * @param {boolean} [options.summary=true]  Print a chat message summary of the attack.
   * @param {number} [options.threshold=1]    The maximum number on a die roll that is considered a defender death.
   * @returns {Promise<number>}               The number of defenders who died in the attack.
   */
  async resolveAttack(actor, formula, { summary=true, threshold=1 }={}) {
    const results = {};
    const roll = await Roll.create(formula).evaluate();
    const deaths = roll.dice.reduce((count, die) => {
      return count + die.results.filter(({ result, active }) => active && (result <= threshold)).length;
    }, 0);
    const defenders = this.#getDefenders(actor);
    if ( defenders.length ) results.deaths = Math.min(deaths, defenders.length);
    else results.undefended = true;
    if ( summary ) {
      const content = await this.#renderAttackSummary(actor, roll, results);
      await ChatMessage.implementation.create({
        content,
        speaker: ChatMessage.implementation.getSpeaker({ actor }),
        rolls: [roll],
        flags: { dnd5e: { bastion: results } }
      });
    }
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Attach interactivity to chat messages.
   * @param {ChatMessage5e} message  The chat message.
   * @param {HTMLElement} html       The rendered chat card element.
   * @internal
   */
  _activateChatListeners(message, html) {
    html.addEventListener("click", event => {
      const target = event.target.closest("[data-action]");
      if ( target ) this.#onChatAction(event, target, message);
    }, { passive: true });

    const actor = message.getAssociatedActor();
    if ( !actor?.isOwner ) return;

    html.querySelectorAll(".item-summary > li").forEach(async el => {
      const { uuid, quantity } = el.dataset;
      const item = await fromUuid(uuid);
      if ( !item ) return;
      el.draggable = true;
      el.addEventListener("dragstart", event => {
        this.#onDragItem(event, item, { "system.quantity": Number(quantity) });
      });
    });
  }

  /* -------------------------------------------- */

  /**
   * Evaluate the completion of an order.
   * @param {Item5e} facility  The facility.
   * @param {string} order     The order that was completed.
   * @param {object} updates   Facility updates.
   * @returns {Omit<BastionTurnResult, "order">}
   */
  #evaluateOrder(facility, order, updates) {
    switch ( order ) {
      case "build": return this.#evaluateBuildOrder(facility, updates);
      case "craft": return this.#evaluateCraftOrder(facility, updates);
      case "enlarge": return this.#evaluateEnlargeOrder(facility, updates);
      case "harvest": return this.#evaluateHarvestOrder(facility, updates);
      case "repair": return this.#evaluateRepairOrder(facility, updates);
      case "trade": return this.#evaluateTradeOrder(facility, updates);
    }
    return {};
  }

  /* -------------------------------------------- */

  /**
   * Evaluate the completion of a build order.
   * @param {Item5e} facility  The facility.
   * @param {object} updates   Facility updates.
   * @returns {Omit<BastionTurnResult, "order">}
   */
  #evaluateBuildOrder(facility, updates) {
    const { building } = facility.system;
    updates["system.building.built"] = true;
    updates["system.size"] = building.size;
    return {};
  }

  /* -------------------------------------------- */

  /**
   * Evaluate the completion of a craft order.
   * @param {Item5e} facility          The facility.
   * @param {object} updates           Facility updates.
   * @returns {Omit<BastionTurnResult, "order">}
   */
  #evaluateCraftOrder(facility, updates) {
    const { craft } = facility.system;
    updates["system.craft.item"] = null;
    return { items: [{ uuid: craft.item, quantity: 1 }] };
  }

  /* -------------------------------------------- */

  /**
   * Evaluate the completion of an enlarge order.
   * @param {Item5e} facility  The facility.
   * @param {object} updates   Facility updates.
   * @returns {Omit<BastionTurnResult, "order">}
   */
  #evaluateEnlargeOrder(facility, updates) {
    const { size } = facility.system;
    const sizes = Object.entries(CONFIG.DND5E.facilities.sizes).sort(([, a], [, b]) => a.value - b.value);
    const index = Math.clamp(sizes.findIndex(([key]) => key === size), 0, sizes.length - 2);
    const [next] = sizes[index + 1];
    updates["system.size"] = next;
    return {};
  }

  /* -------------------------------------------- */

  /**
   * Evaluate the completion of a harvest order.
   * @param {Item5e} facility  The facility.
   * @param {object} updates   Facility updates.
   * @returns {Omit<BastionTurnResult, "order">}
   */
  #evaluateHarvestOrder(facility, updates) {
    const { craft } = facility.system;
    return { items: [{ uuid: craft.item, quantity: craft.quantity }] };
  }

  /* -------------------------------------------- */

  /**
   * Evaluate the completion of a repair order.
   * @param {Item5e} facility  The facility.
   * @param {object} updates   Facility updates.
   * @returns {Omit<BastionTurnResult, "order">}
   */
  #evaluateRepairOrder(facility, updates) {
    updates["system.disabled"] = false;
    return {};
  }

  /* -------------------------------------------- */

  /**
   * Evaluate the completion of a trade order.
   * @param {Item5e} facility  The facility.
   * @param {object} updates   Facility updates.
   * @returns {Omit<BastionTurnResult, "order">}
   */
  #evaluateTradeOrder(facility, updates) {
    const { trade } = facility.system;
    updates["system.trade.pending.operation"] = null;
    updates["system.trade.pending.creatures"] = [];
    updates["system.trade.pending.value"] = null;
    if ( !trade.pending.operation ) return {};

    if ( trade.pending.operation === "buy" ) {
      // Stocked facility
      if ( (trade.pending.value === null) && trade.pending.stocked ) updates["system.trade.stock.stocked"] = true;

      // Bought goods
      else if ( trade.pending.value !== null ) {
        if ( trade.pending.creatures.length ) {
          updates["system.trade.creatures.value"] = trade.creatures.value.concat(trade.pending.creatures);
        }
        else updates["system.trade.stock.value"] = Math.min(trade.stock.value + trade.pending.value, trade.stock.max);
      }
    } else if ( trade.pending.value !== null ) {
      // Sold goods
      let sold = trade.pending.value;
      if ( !trade.pending.creatures.length ) {
        updates["system.trade.stock.value"] = Math.max(0, trade.stock.value - trade.pending.value);
        sold = trade.stock.value - updates["system.trade.stock.value"];
      }

      return { gold: Math.floor(sold * ((trade.profit / 100) + 1)) };
    }

    return {};
  }

  /* -------------------------------------------- */

  /**
   * Retrieve a list of defenders for the given Actor's bastion.
   * @param {Actor5e} actor  The actor.
   * @returns {{ facility: Item5e, uuid: string }[]}
   */
  #getDefenders(actor) {
    const allDefenders = [];
    for ( const facility of actor.itemTypes.facility ) {
      const { defenders, type } = facility.system;
      if ( (type.value === "special") && defenders.max ) {
        allDefenders.push(...defenders.value.map(uuid => ({ facility, uuid })));
      }
    }
    return allDefenders;
  }

  /* -------------------------------------------- */

  /**
   * Handle clicking action elements in chat cards.
   * @param {PointerEvent} event     The triggering event.
   * @param {HTMLElement} target     The action element.
   * @param {ChatMessage5e} message  The chat message.
   */
  #onChatAction(event, target, message) {
    const { action } = target.dataset;
    switch ( action ) {
      case "claim": this.#onClaimGold(message); break;
      case "resolve": this.#onResolveAttack(message); break;
      case "viewItem": this.#onViewItem(target); break;
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle claiming gold from a bastion turn summary message.
   * @param {ChatMessage5e} message  The message.
   * @returns {Promise<ChatMessage5e|void>}
   */
  async #onClaimGold(message) {
    const results = message.getFlag("dnd5e", "bastion");
    const { gold } = results;
    const actor = message.getAssociatedActor();
    const { gp } = actor?.system?.currency ?? {};
    if ( !gold?.value || gold.claimed || (gp === undefined) ) return;
    await actor.update({ "system.currency.gp": gp + gold.value });
    gold.claimed = true;
    const content = await this.#renderTurnSummary(actor, results);
    return message.update({ content, flags: { dnd5e: { bastion: results } } });
  }

  /* -------------------------------------------- */

  /**
   * Handle dragging an item created as part of order completion.
   * @param {DragEvent} event    The initiating drag event.
   * @param {Item5e} item        The created item.
   * @param {object} [updates]   Updates to apply to the Item.
   */
  #onDragItem(event, item, updates={}) {
    // TODO: Need some way to mark the item as 'claimed' when it is dropped onto an Actor sheet.
    if ( !foundry.utils.isEmpty(updates) ) item.updateSource(updates);
    event.dataTransfer.setData("text/plain", JSON.stringify({
      data: game.items.fromCompendium(item, { keepId: true }), type: "Item"
    }));
  }

  /* -------------------------------------------- */

  /**
   * Handle automatic resolution of a bastion attack via chat message.
   * @param {ChatMessage5e} message  The message.
   * @returns {Promise<ChatMessage5e|void>}
   */
  async #onResolveAttack(message) {
    const results = message.getFlag("dnd5e", "bastion") ?? {};
    const { deaths, undefended } = results;
    const actor = message.getAssociatedActor();
    if ( (!deaths && !undefended) || !actor ) return;

    if ( deaths ) {
      const defenders = this.#getDefenders(actor);
      const slain = [];
      for ( let i = 0; i < deaths; i++ ) {
        if ( !defenders.length ) break;
        const roll = await Roll.create(`1d${defenders.length}`).evaluate({ allowInteractive: false });
        const [defender] = defenders.splice(roll.total - 1, 1);
        slain.push(defender);
      }
      const updates = {};
      for ( const { facility, uuid } of slain ) {
        if ( !updates[facility.id] ) updates[facility.id] = [...facility.system.defenders.value];
        updates[facility.id].findSplice(a => a === uuid);
      }
      await actor.updateEmbeddedDocuments("Item", Object.entries(updates).map(([_id, value]) => {
        return { _id, "system.defenders.value": value };
      }));
    }

    let damaged;
    const defenders = this.#getDefenders(actor);
    if ( !defenders.length ) {
      const special = actor.itemTypes.facility.filter(f => (f.system.type.value === "special") && !f.system.disabled);
      if ( special.length ) {
        const roll = await Roll.create(`1d${special.length}`).evaluate({ allowInteractive: false });
        damaged = special[roll.total - 1];
        await damaged?.update({ "system.disabled": true });
      }
    }

    if ( damaged ) results.damaged = damaged.id;
    results.resolved = true;
    const content = await this.#renderAttackSummary(actor, message.rolls[0], results);
    return message.update({ content, flags: { dnd5e: { bastion: results } } });
  }

  /* -------------------------------------------- */

  /**
   * Handle viewing a created item.
   * @param {HTMLElement} target  The item element.
   * @returns {Promise}
   */
  async #onViewItem(target) {
    const { uuid } = target.dataset;
    const item = await fromUuid(uuid);
    return item?.sheet.render(true);
  }

  /* -------------------------------------------- */

  /**
   * Render a chat card summary for the bastion attack.
   * @param {Actor5e} actor                 The actor whose bastion was attacked.
   * @param {Roll} roll                     The bastion attack roll.
   * @param {object} [results]
   * @param {string} [results.damaged]      The ID of the facility damaged in the attack.
   * @param {number} [results.deaths]       The number of bastion defenders slain in the attack.
   * @param {boolean} [results.undefended]  If the bastion was undefended during the attack.
   * @param {boolean} [results.resolved]    Whether the attack has been automatically resolved.
   * @returns {Promise<string>}
   */
  async #renderAttackSummary(actor, roll, { damaged, deaths, undefended, resolved }={}) {
    const context = {};
    const plurals = new Intl.PluralRules(game.i18n.lang);
    const key = undefended ? "Undefended" : deaths ? `Deaths.${plurals.select(deaths)}` : "NoDeaths";
    context.description = game.i18n.format(`DND5E.Bastion.Attack.Result.${key}`, { deaths });
    context.roll = await roll.render();
    context.buttons = [];
    if ( !resolved && (deaths || undefended) ) {
      context.buttons.push({
        label: game.i18n.localize("DND5E.Bastion.Attack.Automatic"),
        icon: '<i class="fas fa-bolt"></i>',
        dataset: { action: "resolve" }
      });
    }
    if ( damaged ) {
      const facility = actor.items.get(damaged);
      if ( facility ) context.damaged = game.i18n.format("DND5E.Bastion.Attack.Result.Damaged", {
        link: facility.toAnchor().outerHTML
      });
    }
    return foundry.applications.handlebars.renderTemplate(this.constructor.ATTACK_TEMPLATE, context);
  }

  /* -------------------------------------------- */

  /**
   * Render a chat card summary for the bastion turn results.
   * @param {Actor5e} actor                                     The actor whose turn it was.
   * @param {object} results
   * @param {BastionTurnItem[]} results.items                   The items produced during the turn.
   * @param {{ value: number, claimed: boolean }} results.gold  Gold generated during the turn.
   * @param {{ id: string, order: string }[]} results.orders    Orders completed during the turn.
   * @returns {Promise<string>}
   */
  async #renderTurnSummary(actor, results) {
    const context = {};
    context.items = (await Promise.all(results.items.map(async ({ uuid, quantity }) => {
      const item = await fromUuid(uuid);
      if ( !item ) return null;
      const { name, img } = item;
      return { img, name, quantity, uuid };
    }))).filter(_ => _);
    context.orders = results.orders.map(({ id, order }) => {
      const facility = actor.items.get(id);
      return {
        name: facility.name,
        contentLink: facility.toAnchor().outerHTML,
        order: CONFIG.DND5E.facilities.orders[order]?.label
      };
    });
    context.supplements = [];
    if ( results.gold.value ) {
      context.supplements.push(`
        <strong>${game.i18n.localize("DND5E.CurrencyGP")}</strong>
        ${formatNumber(results.gold.value)}
        (${game.i18n.localize(`DND5E.Bastion.Gold.${results.gold.claimed ? "Claimed" : "Unclaimed"}`)})
      `);
    }
    context.buttons = [];
    if ( results.gold.value && !results.gold.claimed ) {
      context.buttons.push({
        label: game.i18n.localize("DND5E.Bastion.Gold.Claim"),
        icon: '<i class="fas fa-coins"></i>',
        dataset: { action: "claim" }
      });
    }
    return foundry.applications.handlebars.renderTemplate(this.constructor.TURN_TEMPLATE, context);
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Confirm the bastion turn should be advanced.
   * @returns {Promise<void>}
   */
  async confirmAdvance() {
    if ( !game.user.isGM ) return;
    const proceed = await foundry.applications.api.DialogV2.confirm({
      content: game.i18n.localize("DND5E.Bastion.Confirm"),
      rejectClose: false
    });
    if ( proceed ) return this.advanceAllBastions();
  }

  /* -------------------------------------------- */

  /**
   * Initialize the bastion UI.
   */
  initializeUI() {
    const turnButton = document.getElementById("bastion-turn");
    const { button, enabled } = game.settings.get("dnd5e", "bastionConfiguration");

    if ( !enabled || !button || !game.user.isGM) {
      turnButton?.remove();
      return;
    }

    if ( !turnButton ) {
      document.querySelector("#controls, #scene-controls")?.insertAdjacentHTML("afterend", `
        <button type="button" id="bastion-turn" data-action="bastionTurn" class="dnd5e2 faded-ui">
          <i class="fas fa-chess-rook"></i>
          <span>${game.i18n.localize("DND5E.Bastion.Action.BastionTurn")}</span>
        </button>
      `);
      document.getElementById("bastion-turn")?.addEventListener("click", this.confirmAdvance.bind(this));
    }
  }

  /* -------------------------------------------- */

  /**
   * Prompt the DM to resolve a bastion attack against a specific Actor.
   * @param {Actor5e} [actor]  The Actor.
   * @returns {Promise}
   */
  async promptAttack(actor) {
    if ( !game.user.isGM ) return;

    // Determine Actor by selected token.
    if ( !actor ) {
      const [token] = canvas.tokens.controlled;
      actor = token?.actor;
    }

    // Determine Actor by active window.
    if ( !actor && (ui.activeWindow instanceof ActorSheet) ) actor = ui.activeWindow.actor;

    if ( !actor ) {
      ui.notifications.warn("DND5E.Bastion.Attack.NoActorWarning", { localize: true });
      return;
    }

    const formula = await BastionAttackDialog.prompt(actor);
    if ( formula ) return this.resolveAttack(actor, formula);
  }
}

/* -------------------------------------------- */

const { StringField } = foundry.data.fields;

/**
 * A dialog for resolving bastion attacks.
 */
class BastionAttackDialog extends Dialog5e {
  constructor({ actor, ...options }={}) {
    super(options);
    this.#actor = actor;
  }

  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["bastion-attack"],
    window: {
      title: "DND5E.Bastion.Attack.Title",
      icon: "fas fa-chess-rook"
    },
    form: {
      handler: BastionAttackDialog.#handleFormSubmission
    },
    position: {
      width: 420
    },
    buttons: [{
      action: "resolve",
      label: "DND5E.Bastion.Attack.Resolve",
      icon: "fas fa-dice",
      default: true
    }]
  };

  /** @inheritDoc */
  static PARTS = {
    ...super.PARTS,
    content: {
      template: "systems/dnd5e/templates/apps/bastion-attack-dialog.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The Actor whose bastion is being attacked.
   * @type {Actor5e}
   */
  #actor;

  /**
   * The bastion attack formula.
   * @type {string|null}
   */
  get formula() {
    return this.#formula;
  }

  #formula = null;

  /** @override */
  get subtitle() {
    return this.#actor.name;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContentContext(context, options) {
    context = await super._prepareContentContext(context, options);
    context.formula = {
      field: new StringField({ initial: "", label: "DND5E.Formula" }),
      name: "formula"
    };
    return context;
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /**
   * Handle submission of the dialog.
   * @this {BastionAttackDialog}
   * @param {SubmitEvent} event          The form submission event.
   * @param {HTMLFormElement} form       The submitted form.
   * @param {FormDataExtended} formData  Data from the dialog.
   * @returns {Promise}
   */
  static #handleFormSubmission(event, form, formData) {
    this.#formula = formData.object.formula;
    return this.close({ dnd5e: { submitted: true } });
  }

  /* -------------------------------------------- */

  /** @override */
  _onClose(options={}) {
    if ( !options.dnd5e?.submitted ) this.#formula = null;
  }

  /* -------------------------------------------- */
  /*  Factory Methods                             */
  /* -------------------------------------------- */

  /**
   * Create the bastion attack prompt.
   * @param {Actor5e} actor      The Actor whose bastion is being attacked.
   * @returns {Promise<string>}  A promise that resolves to the input bastion attack formula.
   */
  static prompt(actor) {
    return new Promise(resolve => {
      const dialog = new this({ actor });
      dialog.addEventListener("close", () => resolve(dialog.formula), { once: true });
      dialog.render({ force: true });
    });
  }
}

var _module = /*#__PURE__*/Object.freeze({
  __proto__: null,
  DependentDocumentMixin: DependentDocumentMixin,
  SystemDocumentMixin: SystemDocumentMixin,
  SystemFlagsMixin: SystemFlagsMixin
});

// Document Classes

var documents = /*#__PURE__*/Object.freeze({
  __proto__: null,
  ActiveEffect5e: ActiveEffect5e,
  Actor5e: Actor5e,
  Adventure5e: Adventure5e,
  Bastion: Bastion,
  ChatMessage5e: ChatMessage5e,
  Combat5e: Combat5e,
  Combatant5e: Combatant5e,
  CombatantGroup5e: CombatantGroup5e,
  HitDice: HitDice,
  Item5e: Item5e,
  JournalEntryPage5e: JournalEntryPage5e,
  Proficiency: Proficiency,
  Scaling: Scaling,
  SelectChoices: SelectChoices,
  TokenDocument5e: TokenDocument5e,
  Trait: trait,
  User5e: User5e,
  activity: _module$x,
  advancement: _module$w,
  macro: macro,
  mixins: _module
});

/**
 * Perform a system migration for the entire World, applying migrations for Actors, Items, and Compendium packs.
 * @param {object} [options={}]
 * @param {boolean} [options.bypassVersionCheck=false]  Bypass certain migration restrictions gated behind system
 *                                                      version stored in item stats.
 * @returns {Promise}      A Promise which resolves once the migration is completed
 */
async function migrateWorld({ bypassVersionCheck=false }={}) {
  const version = game.system.version;
  const progress = ui.notifications.info("MIGRATION.5eBegin", {
    console: false, format: { version }, permanent: true, progress: true
  });
  const { packs, packDocuments } = game.packs.reduce((obj, pack) => {
    if ( _shouldMigrateCompendium(pack) ) {
      obj.packs.push(pack);
      obj.packDocuments += pack.index.size;
    }
    return obj;
  }, { packs: [], packDocuments: 0 });
  const totalDocuments = game.actors.size + game.items.size + game.macros.size + game.tables.size
    + game.scenes.reduce((total, s) => total + s.tokens.size, 0) + packDocuments;
  let migrated = 0;
  const incrementProgress = () => progress.update({ pct: ++migrated / totalDocuments });

  const migrationData = await getMigrationData();
  await migrateSettings();
  let hasErrors = false;
  const logError = (err, type, name) => {
    err.message = `Failed dnd5e system migration for ${type} ${name}: ${err.message}`;
    console.error(err);
    hasErrors = true;
  };

  // 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 flags = { bypassVersionCheck, persistSourceMigration: false };
      const source = valid ? actor.toObject() : game.data.actors.find(a => a._id === actor.id);
      const version = actor._stats.systemVersion;
      let updateData = migrateActorData(actor, source, migrationData, flags, { actorUuid: actor.uuid });
      if ( !foundry.utils.isEmpty(updateData) ) {
        log(`Migrating Actor document ${actor.name}`);
        if ( flags.persistSourceMigration ) {
          updateData = foundry.utils.mergeObject(source, updateData, {inplace: false});
        }
        await actor.update(updateData, {
          enforceTypes: false, diff: valid && !flags.persistSourceMigration,
          recursive: !flags.persistSourceMigration, render: false
        });
      }
      if ( actor.effects && actor.items && foundry.utils.isNewerVersion("3.0.3", version) ) {
        const deleteIds = _duplicatedEffects(actor);
        if ( deleteIds.size ) await actor.deleteEmbeddedDocuments("ActiveEffect", Array.from(deleteIds), {
          render: false
        });
      }
    } catch(err) {
      logError(err, "Actor", actor.name);
    }
    incrementProgress();
  }

  // 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 flags = { bypassVersionCheck, persistSourceMigration: false };
      const source = valid ? item.toObject() : game.data.items.find(i => i._id === item.id);
      let updateData = migrateItemData(item, source, migrationData, flags);
      if ( !foundry.utils.isEmpty(updateData) ) {
        log(`Migrating Item document ${item.name}`);
        if ( flags.persistSourceMigration ) {
          if ( "effects" in updateData ) updateData.effects = source.effects.map(effect => foundry.utils.mergeObject(
            effect, updateData.effects.find(e => e._id === effect._id) ?? {}, { inplace: false, performDeletions: true }
          ));
          updateData = foundry.utils.mergeObject(source, updateData, { inplace: false, performDeletions: true });
        }
        await item.update(updateData, {
          enforceTypes: false, diff: valid && !flags.persistSourceMigration,
          recursive: !flags.persistSourceMigration, render: false
        });
      }
    } catch(err) {
      logError(err, "Item", item.name);
    }
    incrementProgress();
  }

  // Migrate World Macros
  for ( const m of game.macros ) {
    try {
      const updateData = migrateMacroData(m.toObject(), migrationData);
      if ( !foundry.utils.isEmpty(updateData) ) {
        log(`Migrating Macro document ${m.name}`);
        await m.update(updateData, {enforceTypes: false, render: false});
      }
    } catch(err) {
      logError(err, "Macro", m.name);
    }
    incrementProgress();
  }

  // Migrate World Roll Tables
  for ( const table of game.tables ) {
    try {
      const updateData = migrateRollTableData(table.toObject(), migrationData);
      if ( !foundry.utils.isEmpty(updateData) ) {
        log(`Migrating RollTable document ${table.name}`);
        await table.update(updateData, { enforceTypes: false, render: false });
      }
    } catch(err) {
      logError(err, "RollTable", table.name);
    }
    incrementProgress();
  }

  // Migrate Actor Override Tokens
  for ( const s of game.scenes ) {
    try {
      const updateData = migrateSceneData(s, migrationData);
      if ( !foundry.utils.isEmpty(updateData) ) {
        log(`Migrating Scene document ${s.name}`);
        await s.update(updateData, {enforceTypes: false, render: false});
      }
    } catch(err) {
      logError(err, "Scene", s.name);
    }

    // Migrate ActorDeltas individually in order to avoid issues with ActorDelta bulk updates.
    for ( const token of s.tokens ) {
      if ( token.actorLink || !token.actor ) {
        incrementProgress();
        continue;
      }
      try {
        const flags = { bypassVersionCheck, persistSourceMigration: false };
        const source = token.actor.toObject();
        let updateData = migrateActorData(token.actor, source, migrationData, flags, { actorUuid: token.actor.uuid });
        if ( !foundry.utils.isEmpty(updateData) ) {
          log(`Migrating ActorDelta document ${token.actor.name} [${token.delta.id}] in Scene ${s.name}`);
          if ( flags.persistSourceMigration ) {
            updateData = foundry.utils.mergeObject(source, updateData, { inplace: false });
          } else {
            // Workaround for core issue of bulk updating ActorDelta collections.
            ["items", "effects"].forEach(col => {
              for ( const [i, update] of (updateData[col] ?? []).entries() ) {
                const original = token.actor[col].get(update._id);
                updateData[col][i] = foundry.utils.mergeObject(original.toObject(), update, { inplace: false });
              }
            });
          }
          await token.actor.update(updateData, {
            enforceTypes: false, diff: !flags.persistSourceMigration,
            recursive: !flags.persistSourceMigration, render: false
          });
        }
      } catch(err) {
        logError(err, "ActorDelta", `[${token.id}]`);
      }
      incrementProgress();
    }
  }

  // Migrate World Compendium Packs
  for ( let p of packs ) {
    await migrateCompendium(p, { incrementProgress });
  }
  const legacyFolder = game.folders.find(f => f.type === "Compendium" && f.name === "D&D SRD Content");
  if ( legacyFolder ) legacyFolder.update({ name: "D&D Legacy Content" });

  // Set the migration as complete
  game.settings.set("dnd5e", "systemMigrationVersion", game.system.version);
  progress.element?.classList.add(hasErrors ? "warning" : "success");
  progress.update({ message: "MIGRATION.5eComplete", format: { version }, pct: 1 });
}

/* -------------------------------------------- */

/**
 * Determine whether a compendium pack should be migrated during `migrateWorld`.
 * @param {Compendium} pack
 * @returns {boolean}
 */
function _shouldMigrateCompendium(pack) {
  // We only care about actor, item or scene migrations
  if ( !["Actor", "Item", "Scene"].includes(pack.documentName) ) return false;

  // World compendiums should all be migrated, system ones should never by migrated
  if ( pack.metadata.packageType === "world" ) return true;
  if ( pack.metadata.packageType === "system" ) return false;

  // Module compendiums should only be migrated if they don't have a download or manifest URL
  const module = game.modules.get(pack.metadata.packageName);
  return !module.download && !module.manifest;
}

/* -------------------------------------------- */

/**
 * Apply migration rules to all Documents within a single Compendium pack
 * @param {CompendiumCollection} pack       Pack to be migrated.
 * @param {object} [options={}]
 * @param {boolean} [options.bypassVersionCheck=false]  Bypass certain migration restrictions gated behind system
 *                                                      version stored in item stats.
 * @param {Function} [options.incrementProgress]        Function that can be called to increment the progress bar.
 * @param {boolean} [options.strict=false]  Migrate errors should stop the whole process.
 * @returns {Promise}
 */
async function migrateCompendium(pack, { bypassVersionCheck=false, incrementProgress, strict=false }={}) {
  const documentName = pack.documentName;
  if ( !["Actor", "Item", "Scene"].includes(documentName) ) return;

  const migrationData = await getMigrationData();

  // Unlock the pack for editing
  const wasLocked = pack.locked;
  try {
    await pack.configure({locked: false});
    game.compendiumArt.enabled = false;

    // Begin by requesting server-side data model migration and get the migrated content
    const documents = await pack.getDocuments();

    // Iterate over compendium entries - applying fine-tuned migration functions
    for ( let doc of documents ) {
      let updateData = {};
      try {
        const flags = { bypassVersionCheck, persistSourceMigration: false };
        const source = doc.toObject();
        switch ( documentName ) {
          case "Actor":
            updateData = migrateActorData(doc, source, migrationData, flags, { actorUuid: doc.uuid });
            if ( (documentName === "Actor") && source.effects && source.items
              && foundry.utils.isNewerVersion("3.0.3", source._stats.systemVersion) ) {
              const deleteIds = _duplicatedEffects(source);
              if ( deleteIds.size ) {
                if ( flags.persistSourceMigration ) source.effects = source.effects.filter(e => !deleteIds.has(e._id));
                else await doc.deleteEmbeddedDocuments("ActiveEffect", Array.from(deleteIds));
              }
            }
            break;
          case "Item":
            updateData = migrateItemData(doc, source, migrationData, flags);
            break;
          case "Scene":
            updateData = migrateSceneData(source, migrationData, flags);
            break;
        }

        // Save the entry, if data was changed
        if ( foundry.utils.isEmpty(updateData) ) continue;
        if ( flags.persistSourceMigration ) updateData = foundry.utils.mergeObject(source, updateData);
        await doc.update(updateData, { diff: !flags.persistSourceMigration });
        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);
        if ( strict ) throw err;
      }

      finally {
        incrementProgress?.();
      }
    }

    log(`Migrated all ${documentName} documents from Compendium ${pack.collection}`);
  } finally {
    // Apply the original locked status for the pack
    await pack.configure({locked: wasLocked});
    game.compendiumArt.enabled = true;
  }
}

/* -------------------------------------------- */

/**
 * Re-parents compendia from one top-level folder to another.
 * @param {string} from  The name of the source folder.
 * @param {string} to    The name of the destination folder.
 * @returns {Promise<Folder[]> | undefined}
 */
function reparentCompendiums(from, to) {
  const compendiumFolders = new Map();
  for ( const folder of game.folders ) {
    if ( folder.type !== "Compendium" ) continue;
    if ( folder.folder ) {
      let folders = compendiumFolders.get(folder.folder);
      if ( !folders ) {
        folders = [];
        compendiumFolders.set(folder.folder, folders);
      }
      folders.push(folder);
    }
    if ( folder.name === from ) from = folder;
    else if ( folder.name === to ) to = folder;
  }
  if ( !(from instanceof Folder) || !(to instanceof Folder) ) return;
  const config = game.settings.get("core", "compendiumConfiguration");

  // Re-parent packs directly under the source folder.
  Object.values(config).forEach(conf => {
    if ( conf.folder === from.id ) conf.folder = to.id;
  });

  game.settings.set("core", "compendiumConfiguration", config);

  // Re-parent folders directly under the source folder.
  const updates = (compendiumFolders.get(from) ?? []).map(f => ({ _id: f.id, folder: to.id }));
  return Folder.implementation.updateDocuments(updates).then(() => from.delete());
}

/* -------------------------------------------- */

/**
 * Update all compendium packs using the new system data model.
 * @param {object} [options={}]
 * @param {boolean} [options.bypassVersionCheck=false]  Bypass certain migration restrictions gated behind system
 *                                                      version stored in item stats.
 * @param {boolean} [options.migrate=true]  Also perform a system migration before refreshing.
 */
async function refreshAllCompendiums(options) {
  for ( const pack of game.packs ) {
    await refreshCompendium(pack, options);
  }
}

/* -------------------------------------------- */

/**
 * Update all Documents in a compendium using the new system data model.
 * @param {CompendiumCollection} pack  Pack to refresh.
 * @param {object} [options={}]
 * @param {boolean} [options.bypassVersionCheck=false]  Bypass certain migration restrictions gated behind system
 *                                                      version stored in item stats.
 * @param {boolean} [options.migrate=true]  Also perform a system migration before refreshing.
 */
async function refreshCompendium(pack, { bypassVersionCheck, migrate=true }={}) {
  if ( !pack?.documentName ) return;
  if ( migrate ) {
    try {
      await migrateCompendium(pack, { bypassVersionCheck, strict: true });
    } catch( err ) {
      err.message = `Failed dnd5e system migration pack ${pack.collection}: ${err.message}`;
      console.error(err);
      return;
    }
  }

  game.compendiumArt.enabled = false;
  const DocumentClass = CONFIG[pack.documentName].documentClass;
  const wasLocked = pack.locked;
  await pack.configure({locked: false});

  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});
  game.compendiumArt.enabled = true;
  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}
 */
async function migrateArmorClass(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 {
      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.type.value) && 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});
  log(`Migrated the AC of all Actors from Compendium ${pack.collection}`);
}

/* -------------------------------------------- */

/**
 * Migrate system settings to new data types.
 */
async function migrateSettings() {
  // Migrate Disable Experience Tracking to Leveling Mode
  const disableExperienceTracking = game.settings.storage.get("world")
    ?.find(s => s.key === "dnd5e.disableExperienceTracking")?.value;
  const levelingMode = game.settings.storage.get("world")?.find(s => s.key === "dnd5e.levelingMode")?.value;
  if ( (disableExperienceTracking !== undefined) && (levelingMode === undefined) ) {
    await game.settings.set("dnd5e", "levelingMode", "noxp");
  }
  // Migrate Disable Movement Automation to Movement Automation
  const disableMovementAutomation = game.settings.storage.get("world")
    ?.find(s => s.key === "dnd5e.disableMovementAutomation")?.value;
  const movementAutomation = game.settings.storage.get("world")
    ?.find(s => s.key === "dnd5e.movementAutomation")?.value;
  if ( (disableMovementAutomation !== undefined) && (movementAutomation === undefined) ) {
    await game.settings.set("dnd5e", "movementAutomation", disableMovementAutomation ? "none" : "full");
  }
}

/* -------------------------------------------- */
/*  Document Type Migration Helpers             */
/* -------------------------------------------- */

/**
 * Migrate a single Actor document to incorporate latest data model changes
 * Return an Object of updateData to be applied
 * @param {Actor5e} actor               Full actor instance.
 * @param {object} actorData            The actor data object to update.
 * @param {object} [migrationData]      Additional data to perform the migration.
 * @param {object} [flags={}]           Track the needs migration flag.
 * @param {object} [options]
 * @param {string} [options.actorUuid]  The UUID of the actor.
 * @returns {object}                    The updateData to apply.
 */
function migrateActorData(actor, actorData, migrationData, flags={}, { actorUuid }={}) {
  const updateData = {};
  _migrateTokenImage(actorData, updateData);
  _migrateActorAC(actorData, updateData);
  _migrateActorFlags(actorData, updateData);
  _migrateActorMovementSenses(actorData, updateData);

  // Migrate embedded effects
  if ( actorData.effects ) {
    const effects = migrateEffects(actorData, migrationData);
    if ( foundry.utils.isNewerVersion("3.1.0", actorData._stats?.systemVersion) ) {
      migrateCopyActorTransferEffects(actorData, effects, { actorUuid });
    }
    if ( effects.length > 0 ) updateData.effects = effects;
  }

  // Set source rules version to Legacy
  if ( foundry.utils.isNewerVersion("4.0.0", actorData._stats?.systemVersion) || flags.bypassVersionCheck ) {
    updateData["system.source.rules"] = "2014";
  }

  // Migrate Owned Items
  if ( !actorData.items ) return updateData;
  const items = actor.items.reduce((arr, i) => {
    // Migrate the Owned Item
    const itemData = i instanceof CONFIG.Item.documentClass ? i.toObject() : i;
    const itemFlags = { bypassVersionCheck: flags.bypassVersionCheck ?? false, persistSourceMigration: false };
    let itemUpdate = migrateItemData(i, itemData, migrationData, itemFlags);

    if ( (itemData.type === "background") && (actorData.system?.details?.background !== itemData._id) ) {
      updateData["system.details.background"] = itemData._id;
    }

    // Prepared, Equipped, and Proficient for NPC actors
    if ( actorData.type === "npc" ) {
      if (foundry.utils.getProperty(itemData.system, "prepared") === false) itemUpdate["system.prepared"] = 1;
      if (foundry.utils.getProperty(itemData.system, "equipped") === false) itemUpdate["system.equipped"] = true;
    }

    // Update the Owned Item
    if ( itemFlags.persistSourceMigration ) flags.persistSourceMigration = true;
    arr.push({ itemData, itemUpdate });

    // Update tool expertise.
    if ( actorData.system.tools ) {
      const hasToolProf = itemData.system.type?.baseItem in actorData.system.tools;
      if ( (itemData.type === "tool") && (itemData.system.proficient > 1) && hasToolProf ) {
        updateData[`system.tools.${itemData.system.type.baseItem}.value`] = itemData.system.proficient;
      }
    }

    return arr;
  }, []).map(({ itemData, itemUpdate }) => {
    if ( flags.persistSourceMigration ) {
      if ( "effects" in itemUpdate ) itemUpdate.effects = itemData.effects.map(effect => foundry.utils.mergeObject(
        effect, itemUpdate.effects.find(e => e._id === effect._id) ?? {}, { inplace: false, performDeletions: true }
      ));
      itemUpdate = foundry.utils.mergeObject(itemData, itemUpdate, { inplace: false, performDeletions: true });
    }
    return foundry.utils.isEmpty(itemUpdate) ? null : { ...itemUpdate, _id: itemData._id };
  }).filter(_ => _);
  if ( items.length > 0 ) updateData.items = items;

  return updateData;
}

/* -------------------------------------------- */

/**
 * Migrate a single Item document to incorporate latest data model changes
 *
 * @param {Item5e} item             Full item instance.
 * @param {object} itemData         Item data to migrate.
 * @param {object} [migrationData]  Additional data to perform the migration.
 * @param {object} [flags={}]       Track the needs migration flag.
 * @returns {object}                The updateData to apply.
 */
function migrateItemData(item, itemData, migrationData, flags={}) {
  const updateData = {};
  _migrateDocumentIcon(itemData, updateData, migrationData);
  _migrateItemUses(item, itemData, updateData, flags);

  // Migrate embedded effects
  if ( itemData.effects ) {
    const riders = foundry.utils.getProperty(itemData, "flags.dnd5e.riders.effect");
    if ( riders?.length ) updateData["flags.dnd5e.riders.effect"] = riders;
    const effects = migrateEffects(itemData, migrationData, updateData, flags);
    if ( riders?.length === updateData["flags.dnd5e.riders.effect"]?.length ) {
      delete updateData["flags.dnd5e.riders.effect"];
    }
    if ( effects.length > 0 ) updateData.effects = effects;
  }

  // Set source rules version to Legacy
  if ( foundry.utils.isNewerVersion("4.0.0", itemData._stats?.systemVersion) || flags.bypassVersionCheck ) {
    updateData["system.source.rules"] = "2014";
    if ( Object.hasOwn(item.system, "identifier") && !itemData.system?.identifier ) {
      updateData["system.identifier"] = item.identifier;
    }
  }

  // Migrate properties
  const migratedProperties = foundry.utils.getProperty(itemData, "flags.dnd5e.migratedProperties");
  if ( migratedProperties?.length ) {
    flags.persistSourceMigration = true;
    const properties = new Set(foundry.utils.getProperty(itemData, "system.properties") ?? [])
      .union(new Set(migratedProperties));
    updateData["system.properties"] = Array.from(properties);
    updateData["flags.dnd5e.-=migratedProperties"] = null;
  }

  if ( foundry.utils.getProperty(itemData, "flags.dnd5e.persistSourceMigration") ) {
    flags.persistSourceMigration = true;
    updateData["flags.dnd5e.-=persistSourceMigration"] = null;
  }

  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.
 * @param {object} [itemUpdateData]  Update data for the item to apply changes back to item.
 * @param {object} [flags={}]        Track the needs migration flag.
 * @returns {object[]}               Updates to apply on the embedded effects.
 */
function migrateEffects(parent, migrationData, itemUpdateData, flags={}) {
  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, { parent });
    if ( effectData.flags?.dnd5e?.rider ) {
      itemUpdateData["flags.dnd5e.riders.effect"] ??= [];
      itemUpdateData["flags.dnd5e.riders.effect"].push(effectData._id);
      effectUpdate["flags.dnd5e.-=rider"] = null;
    }
    if ( effectData.flags?.dnd5e?.persistSourceMigration ) {
      flags.persistSourceMigration = true;
      effectUpdate["flags.dnd5e.-=persistSourceMigration"] = null;
    }
    if ( !foundry.utils.isEmpty(effectUpdate) ) {
      effectUpdate._id = effectData._id;
      arr.push(foundry.utils.expandObject(effectUpdate));
    }
    return arr;
  }, []);
}

/* -------------------------------------------- */

/**
 * Migrates transfer effects on items belonging to this actor to "real" effects on the actor.
 * @param {object} actor                 The parent actor.
 * @param {object[]} effects             An array of new effects to add.
 * @param {object} [options]             Additional options.
 * @param {string} [options.actorUuid]   UUID of the parent actor
 */
const migrateCopyActorTransferEffects = function(actor, effects, { actorUuid }={}) {
  if ( !actor.items ) return;

  for ( const item of actor.items ) {
    for ( const effect of item.effects ) {
      if ( !effect.transfer ) continue;
      if ( !isSpellOrScroll(item) ) continue;
      if ( effect.disabled ) continue;

      const newEffect = foundry.utils.deepClone(effect);
      newEffect.transfer = false;
      if ( actorUuid ) newEffect.origin = `${actorUuid}.Item.${item._id}.ActiveEffect.${effect._id}`;
      delete newEffect._id;
      effects.push(newEffect);
    }
  }
};

/* -------------------------------------------- */

/**
 * Migrate the provided active effect data.
 * @param {object} effect            Effect data to migrate.
 * @param {object} [migrationData]   Additional data to perform the migration.
 * @param {object} [options]         Additional options.
 * @param {object} [options.parent]  Parent of this effect.
 * @returns {object}                 The updateData to apply.
 */
const migrateEffectData = function(effect, migrationData, { parent }={}) {
  const updateData = {};
  _migrateDocumentIcon(effect, updateData, {...migrationData, field: "img"});
  _migrateEffectArmorClass(effect, updateData);
  if ( foundry.utils.isNewerVersion("3.1.0", effect._stats?.systemVersion ?? parent?._stats?.systemVersion) ) {
    _migrateTransferEffect(effect, parent, updateData);
  }
  return updateData;
};

/* -------------------------------------------- */

/**
 * Migrate a single Macro document to incorporate latest data model changes.
 * @param {object} macro            Macro data to migrate
 * @param {object} [migrationData]  Additional data to perform the migration
 * @returns {object}                The updateData to apply
 */
const migrateMacroData = function(macro, migrationData) {
  const updateData = {};
  _migrateDocumentIcon(macro, updateData, migrationData);
  _migrateMacroCommands(macro, updateData);
  return updateData;
};

/* -------------------------------------------- */

/**
 * Migrate a single RollTable document to incorporate the latest data model changes.
 * @param {object} table            Roll table data to migrate.
 * @param {object} [migrationData]  Additional data to perform the migration.
 * @returns {object}                The update delta to apply.
 */
function migrateRollTableData(table, migrationData) {
  const updateData = {};
  _migrateDocumentIcon(table, updateData, migrationData);
  if ( !table.results?.length ) return updateData;
  const results = table.results.reduce((arr, result) => {
    const resultUpdate = {};
    _migrateDocumentIcon(result, resultUpdate, migrationData);
    if ( !foundry.utils.isEmpty(resultUpdate) ) {
      resultUpdate._id = result._id;
      arr.push(foundry.utils.expandObject(resultUpdate));
    }
    return arr;
  }, []);
  if ( results.length ) updateData.results = results;
  return updateData;
}

/* -------------------------------------------- */

/**
 * Migrate a single Scene document to incorporate changes to the data model of its 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.reduce((arr, token) => {
    const t = token instanceof foundry.abstract.DataModel ? token.toObject() : token;
    const update = {};
    _migrateTokenImage(t, update);
    if ( !game.actors.has(t.actorId) ) update.actorId = null;
    if ( !foundry.utils.isEmpty(update) ) arr.push({ ...update, _id: t._id });
    return arr;
  }, []);
  if ( tokens.length ) return { tokens };
  return {};
};

/* -------------------------------------------- */

/**
 * 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
/* -------------------------------------------- */

/**
 * Identify effects that might have been duplicated when legacyTransferral was disabled.
 * @param {object} parent   Data of the actor being migrated.
 * @returns {Set<string>}   IDs of effects to delete from the actor.
 * @private
 */
function _duplicatedEffects(parent) {
  const deleteIds = new Set();
  for ( const item of parent.items ) {
    for ( const effect of item.effects ?? [] ) {
      if ( !effect.transfer ) continue;
      const match = parent.effects.find(t => {
        const diff = foundry.utils.diffObject(t, effect);
        return t.origin?.endsWith(`Item.${item._id}`) && !("changes" in diff) && !deleteIds.has(t._id);
      });
      if ( match ) deleteIds.add(match._id);
    }
  }
  return deleteIds;
}

/* -------------------------------------------- */

/**
 * 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.evaluateSync();
    } catch( e ) {
      updateData["system.attributes.ac.formula"] = "";
    }
  }

  return updateData;
}

/* -------------------------------------------- */

/**
 * Migrate the actor flags that have been deprecated.
 * @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 _migrateActorFlags(actorData, updateData) {
  const initiativeAdv = foundry.utils.getProperty(actorData, "flags.dnd5e.initiativeAdv");
  if ( initiativeAdv ) {
    const key = "system.attributes.init.roll.mode";
    updateData[key] = Math.min(1, (foundry.utils.getProperty(actorData, key) ?? 0) + 1);
    updateData["flags.dnd5e.-=initiativeAdv"] = null;
  }
  return updateData;
}

/* -------------------------------------------- */

/**
 * Migrate the actor movement & senses to replace `0` with `null`.
 * @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 _migrateActorMovementSenses(actorData, updateData) {
  if ( actorData._stats?.systemVersion && foundry.utils.isNewerVersion("2.4.0", actorData._stats.systemVersion) ) {
    for ( const key of Object.keys(CONFIG.DND5E.movementTypes) ) {
      const keyPath = `system.attributes.movement.${key}`;
      if ( foundry.utils.getProperty(actorData, keyPath) === 0 ) updateData[keyPath] = null;
    }
    for ( const key of Object.keys(CONFIG.DND5E.senses) ) {
      const keyPath = `system.attributes.senses.${key}`;
      if ( foundry.utils.getProperty(actorData, keyPath) === 0 ) updateData[keyPath] = null;
    }
  }
  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;
}

/* -------------------------------------------- */

/**
 * Move `uses.value` to `uses.spent` for items.
 * @param {Item5e} item        Full item instance.
 * @param {object} itemData    Item data to migrate.
 * @param {object} updateData  Existing update to expand upon.
 * @param {object} flags       Track the needs migration flag.
 */
function _migrateItemUses(item, itemData, updateData, flags) {
  const value = foundry.utils.getProperty(itemData, "flags.dnd5e.migratedUses");
  const max = foundry.utils.getProperty(item, "system.uses.max");
  if ( (value !== undefined) && (max !== undefined) && Number.isNumeric(value) && Number.isNumeric(max) ) {
    foundry.utils.setProperty(updateData, "system.uses.spent", parseInt(max) - parseInt(value));
    flags.persistSourceMigration = true;
  }
  if ( value !== undefined ) updateData["flags.dnd5e.-=migratedUses"] = null;
}

/* -------------------------------------------- */

/**
 * Disable transfer on effects on spell items
 * @param {object} effect      Effect data to migrate.
 * @param {object} parent      The parent of this effect.
 * @param {object} updateData  Existing update to expand upon.
 * @returns {object}           The updateData to apply.
 */
function _migrateTransferEffect(effect, parent, updateData) {
  if ( !effect.transfer ) return updateData;
  if ( !isSpellOrScroll(parent) ) return updateData;

  updateData.transfer = false;
  updateData.disabled = true;
  updateData["duration.startTime"] = null;
  updateData["duration.startRound"] = null;
  updateData["duration.startTurn"] = null;

  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});
    log(`Purged flags from ${doc.name}`);
  }
  await pack.configure({locked: true});
}

/* -------------------------------------------- */

/**
 * Returns whether given item data represents either a spell item or a spell scroll consumable
 * @param {object} item  The item data.
 * @returns {boolean}
 */
function isSpellOrScroll(item) {
  if ( (item.type === "consumable") && (item.system.type.value === "scroll") ) return true;
  return item.type === "spell";
}

var migrations = /*#__PURE__*/Object.freeze({
  __proto__: null,
  getMigrationData: getMigrationData,
  migrateActorData: migrateActorData,
  migrateArmorClass: migrateArmorClass,
  migrateCompendium: migrateCompendium,
  migrateCopyActorTransferEffects: migrateCopyActorTransferEffects,
  migrateEffectData: migrateEffectData,
  migrateEffects: migrateEffects,
  migrateItemData: migrateItemData,
  migrateMacroData: migrateMacroData,
  migrateRollTableData: migrateRollTableData,
  migrateSceneData: migrateSceneData,
  migrateSettings: migrateSettings,
  migrateWorld: migrateWorld,
  purgeFlags: purgeFlags,
  refreshAllCompendiums: refreshAllCompendiums,
  refreshCompendium: refreshCompendium,
  reparentCompendiums: reparentCompendiums
});

/* -------------------------------------------- */
/*  Module Data                                 */
/* -------------------------------------------- */

/**
 * Scan module manifests for any data that should be integrated into the system configuration.
 */
function registerModuleData() {
  log("Registering Module Data", { level: "groupCollapsed" });
  for ( const manifest of [game.system, ...game.modules.filter(m => m.active), game.world] ) {
    try {
      const complete = registerMethods.map(m => m(manifest)).filter(r => r);
      if ( complete.length ) log(`Registered ${manifest.title} data: ${complete.join(", ")}`);
    } catch(err) {
      log(`Error registering ${manifest.title}\n`, { extras: [err.message], level: "error" });
    }
  }
  console.groupEnd();
}

const registerMethods = [registerSourceBooks, registerSpellLists];

/* -------------------------------------------- */

/**
 * Register package source books from `flags.dnd5e.sourceBooks`.
 * @param {Module|System|World} manifest  Manifest from which to register data.
 * @returns {string|void}                 Description of the data registered.
 */
function registerSourceBooks(manifest) {
  if ( !manifest.flags.dnd5e?.sourceBooks ) return;
  Object.assign(CONFIG.DND5E.sourceBooks, manifest.flags.dnd5e.sourceBooks);
  return "source books";
}

/* -------------------------------------------- */

/**
 * Register package spell lists from `flags.dnd5e.spellLists`.
 * @param {Module|System|World} manifest  Manifest from which to register data.
 * @returns {string|void}                 Description of the data registered.
 */
function registerSpellLists(manifest) {
  if ( foundry.utils.getType(manifest.flags.dnd5e?.spellLists) !== "Array" ) return;
  manifest.flags.dnd5e.spellLists.forEach(uuid => dnd5e.registry.spellLists.register(uuid));
  return "spell lists";
}

/* -------------------------------------------- */
/*  Compendium Packs                            */
/* -------------------------------------------- */

/**
 * Apply any changes to compendium packs during the setup hook.
 */
function setupModulePacks() {
  log("Setting Up Compendium Packs", { level: "groupCollapsed" });
  for ( const pack of game.packs ) {
    if ( pack.metadata.type === "Item" ) pack.applicationClass = ItemCompendium5e;
    try {
      const complete = setupMethods.map(m => m(pack)).filter(r => r);
      if ( complete.length ) log(`Finished setting up ${pack.metadata.label}: ${complete.join(", ")}`);
    } catch(err) {
      log(`Error setting up ${pack.title}\n`, { extras: [err.message], level: "error" });
    }
  }
  if ( sortingChanged ) game.settings.set("core", "collectionSortingModes", collectionSortingModes);
  console.groupEnd();
}

const setupMethods = [setupPackDisplay, setupPackSorting];

/* -------------------------------------------- */

/**
 * Set application based on `flags.dnd5e.display`.
 * @param {Compendium} pack  Pack to set up.
 * @returns {string|void}    Description of the step.
 */
function setupPackDisplay(pack) {
  const display = pack.metadata.flags.display ?? pack.metadata.flags.dnd5e?.display;
  if ( display !== "table-of-contents" ) return;
  pack.applicationClass = TableOfContentsCompendium;
  return "table of contents";
}

/* -------------------------------------------- */

let collectionSortingModes;
let sortingChanged = false;

/**
 * Set default sorting order based on `flags.dnd5e.sorting`.
 * @param {Compendium} pack  Pack to set up.
 * @returns {string|void}    Description of the step.
 */
function setupPackSorting(pack) {
  collectionSortingModes ??= game.settings.get("core", "collectionSortingModes") ?? {};
  if ( !pack.metadata.flags.dnd5e?.sorting || collectionSortingModes[pack.metadata.id] ) return;
  collectionSortingModes[pack.metadata.id] = pack.metadata.flags.dnd5e.sorting;
  sortingChanged = true;
  return "default sorting";
}

/* -------------------------------------------- */
/*  Redirects                                   */
/* -------------------------------------------- */

/**
 * Add compendium UUID redirects from core premium modules to SRD if module's aren't enabled.
 */
function registerModuleRedirects() {
  log("Registering Module Redirects", { level: "groupCollapsed" });
  for ( const [moduleId, redirects] of Object.entries(moduleRedirects) ) {
    if ( game.modules.get(moduleId)?.active ) {
      log(`Skipped redirects for ${moduleId}`);
    } else {
      log(`Registered redirects to SRD for ${moduleId}`);
      Object.assign(CONFIG.compendium.uuidRedirects, redirects);
    }
  }
  console.groupEnd();
}

const moduleRedirects = {
  "dnd-players-handbook": {
    "Compendium.dnd-players-handbook.actors": "Compendium.dnd5e.actors24",
    "Compendium.dnd-players-handbook.classes": "Compendium.dnd5e.classes24",
    "Compendium.dnd-players-handbook.content": "Compendium.dnd5e.content24",
    "Compendium.dnd-players-handbook.equipment": "Compendium.dnd5e.equipment24",
    "Compendium.dnd-players-handbook.feats": "Compendium.dnd5e.feats24",
    "Compendium.dnd-players-handbook.origins": "Compendium.dnd5e.origins24",
    "Compendium.dnd-players-handbook.spells": "Compendium.dnd5e.spells24",
    "Compendium.dnd-players-handbook.tables": "Compendium.dnd5e.tables24"
  },
  "dnd-dungeon-masters-guide": {
    "Compendium.dnd-dungeon-masters-guide.actors": "Compendium.dnd5e.actors24",
    "Compendium.dnd-dungeon-masters-guide.content": "Compendium.dnd5e.content24",
    "Compendium.dnd-dungeon-masters-guide.equipment": "Compendium.dnd5e.equipment24",
    "Compendium.dnd-dungeon-masters-guide.tables": "Compendium.dnd5e.tables24"
  },
  "dnd-monster-manual": {
    "Compendium.dnd-monster-manual.actors": "Compendium.dnd5e.actors24",
    "Compendium.dnd-monster-manual.content": "Compendium.dnd5e.content24",
    "Compendium.dnd-monster-manual.features": "Compendium.dnd5e.monsterfeatures24",
    "Compendium.dnd-monster-manual.tables": "Compendium.dnd5e.tables24"
  }
};

/**
 * @import { RegisteredItemData } from "./_types.mjs";
 */

/* -------------------------------------------- */
/*  Dependents                                  */
/* -------------------------------------------- */

class DependentsRegistry {
  /**
   * Registration of documents that are dependent on an active effect. The map is keyed by the UUID of
   * the active effect upon which the document is dependent and contains a set of UUIDs for that effect's
   * dependents. All UUIDs are expected to be world UUIDs or UUIDs of documents with the same ancestor
   * document as the effect they are dependent on.
   * @type {Map<string, Set<string>>}
   */
  static #dependents = new Map();

  /* -------------------------------------------- */

  /**
   * Fetch dependent documents for an active effect.
   * @param {ActiveEffect|string} effect  Active effect for which to get the dependent documents or UUID for an
   *                                      effect in the world.
   * @returns {Document[]}
   */
  static get(effect) {
    effect = effect instanceof ActiveEffect ? effect : fromUuidSync(effect);
    return Array.from(this.#dependents.get(effect?.uuid) ?? [])
      .map(uuid => {
        // TODO: Remove this special casing once https://github.com/foundryvtt/foundryvtt/issues/11214 is resolved
        if ( effect.parent.pack && uuid.includes(effect.parent.uuid) ) {
          const [, embeddedName, id] = uuid.replace(effect.parent.uuid, "").split(".");
          return effect.parent.getEmbeddedDocument(embeddedName, id);
        }
        return fromUuidSync(uuid, { strict: false });
      })
      .filter(_ => _);
  }

  /* -------------------------------------------- */

  /**
   * Resolve an active effect ID into an absolute UUID.
   * @param {string} idOrUuid      ID or UUID of active effect.
   * @param {Document} dependent   Document to track as a dependent.
   * @returns {string}
   */
  static #resolveDependentID(idOrUuid, dependent) {
    if ( idOrUuid.length > 16 ) return foundry.utils.parseUuid(idOrUuid, { relative: dependent })?.uuid;
    let relative = dependent.parent;
    if ( relative && !(relative instanceof Item) ) relative = relative.parent;
    return relative.effects.get(idOrUuid)?.uuid;
  }

  /* -------------------------------------------- */

  /**
   * Add a dependent document to the registry.
   * @param {string} idOrUuid      ID or UUID of active effect.
   * @param {Document} dependent   Document to track as a dependent.
   */
  static track(idOrUuid, dependent) {
    const uuid = DependentsRegistry.#resolveDependentID(idOrUuid, dependent);
    if ( !uuid ) return;
    if ( !DependentsRegistry.#dependents.has(uuid) ) DependentsRegistry.#dependents.set(uuid, new Set());
    DependentsRegistry.#dependents.get(uuid).add(dependent.uuid);
  }

  /* -------------------------------------------- */

  /**
   * Remove a dependent document from the registry.
   * @param {string} idOrUuid     ID or UUID of active effect.
   * @param {Document} dependent  Dependent document to stop tracking.
   */
  static untrack(idOrUuid, dependent) {
    const uuid = DependentsRegistry.#resolveDependentID(idOrUuid, dependent);
    DependentsRegistry.#dependents.get(uuid)?.delete(dependent.uuid);
  }
}

/* -------------------------------------------- */
/*  Enchantments                                */
/* -------------------------------------------- */

class EnchantmentRegisty {
  /**
   * Registration of enchanted items mapped to a specific enchantment source. The map is keyed by the UUID of
   * enchant activities while the set contains UUID of applied enchantment active effects.
   * @type {Map<string, Set<string>>}
   */
  static #appliedEnchantments = new Map();

  /* -------------------------------------------- */

  /**
   * Fetch the tracked enchanted items.
   * @param {string} uuid  UUID of an activity or item.
   * @returns {ActiveEffect5e[]}
   */
  static applied(uuid) {
    const source = fromUuidSync(uuid);
    if ( source instanceof Item ) {
      return source.system.activities?.getByType("enchant")
        .map(a => EnchantmentRegisty.applied(a.uuid))
        .flat() ?? [];
    }
    return Array.from(EnchantmentRegisty.#appliedEnchantments.get(uuid) ?? [])
      .map(uuid => fromUuidSync(uuid))
      .filter(effect => effect?.isAppliedEnchantment);
  }

  /* -------------------------------------------- */

  /**
   * Add a new enchantment effect to the list of tracked enchantments. Will not track enchanted items in compendiums.
   * @param {string} source     UUID of the active effect origin for the enchantment.
   * @param {string} enchanted  UUID of the enchantment to track.
   */
  static track(source, enchanted) {
    if ( enchanted.startsWith("Compendium.") ) return;
    if ( !EnchantmentRegisty.#appliedEnchantments.has(source) ) {
      EnchantmentRegisty.#appliedEnchantments.set(source, new Set());
    }
    EnchantmentRegisty.#appliedEnchantments.get(source).add(enchanted);
  }

  /* -------------------------------------------- */

  /**
   * Stop tracking an enchantment.
   * @param {string} source     UUID of the active effect origin for the enchantment.
   * @param {string} enchanted  UUID of the enchantment to stop tracking.
   */
  static untrack(source, enchanted) {
    EnchantmentRegisty.#appliedEnchantments.get(source)?.delete(enchanted);
  }
}

/* -------------------------------------------- */
/*  Item Registry                               */
/* -------------------------------------------- */

class ItemRegistry {
  constructor(itemsType) {
    this.#itemType = itemsType;
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Items grouped by identifiers.
   * @type {Map<string, RegisteredItemData>}
   */
  #items = new Map();

  /* -------------------------------------------- */

  /**
   * Type of item represented by this registry.
   * @type {string}
   */
  #itemType;

  /* -------------------------------------------- */

  /**
   * Has initial loading been completed?
   * @type {number}
   */
  #status = ItemRegistry.#STATUS_STATES.NONE;

  /**
   * Possible preparation states for the item registry.
   * @enum {number}
   */
  static #STATUS_STATES = Object.freeze({
    NONE: 0,
    LOADING: 1,
    READY: 2
  });

  /* -------------------------------------------- */

  /**
   * Choices object.
   * @type {Record<string, string>}
   */
  get choices() {
    return this.options.reduce((obj, { value, label }) => {
      obj[value] = label;
      return obj;
    }, {});
  }

  /* -------------------------------------------- */

  /**
   * All items formatted for a select input.
   * @type {FormSelectOption[]}
   */
  get options() {
    return Array.from(this.#items.entries())
      .map(([value, data]) => ({ value, label: data.name }))
      .sort((lhs, rhs) => lhs.label.localeCompare(rhs.label, game.i18n.lang));
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Get information on a single item based on its identifier.
   * @param {string} identifier
   * @returns {RegisteredItemData|void}
   */
  get(identifier) {
    return this.#items.get(identifier);
  }

  /* -------------------------------------------- */

  /**
   * Scan compendium packs to register matching items of this type.
   */
  async initialize() {
    if ( this.#status > ItemRegistry.#STATUS_STATES.NONE ) return;
    RegistryStatus.set(this.#itemType, false);
    if ( game.modules.get("babele")?.active && (game.babele?.initialized === false) ) {
      Hooks.once("babele.ready", () => this.initialize());
      return;
    } else if ( !game.ready ) {
      Hooks.once("ready", () => this.initialize());
      return;
    }
    this.#status = ItemRegistry.#STATUS_STATES.LOADING;

    const indexes = await CompendiumBrowser.fetch(Item, {
      types: new Set([this.#itemType]),
      indexFields: new Set(["system.identifier", "system.source"]),
      sort: false
    });
    for ( const item of indexes ) {
      const identifier = item.system?.identifier ?? formatIdentifier(item.name);
      if ( !this.#items.has(identifier) ) this.#items.set(identifier, { sources: [] });
      const itemData = this.#items.get(identifier);
      itemData.name = item.name;
      itemData.img = item.img;
      itemData.identifier = identifier;
      itemData.sources.push(item.uuid);
    }

    this.#status = ItemRegistry.#STATUS_STATES.READY;
    RegistryStatus.set(this.#itemType, true);
  }
}

/* -------------------------------------------- */
/*  Message Rolls                               */
/* -------------------------------------------- */

class MessageRegistry {
  /**
   * Registration of roll chat messages that originated at a specific message. The map is keyed by the ID of
   * the originating message and contains sets of IDs for each roll type.
   * @type {Map<string, Map<string, Set<string>>}
   */
  static #messages = new Map();

  /* -------------------------------------------- */

  /**
   * Fetch roll messages for an origin message, in chronological order.
   * @param {string} origin  ID of the origin message.
   * @param {string} [type]  Type of roll messages to fetch.
   * @returns {ChatMessage5e[]}
   */
  static get(origin, type) {
    const originMap = MessageRegistry.#messages.get(origin);
    if ( !originMap ) return [];
    let ids;
    if ( type ) ids = Array.from(originMap.get(type) ?? []);
    else ids = Array.from(originMap.values()).map(v => Array.from(v)).flat();
    return ids
      .map(id => game.messages.get(id))
      .filter(m => m)
      .sort((lhs, rhs) => lhs.timestamp - rhs.timestamp);
  }

  /* -------------------------------------------- */

  /**
   * Add a new roll message to the registry.
   * @param {ChatMessage5e} message  Message to add to the registry.
   */
  static track(message) {
    const origin = message.getFlag("dnd5e", "originatingMessage");
    const type = message.getFlag("dnd5e", "roll.type");
    if ( !origin || !type ) return;
    if ( !MessageRegistry.#messages.has(origin) ) MessageRegistry.#messages.set(origin, new Map());
    const originMap = MessageRegistry.#messages.get(origin);
    if ( !originMap.has(type) ) originMap.set(type, new Set());
    originMap.get(type).add(message.id);
  }

  /* -------------------------------------------- */

  /**
   * Remove a roll message to the registry.
   * @param {ChatMessage5e} message  Message to remove from the registry.
   */
  static untrack(message) {
    const origin = message.getFlag("dnd5e", "originatingMessage");
    const type = message.getFlag("dnd5e", "roll.type");
    MessageRegistry.#messages.get(origin)?.get(type)?.delete(message.id);
  }
}

/* -------------------------------------------- */
/*  Spell Lists                                 */
/* -------------------------------------------- */

class SpellListRegistry {
  /**
   * Spell lists organized by the UUID of a spell they contain.
   * @type {Map<string, Set<SpellList>>}
   */
  static #bySpell = new Map();

  /* -------------------------------------------- */

  /**
   * Registration of spell lists grouped by type and identifier.
   * @type {Map<string, Map<string, SpellList>>}
   */
  static #byType = new Map();

  /* -------------------------------------------- */

  /**
   * IDs of compendiums that have been re-indexed during loading.
   * @type {Set<string>}
   */
  static #compendiumsIndexed = new Set();

  /* -------------------------------------------- */

  /**
   * UUIDs of spell lists or IDs of compendiums in the process of being loaded.
   * @type {Set<string>}
   */
  static #loading = new Set();

  /* -------------------------------------------- */

  /**
   * Options for each registered spell list, grouped by type.
   * @type {FormSelectOption[]}
   */
  static get options() {
    return Object.entries(CONFIG.DND5E.spellListTypes).map(([type, group]) => {
      const lists = this.#byType.get(type);
      if ( !lists ) return [];
      return Array.from(lists.entries())
        .map(([value, list]) => ({ value: `${type}:${value}`, label: list.name, group, type }))
        .sort((lhs, rhs) => lhs.label.localeCompare(rhs.label, game.i18n.lang));
    }).flat();
  }

  /* -------------------------------------------- */

  /**
   * Have spell lists finished loading?
   * @type {boolean}
   */
  static get ready() {
    return this.#loading.size === 0;
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Retrieve a list of spell lists a spell belongs to.
   * @param {string} uuid  UUID of a spell item.
   * @returns {Set<SpellList>}
   */
  static forSpell(uuid) {
    return SpellListRegistry.#bySpell.get(uuid) ?? new Set();
  }

  /* -------------------------------------------- */

  /**
   * Retrieve a specific spell list from the registry.
   * @param {string} type          Type of list as defined in `CONFIG.DND5E.spellListTypes`. Can also be a combination
   *                               of the type and identifier split by a colon (e.g. `class:bard`).
   * @param {string} [identifier]  Identifier of the specific spell list.
   * @returns {SpellList|null}
   */
  static forType(type, identifier) {
    if ( type.includes(":") && !identifier ) [type, identifier] = type.split(":", 2);
    return SpellListRegistry.#byType.get(type)?.get(identifier) ?? null;
  }

  /* -------------------------------------------- */

  /**
   * Register a spell list journal entry page.
   * @param {string} uuid  UUID of a spell list journal entry page.
   */
  static async register(uuid) {
    RegistryStatus.set("spellLists", false);
    this.#loading.add(uuid);
    if ( !game.ready ) {
      Hooks.once("ready", () => this.register(uuid));
      return;
    }

    const page = await fromUuid(uuid);
    if ( !page ) throw new Error(`Journal entry page "${uuid}" could not be found to register as spell list.`);
    if ( page.type !== "spells" ) throw new Error(`Journal entry page "${uuid}" is not a Spell List.`);

    if ( !SpellListRegistry.#byType.has(page.system.type) ) SpellListRegistry.#byType.set(page.system.type, new Map());

    const type = SpellListRegistry.#byType.get(page.system.type);
    if ( !type.has(page.system.identifier) ) type.set(page.system.identifier, new SpellList({
      identifier: page.system.identifier, name: page.name, type: page.system.type
    }));

    const list = type.get(page.system.identifier);
    await Promise.all(Array.from(list.contribute(page)).map(async uuid => {
      if ( !SpellListRegistry.#bySpell.has(uuid) ) SpellListRegistry.#bySpell.set(uuid, new Set());
      SpellListRegistry.#bySpell.get(uuid).add(list);
      const { collection } = foundry.utils.parseUuid(uuid);
      if ( (collection instanceof foundry.documents.collections.CompendiumCollection)
        && !this.#compendiumsIndexed.has(collection.metadata.id) ) {
        this.#compendiumsIndexed.add(collection.metadata.id);
        this.#loading.add(collection.metadata.id);
        await collection.getIndex();
        this.#loading.delete(collection.metadata.id);
      }
    }));

    this.#loading.delete(uuid);
    if ( this.ready ) RegistryStatus.set("spellLists", true);
  }
}

/**
 * Type that represents a unified spell list for a specific class, subclass, species, or something else.
 */
class SpellList {
  constructor(metadata) {
    this.#metadata = Object.freeze(metadata);
  }

  /* -------------------------------------------- */

  /**
   * Mapping of spell list types to item registries.
   * @enum {string}
   */
  static #REGISTRIES = {
    class: "classes",
    subclass: "subclasses"
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Identifiers for all the available & unlinked spells in this list.
   * @type {Set<string>}
   */
  get identifiers() {
    return new Set([
      ...this.indexes.map(s => s.system?.identifier),
      ...this.#unlinked.map(u => u.identifier)
    ].filter(_ => _));
  }

  /* -------------------------------------------- */

  /**
   * Indexes for the available spells sorted by name.
   * @type {object[]}
   */
  get indexes() {
    return Array.from(this.#spells.keys())
      .map(s => fromUuidSync(s))
      .filter(_ => _)
      .sort((lhs, rhs) => lhs.name.localeCompare(rhs.name, game.i18n.lang));
  }

  /* -------------------------------------------- */

  /**
   * Information on the spell list.
   * @type {{ identifier: string, name: string, type: string }}
   */
  #metadata;

  get metadata() {
    return this.#metadata;
  }

  /* -------------------------------------------- */

  /**
   * Display name for the spell list.
   * @type {string}
   */
  get name() {
    return dnd5e.registry[SpellList.#REGISTRIES[this.metadata.type]]?.get(this.metadata.identifier)?.name
      ?? this.metadata.name;
  }

  /* -------------------------------------------- */

  /**
   * @typedef {SpellData}
   * @property {string} page  UUID of the original page.
   */

  /**
   * Spells represented by this spell list.
   * @type {Map<string, SpellList.SpellData>}
   */
  #spells = new Map();

  /* -------------------------------------------- */

  /**
   * Unlinked spell definitions.
   * @type {UnlinkedSpellConfiguration[]}
   */
  #unlinked = [];

  /* -------------------------------------------- */

  /**
   * UUIDs of all of the spells in this list.
   * @type {Set<string>}
   */
  get uuids() {
    return new Set(this.#spells.keys());
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Add a spell list page to this unified spell list.
   * @param {JournalEntryPage} page  Spells page to contribute.
   * @returns {Set<string>}          Newly added UUIDs.
   */
  contribute(page) {
    const added = new Set();

    page.system.spells.forEach(s => {
      if ( !this.#spells.has(s) ) added.add(s);
      this.#spells.set(s, { page: page.uuid });
    });

    for ( const unlinked of page.system.unlinkedSpells ) {
      if ( fromUuidSync(unlinked.source?.uuid) ) {
        if ( !this.#spells.has(unlinked.source.uuid) ) added.add(unlinked.source.uuid);
        this.#spells.set(unlinked.source.uuid, { page: page.uuid });
      } else {
        this.#unlinked.push(foundry.utils.mergeObject({ page: page.uuid }, unlinked));
      }
    }

    return added;
  }

  /* -------------------------------------------- */

  /**
   * Determine whether the provided spell is included in the list.
   * @param {Item5e|string} spell  Spell item or a compendium UUID.
   * @returns {boolean}
   */
  has(spell) {
    if ( spell instanceof Item ) spell = spell._stats?.compendiumSource ?? spell.uuid;
    return this.#spells.has(spell);
  }

  /* -------------------------------------------- */

  /**
   * All of the spells represented by this list.
   * @returns {Promise<Item5e>}
   */
  async getSpells() {
    return Promise.all(Array.from(this.#spells.keys()).map(s => fromUuid(s)));
  }
}

/* -------------------------------------------- */
/*  Summons                                     */
/* -------------------------------------------- */

class SummonRegistry {
  /**
   * Registration of summoned creatures mapped to a specific summoner. The map is keyed by the UUID of
   * summoner while the set contains UUID of actors that have been summoned.
   * @type {Map<string, Set<string>>}
   */
  static #creatures = new Map();

  /* -------------------------------------------- */

  /**
   * Fetch creatures summoned by an actor.
   * @param {Actor5e} actor  Actor for which to find the summoned creatures.
   * @returns {Actor5e[]}
   */
  static creatures(actor) {
    return Array.from(SummonRegistry.#creatures.get(actor.uuid) ?? []).map(uuid => fromUuidSync(uuid));
  }

  /* -------------------------------------------- */

  /**
   * Add a new summoned creature to the list of summoned creatures.
   * @param {string} summoner  UUID of the actor who performed the summoning.
   * @param {string} summoned  UUID of the summoned creature to track.
   */
  static track(summoner, summoned) {
    if ( summoned.startsWith("Compendium.") ) return;
    if ( !SummonRegistry.#creatures.has(summoner) ) {
      SummonRegistry.#creatures.set(summoner, new Set());
    }
    SummonRegistry.#creatures.get(summoner).add(summoned);
  }

  /* -------------------------------------------- */

  /**
   * Stop tracking a summoned creature.
   * @param {string} summoner  UUID of the actor who performed the summoning.
   * @param {string} summoned  UUID of the summoned creature to stop tracking.
   */
  static untrack(summoner, summoned) {
    SummonRegistry.#creatures.get(summoner)?.delete(summoned);
  }
}

/* -------------------------------------------- */
/*  Ready API                                   */
/* -------------------------------------------- */

/**
 * Track the ready status of various registries.
 * @type {Map<string, boolean>}
 */
const RegistryStatus = new class extends Map {
  constructor(iterable) {
    super(iterable);
    const { promise, resolve } = Promise.withResolvers();
    this.#ready = promise;
    this.#resolve = resolve;
  }

  /* -------------------------------------------- */

  /**
   * Promise that resolves when the registry is ready.
   * @type {Promise}
   */
  #ready;

  /* -------------------------------------------- */

  /**
   * Promise that resolves when all registries are ready.
   * @returns {Promise}
   */
  get ready() {
    return this.#ready;
  }

  /* -------------------------------------------- */

  /**
   * Internal method called when registry is ready.
   * @type {Function}
   */
  #resolve;

  /* -------------------------------------------- */

  /** @inheritDoc */
  set(key, value) {
    super.set(key, value);
    if ( Array.from(this.values()).every(s => s) ) this.#resolve();
    return this;
  }
}();

/* -------------------------------------------- */

var registry = {
  classes: new ItemRegistry("class"),
  dependents: DependentsRegistry,
  enchantments: EnchantmentRegisty,
  messages: MessageRegistry,
  ready: RegistryStatus.ready,
  spellLists: SpellListRegistry,
  subclasses: new ItemRegistry("subclass"),
  summons: SummonRegistry
};

const TooltipManager = foundry.helpers.interaction.TooltipManager.implementation;

/**
 * A class responsible for orchestrating tooltips in the system.
 */
class Tooltips5e {
  /* -------------------------------------------- */
  /*  Properties & Getters                        */
  /* -------------------------------------------- */

  /**
   * The currently registered observer.
   * @type {MutationObserver}
   */
  #observer;

  /**
   * The tooltip element.
   * @type {HTMLElement}
   */
  get tooltip() {
    return this.#tooltip;
  }

  #tooltip = document.getElementById("tooltip");

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Initialize the mutation observer.
   */
  observe() {
    this.#observer?.disconnect();
    this.#observer = new MutationObserver(this._onMutation.bind(this));
    this.#observer.observe(this.tooltip, { attributeFilter: ["class"], attributeOldValue: true });
  }

  /* -------------------------------------------- */

  /**
   * Handle a mutation event.
   * @param {MutationRecord[]} mutationList  The list of changes.
   * @protected
   */
  _onMutation(mutationList) {
    let isActive = false;
    const tooltip = this.tooltip;
    for ( const { type, attributeName, oldValue } of mutationList ) {
      if ( (type === "attributes") && (attributeName === "class") ) {
        const difference = new Set(tooltip.classList).difference(new Set(oldValue?.split(" ")));
        if ( difference.has("active") ) isActive = true;
      }
    }
    if ( isActive ) this._onTooltipActivate();
  }

  /* -------------------------------------------- */

  /**
   * Handle tooltip activation.
   * @protected
   * @returns {Promise}
   */
  async _onTooltipActivate() {
    // General content links
    if ( game.tooltip.element?.classList.contains("content-link") ) {
      const doc = await fromUuid(game.tooltip.element.dataset.uuid);
      return this._onHoverContentLink(doc);
    }

    const loading = this.tooltip.querySelector(".loading");

    // Sheet-specific tooltips
    if ( loading?.dataset.uuid ) {
      const doc = await fromUuid(loading.dataset.uuid);
      if ( doc instanceof dnd5e.documents.Actor5e ) return this._onHoverActor(doc);
      return this._onHoverContentLink(doc);
    }

    // Passive checks
    else if ( loading?.dataset.passive !== undefined ) {
      const dataset = game.tooltip.element?.dataset ?? {};
      switch ( dataset.type ) {
        case "language":
          return this._onHoverPassiveLanguage(dataset.language);
        default:
          const { skill, ability, dc } = dataset;
          return this._onHoverPassiveCheck(skill, ability, dc);
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle hovering some part of an actor's sheet.
   * @param {Actor5e} actor  The actor.
   * @protected
   */
  async _onHoverActor(actor) {
    const { attribution, attributionCaption } = game.tooltip.element.dataset;
    if ( !attribution ) return;
    this.tooltip.innerHTML = await actor.getAttributionData(attribution, { title: attributionCaption });
  }

  /* -------------------------------------------- */

  /**
   * Handle hovering over a content link and showing rich tooltips if possible.
   * @param {Document} doc  The document linked by the content link.
   * @protected
   */
  async _onHoverContentLink(doc) {
    const { content, classes } = await (doc.richTooltip?.() ?? doc.system?.richTooltip?.() ?? {});
    if ( !content ) return;
    this.tooltip.innerHTML = content;
    this.tooltip.classList.remove("theme-dark");
    if ( classes?.length ) this.tooltip.classList.add(...classes);
    const { tooltipDirection } = game.tooltip.element.dataset;
    requestAnimationFrame(() => this._positionItemTooltip(tooltipDirection));
  }

  /* -------------------------------------------- */

  /**
   * Handle hovering a passive language link to display results for primary party.
   * @param {string} language  Language to display proficiency in.
   * @protected
   */
  async _onHoverPassiveLanguage(language) {
    const label = CONFIG.DND5E.enrichmentLookup.languages[language];
    this._onHoverPassive({ label }, actor => {
      if ( !actor.system.traits?.languages?.value ) return false;
      return { status: actor.system.traits.languages.value.has(language) ? "success" : "failure" };
    });
  }

  /* -------------------------------------------- */

  /**
   * Handle hovering a passive skill or ability check link to display results for primary party.
   * Either skill or ability (or both) must be provided.
   * @param {string} [skill]     Passive skill key. If undefined, this will be a passive ability check.
   * @param {string} [ability]   Passive ability key. If undefined, the skill's default ability is used.
   * @param {number} [dc]        DC against which to compare party values.
   * @protected
   */
  async _onHoverPassiveCheck(skill, ability, dc) {
    const skillConfig = CONFIG.DND5E.skills[skill];
    const abilityConfig = CONFIG.DND5E.abilities[ability ?? skillConfig.ability];

    let label;
    if ( skillConfig ) {
      label = game.i18n.format("DND5E.SkillPassiveSpecificHint", { skill: skillConfig.label, ability: abilityConfig.label });
    } else {
      // If no skill was provided, we're doing a passive ability check.
      // This isn't technically a thing in the rules, but we can support it anyway if people want to use it.
      label = game.i18n.format("DND5E.SkillPassiveHint", { skill: abilityConfig.label });
    }

    this._onHoverPassive({ label }, actor => {
      const systemData = actor.system;
      let passive;
      if ( skill && (!ability || (ability === skillConfig.ability)) ) {
        // Default passive skill check
        passive = systemData.skills?.[skill]?.passive;
      } else if ( skill ) {
        // Passive ability check with custom ability
        const customSkillData = actor._prepareSkill(skill, { ability });
        passive = customSkillData.passive;
      } else {
        // Passive ability check
        const abilityMod = systemData.abilities?.[ability]?.mod;
        if ( abilityMod !== undefined ) passive = 10 + abilityMod;
      }
      if ( passive === undefined ) return false;
      return {
        passive, status: dc !== undefined ? passive >= dc ? "success" : "failure" : ""
      };
    });
  }

  /* -------------------------------------------- */

  /**
   * @callback PassiveMemberCallback
   * @param {Actor5e} actor                The actor instance.
   * @returns {{ passive, status }|false}  Data used to render the member's entry or `false` to exclude actor.
   */

  /**
   * Handle hovering a passive tooltip to display results for primary party.
   * @param {object} context
   * @param {string} context.label                  Header or fallback label if no party is defined.
   * @param {PassiveMemberCallback} memberCallback  Method called for each member to get additional context information.
   * @protected
   */
  async _onHoverPassive({ label }, memberCallback) {
    const { party } = game.actors;
    if ( !party ) {
      this.tooltip.innerHTML = label;
      return;
    }

    const context = { label, party: [] };
    for ( const member of party.system.members ) {
      if ( !member.actor ) continue;
      const ctx = memberCallback(member.actor);
      if ( ctx ) context.party.push({ name: member.actor.name, img: member.actor.img, ...ctx });
    }

    this.tooltip.classList.add("dnd5e-tooltip", "passive-tooltip", "dnd5e2", "themed", "theme-light");
    this.tooltip.classList.remove("theme-dark");
    this.tooltip.innerHTML = await foundry.applications.handlebars.renderTemplate(
      "systems/dnd5e/templates/journal/passive-tooltip.hbs", context
    );
    game.tooltip._setAnchor(TooltipManager.TOOLTIP_DIRECTIONS.DOWN);
  }

  /* -------------------------------------------- */

  /**
   * Position a tooltip after rendering.
   * @param {string} [direction]  The direction to position the tooltip.
   * @protected
   */
  _positionItemTooltip(direction) {
    if ( !direction ) {
      direction = TooltipManager.TOOLTIP_DIRECTIONS.LEFT;
      game.tooltip._setAnchor(direction);
    }

    const pos = this.tooltip.getBoundingClientRect();
    const dirs = TooltipManager.TOOLTIP_DIRECTIONS;
    const { innerHeight, innerWidth } = this.tooltip.ownerDocument.defaultView;
    switch ( direction ) {
      case dirs.UP:
        if ( pos.y - TooltipManager.TOOLTIP_MARGIN_PX <= 0 ) direction = dirs.DOWN;
        break;
      case dirs.DOWN:
        if ( pos.y + this.tooltip.offsetHeight > innerHeight ) direction = dirs.UP;
        break;
      case dirs.LEFT:
        if ( pos.x - TooltipManager.TOOLTIP_MARGIN_PX <= 0 ) direction = dirs.RIGHT;
        break;
      case dirs.RIGHT:
        if ( pos.x + this.tooltip.offsetWidth > innerWidth ) direction = dirs.LEFT;
        break;
    }

    game.tooltip._setAnchor(direction);

    // Set overflowing styles for item tooltips.
    if ( this.tooltip.classList.contains("item-tooltip") ) {
      const description = this.tooltip.querySelector(".description");
      description?.classList.toggle("overflowing", description.clientHeight < description.scrollHeight);
    }
  }

  /* -------------------------------------------- */
  /*  Static Helpers                              */
  /* -------------------------------------------- */

  /**
   * Intercept middle-click listeners to prevent scrolling behavior inside a locked tooltip when attempting to lock
   * another tooltip.
   */
  static activateListeners() {
    document.addEventListener("pointerdown", event => {
      if ( (event.button === 1) && event.target.closest(".locked-tooltip") ) {
        event.preventDefault();
      }
    }, { capture: true });
  }
}

/**
 * The D&D fifth edition 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
 *                  https://media.dndbeyond.com/compendium-images/srd/5.2/SRD_CC_v5.2.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,
  enrichers,
  Filter,
  migrations,
  registry,
  ui: {},
  utils
};

/* -------------------------------------------- */
/*  Foundry VTT Initialization                  */
/* -------------------------------------------- */

Hooks.once("init", function() {
  globalThis.dnd5e = game.dnd5e = Object.assign(game.system, globalThis.dnd5e);
  log(`Initializing the D&D Fifth Game System - Version ${dnd5e.version}\n${DND5E.ASCII}`);

  // Record Configuration Values
  CONFIG.DND5E = DND5E;
  CONFIG.ActiveEffect.documentClass = ActiveEffect5e;
  CONFIG.ActiveEffect.legacyTransferral = false;
  CONFIG.Actor.collection = Actors5e;
  CONFIG.Actor.documentClass = Actor5e;
  CONFIG.Adventure.documentClass = Adventure5e;
  CONFIG.Canvas.layers.tokens.layerClass = CONFIG.Token.layerClass = TokenLayer5e;
  CONFIG.ChatMessage.documentClass = ChatMessage5e;
  CONFIG.Combat.documentClass = Combat5e;
  CONFIG.Combatant.documentClass = Combatant5e;
  CONFIG.CombatantGroup.documentClass = CombatantGroup5e;
  CONFIG.Item.collection = Items5e;
  CONFIG.Item.compendiumIndexFields.push("system.container", "system.identifier");
  CONFIG.Item.documentClass = Item5e;
  CONFIG.JournalEntryPage.documentClass = JournalEntryPage5e;
  CONFIG.Token.documentClass = TokenDocument5e;
  CONFIG.Token.objectClass = Token5e;
  CONFIG.Token.rulerClass = TokenRuler5e;
  CONFIG.Token.movement.TerrainData = TerrainData5e;
  CONFIG.User.documentClass = User5e;
  CONFIG.time.roundTime = 6;
  Roll.TOOLTIP_TEMPLATE = "systems/dnd5e/templates/chat/roll-breakdown.hbs";
  CONFIG.Dice.BasicRoll = BasicRoll;
  CONFIG.Dice.DamageRoll = DamageRoll;
  CONFIG.Dice.D20Die = D20Die;
  CONFIG.Dice.D20Roll = D20Roll;
  CONFIG.MeasuredTemplate.defaults.angle = 53.13; // 5e cone RAW should be 53.13 degrees
  CONFIG.Note.objectClass = Note5e;
  CONFIG.ui.chat = ChatLog5e;
  CONFIG.ui.combat = CombatTracker5e;
  CONFIG.ui.items = ItemDirectory5e;
  CONFIG.ux.DragDrop = DragDrop5e;

  // Register System Settings
  registerSystemSettings();
  registerSystemKeybindings();

  // Configure module art
  game.dnd5e.moduleArt = new ModuleArt();

  // Configure bastions
  game.dnd5e.bastion = new Bastion();

  // Configure tooltips
  game.dnd5e.tooltips = new Tooltips5e();

  // 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;

  // Legacy rules.
  if ( game.settings.get("dnd5e", "rulesVersion") === "legacy" ) applyLegacyRules();

  // Register system
  DND5E.SPELL_LISTS.forEach(uuid => dnd5e.registry.spellLists.register(uuid));

  // Register module data from manifests
  registerModuleData();
  registerModuleRedirects();

  // Register Roll Extensions
  CONFIG.Dice.rolls = [BasicRoll, D20Roll, DamageRoll];

  // Hook up system data types
  CONFIG.ActiveEffect.dataModels = config$5;
  CONFIG.Actor.dataModels = config$4;
  CONFIG.ChatMessage.dataModels = config$3;
  CONFIG.Item.dataModels = config$2;
  CONFIG.JournalEntryPage.dataModels = config$1;
  Object.assign(CONFIG.RegionBehavior.dataModels, config);
  Object.assign(CONFIG.RegionBehavior.typeIcons, icons);

  // Add fonts
  _configureFonts();

  // Register sheet application classes
  const DocumentSheetConfig = foundry.applications.apps.DocumentSheetConfig;
  DocumentSheetConfig.unregisterSheet(Actor, "core", foundry.appv1.sheets.ActorSheet);
  DocumentSheetConfig.registerSheet(Actor, "dnd5e", CharacterActorSheet, {
    types: ["character"],
    makeDefault: true,
    label: "DND5E.SheetClass.Character"
  });
  DocumentSheetConfig.registerSheet(Actor, "dnd5e", NPCActorSheet, {
    types: ["npc"],
    makeDefault: true,
    label: "DND5E.SheetClass.NPC"
  });
  DocumentSheetConfig.registerSheet(Actor, "dnd5e", VehicleActorSheet, {
    types: ["vehicle"],
    makeDefault: true,
    label: "DND5E.SheetClass.Vehicle"
  });
  DocumentSheetConfig.registerSheet(Actor, "dnd5e", GroupActorSheet, {
    types: ["group"],
    makeDefault: true,
    label: "DND5E.SheetClass.Group"
  });
  DocumentSheetConfig.registerSheet(Actor, "dnd5e", EncounterActorSheet, {
    types: ["encounter"],
    makeDefault: true,
    label: "DND5E.SheetClass.Encounter"
  });

  DocumentSheetConfig.unregisterSheet(Item, "core", foundry.appv1.sheets.ItemSheet);
  DocumentSheetConfig.registerSheet(Item, "dnd5e", ItemSheet5e, {
    makeDefault: true,
    label: "DND5E.SheetClass.Item"
  });
  DocumentSheetConfig.unregisterSheet(Item, "dnd5e", ItemSheet5e, { types: ["container"] });
  DocumentSheetConfig.registerSheet(Item, "dnd5e", ContainerSheet, {
    makeDefault: true,
    types: ["container"],
    label: "DND5E.SheetClass.Container"
  });

  DocumentSheetConfig.registerSheet(JournalEntry, "dnd5e", JournalEntrySheet5e, {
    makeDefault: true,
    label: "DND5E.SheetClass.JournalEntry"
  });
  DocumentSheetConfig.registerSheet(JournalEntry, "dnd5e", JournalSheet5e, {
    makeDefault: false,
    canConfigure: false,
    canBeDefault: false,
    label: "DND5E.SheetClass.JournalEntrySheetLegacy"
  });
  DocumentSheetConfig.registerSheet(JournalEntryPage, "dnd5e", JournalClassPageSheet, {
    label: "DND5E.SheetClass.ClassSummary",
    types: ["class", "subclass"]
  });
  DocumentSheetConfig.registerSheet(JournalEntryPage, "dnd5e", JournalMapLocationPageSheet, {
    label: "DND5E.SheetClass.MapLocation",
    types: ["map"]
  });
  DocumentSheetConfig.registerSheet(JournalEntryPage, "dnd5e", JournalRulePageSheet, {
    label: "DND5E.SheetClass.Rule",
    types: ["rule"]
  });
  DocumentSheetConfig.registerSheet(JournalEntryPage, "dnd5e", JournalSpellListPageSheet, {
    label: "DND5E.SheetClass.SpellList",
    types: ["spells"]
  });

  DocumentSheetConfig.unregisterSheet(RegionBehavior, "core", foundry.applications.sheets.RegionBehaviorConfig, {
    types: ["dnd5e.difficultTerrain", "dnd5e.rotateArea"]
  });
  DocumentSheetConfig.registerSheet(RegionBehavior, "dnd5e", DifficultTerrainConfig, {
    label: "DND5E.SheetClass.DifficultTerrain",
    types: ["dnd5e.difficultTerrain"]
  });
  DocumentSheetConfig.registerSheet(RegionBehavior, "dnd5e", RotateAreaConfig, {
    label: "DND5E.SheetClass.RotateArea",
    types: ["dnd5e.rotateArea"]
  });

  CONFIG.Token.prototypeSheetClass = PrototypeTokenConfig5e;
  DocumentSheetConfig.unregisterSheet(TokenDocument, "core", foundry.applications.sheets.TokenConfig);
  DocumentSheetConfig.registerSheet(TokenDocument, "dnd5e", TokenConfig5e, {
    label: "DND5E.SheetClass.Token"
  });

  // Preload Handlebars helpers & partials
  registerHandlebarsHelpers();
  preloadHandlebarsTemplates();

  // Enrichers
  registerCustomEnrichers();

  // Exhaustion handling
  ActiveEffect5e.registerHUDListeners();

  // Set up token movement actions
  TokenDocument5e.registerMovementActions();

  // Custom movement cost aggregator
  CONFIG.Token.movement.costAggregator = (results, distance, segment) => {
    return Math.max(...results.map(i => i.cost));
  };

  // Setup Calendar
  _configureCalendar();
});

/* -------------------------------------------- */

/**
 * Configure world calendar based on setting.
 */
function _configureCalendar() {
  CONFIG.time.earthCalendarClass = CalendarData5e;
  CONFIG.time.worldCalendarClass = CalendarData5e;

  /**
   * A hook event that fires during the `init` step to give modules a chance to customize the calendar
   * configuration before loading the world calendar.
   * @function dnd5e.preSetupCalendar
   * @memberof hookEvents
   * @returns               Explicitly return `false` to prevent system from setting up the calendar.
   */
  if ( Hooks.call("dnd5e.setupCalendar") === false ) return;

  const calendar = game.settings.get("dnd5e", "calendar");
  const calendarConfig = CONFIG.DND5E.calendar.calendars.find(c => c.value === calendar);
  if ( calendarConfig ) {
    CONFIG.time.worldCalendarConfig = calendarConfig.config;
    if ( calendarConfig.class ) CONFIG.time.worldCalendarClass = calendarConfig.class;
  }
}

/* -------------------------------------------- */

/**
 * 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",
      ..._trackedSpellAttributes()
    ],
    value: [
      ...common.value,
      ...Object.keys(DND5E.skills).map(skill => `skills.${skill}.passive`),
      ...Object.keys(DND5E.senses).map(sense => `attributes.senses.${sense}`),
      "attributes.hp.temp", "attributes.spell.attack", "attributes.spell.dc"
    ]
  };

  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, "attributes.spell.level", "details.cr", "details.xp.value"]
    },
    vehicle: {
      bar: [...common.bar, "attributes.hp"],
      value: [...common.value]
    },
    group: {
      bar: [],
      value: []
    }
  };
}

/* -------------------------------------------- */

/**
 * Get all trackable spell slot attributes.
 * @param {string} [suffix=""]  Suffix appended to the path.
 * @returns {Set<string>}
 * @internal
 */
function _trackedSpellAttributes(suffix="") {
  return Object.entries(DND5E.spellcasting).reduce((acc, [k, v]) => {
    if ( v.slots ) Array.fromRange(Object.keys(DND5E.spellLevels).length - 1, 1).forEach(l => {
      acc.add(`spells.${v.getSpellSlotKey(l)}${suffix}`);
    });
    return acc;
  }, new Set());
}

/* -------------------------------------------- */

/**
 * Configure which attributes are available for item consumption.
 * @internal
 */
function _configureConsumableAttributes() {
  CONFIG.DND5E.consumableResources = [
    ...Object.keys(DND5E.abilities).map(ability => `abilities.${ability}.value`),
    "attributes.ac.flat",
    "attributes.hp.value",
    "attributes.exhaustion",
    ...Object.keys(DND5E.senses).map(sense => `attributes.senses.${sense}`),
    ...Object.keys(DND5E.movementTypes).map(type => `attributes.movement.${type}`),
    ...Object.keys(DND5E.currencies).map(denom => `currency.${denom}`),
    "details.xp.value",
    "resources.primary.value", "resources.secondary.value", "resources.tertiary.value",
    "resources.legact.value", "resources.legres.value", "attributes.actions.value",
    ..._trackedSpellAttributes(".value")
  ];
}

/* -------------------------------------------- */

/**
 * Configure additional system fonts.
 */
function _configureFonts() {
  Object.assign(CONFIG.fontDefinitions, {
    Roboto: {
      editor: true,
      fonts: [
        { urls: ["systems/dnd5e/fonts/roboto/Roboto-Regular.woff2"] },
        { urls: ["systems/dnd5e/fonts/roboto/Roboto-Bold.woff2"], weight: "bold" },
        { urls: ["systems/dnd5e/fonts/roboto/Roboto-Italic.woff2"], style: "italic" },
        { urls: ["systems/dnd5e/fonts/roboto/Roboto-BoldItalic.woff2"], weight: "bold", style: "italic" }
      ]
    },
    "Roboto Condensed": {
      editor: true,
      fonts: [
        { urls: ["systems/dnd5e/fonts/roboto-condensed/RobotoCondensed-Regular.woff2"] },
        { urls: ["systems/dnd5e/fonts/roboto-condensed/RobotoCondensed-Bold.woff2"], weight: "bold" },
        { urls: ["systems/dnd5e/fonts/roboto-condensed/RobotoCondensed-Italic.woff2"], style: "italic" },
        {
          urls: ["systems/dnd5e/fonts/roboto-condensed/RobotoCondensed-BoldItalic.woff2"], weight: "bold",
          style: "italic"
        }
      ]
    },
    "Roboto Slab": {
      editor: true,
      fonts: [
        { urls: ["systems/dnd5e/fonts/roboto-slab/RobotoSlab-Regular.ttf"] },
        { urls: ["systems/dnd5e/fonts/roboto-slab/RobotoSlab-Bold.ttf"], weight: "bold" }
      ]
    }
  });
}

/* -------------------------------------------- */

/**
 * Configure system status effects.
 */
function _configureStatusEffects() {
  const addEffect = (effects, {special, ...data}) => {
    data = foundry.utils.deepClone(data);
    data._id = staticID(`dnd5e${data.id}`);
    data.order ??= Infinity;
    effects.push(data);
    if ( special ) CONFIG.specialStatusEffects[special] = data.id;
    if ( data.neverBlockMovement ) DND5E.neverBlockStatuses.add(data.id);
  };
  CONFIG.statusEffects = Object.entries(CONFIG.DND5E.statusEffects).reduce((arr, [id, data]) => {
    const original = CONFIG.statusEffects.find(s => s.id === id);
    addEffect(arr, foundry.utils.mergeObject(original ?? {}, { id, ...data }, { inplace: false }));
    return arr;
  }, []);
  for ( const [id, data] of Object.entries(CONFIG.DND5E.conditionTypes) ) {
    addEffect(CONFIG.statusEffects, { id, ...data });
  }
  for ( const [id, data] of Object.entries(CONFIG.DND5E.encumbrance.effects) ) {
    addEffect(CONFIG.statusEffects, { id, ...data, hud: false });
  }
}

/* -------------------------------------------- */
/*  Foundry VTT Setup                           */
/* -------------------------------------------- */

/**
 * Prepare attribute lists.
 */
Hooks.once("setup", function() {
  // Configure trackable & consumable attributes.
  _configureTrackableAttributes();
  _configureConsumableAttributes();

  CONFIG.DND5E.trackableAttributes = expandAttributeList(CONFIG.DND5E.trackableAttributes);
  game.dnd5e.moduleArt.registerModuleArt();
  Tooltips5e.activateListeners();
  game.dnd5e.tooltips.observe();

  // Register settings after modules have had a chance to initialize
  registerDeferredSettings();

  // Set up compendiums with custom applications & sorting
  setupModulePacks();

  // Create CSS for currencies
  const style = document.createElement("style");
  const currencies = append => Object.entries(CONFIG.DND5E.currencies)
    .map(([key, { icon }]) => `&.${key}${append ?? ""} { background-image: url("${icon}"); }`);
  style.innerHTML = `
    :is(.dnd5e2, .dnd5e2-journal) :is(i, span).currency {
      ${currencies().join("\n")}
    }
    .dnd5e2 .form-group label.label-icon.currency {
      ${currencies("::after").join("\n")}
    }
  `;
  document.head.append(style);
});

/* --------------------------------------------- */

/**
 * 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", () => {
  // Set up status effects. Explicitly performed after init and before prelocalization.
  _configureStatusEffects();

  if ( game.settings.get("dnd5e", "rulesVersion") === "legacy" ) {
    const { translations, _fallback } = game.i18n;
    foundry.utils.mergeObject(translations, {
      "TYPES.Item": {
        race: game.i18n.localize("TYPES.Item.raceLegacy"),
        racePl: game.i18n.localize("TYPES.Item.raceLegacyPl")
      },
      DND5E: {
        "Feature.Class.ArtificerPlan": game.i18n.localize("DND5E.Feature.Class.ArtificerInfusion"),
        "Feature.Species": game.i18n.localize("DND5E.Feature.SpeciesLegacy"),
        FlagsAlertHint: game.i18n.localize("DND5E.FlagsAlertHintLegacy"),
        ItemSpeciesDetails: game.i18n.localize("DND5E.ItemSpeciesDetailsLegacy"),
        "Language.Category.Rare": game.i18n.localize("DND5E.Language.Category.Exotic"),
        "MOVEMENT.Type.Speed": game.i18n.localize("DND5E.MOVEMENT.Type.Walk"),
        RacialTraits: game.i18n.localize("DND5E.RacialTraitsLegacy"),
        "REST.Long.Hint.Normal": game.i18n.localize("DND5E.REST.Long.Hint.NormalLegacy"),
        "REST.Long.Hint.Group": game.i18n.localize("DND5E.REST.Long.Hint.GroupLegacy"),
        "Species.Add": game.i18n.localize("DND5E.Species.AddLegacy"),
        "Species.Features": game.i18n.localize("DND5E.Species.FeaturesLegacy"),
        "TARGET.Type.Emanation": foundry.utils.mergeObject(
          _fallback.DND5E?.TARGET?.Type?.Radius ?? {},
          translations.DND5E?.TARGET?.Type?.Radius ?? {},
          { inplace: false }
        ),
        TraitArmorPlural: foundry.utils.mergeObject(
          _fallback.DND5E?.TraitArmorLegacyPlural ?? {},
          translations.DND5E?.TraitArmorLegacyPlural ?? {},
          { inplace: false }
        ),
        TraitArmorProf: game.i18n.localize("DND5E.TraitArmorLegacyProf")
      }
    });
  }
  performPreLocalization(CONFIG.DND5E);
  Object.values(CONFIG.DND5E.activityTypes).forEach(c => c.documentClass.localize());
  Object.values(CONFIG.DND5E.advancementTypes).forEach(c => c.documentClass.localize());
  foundry.helpers.Localization.localizeDataModel(CalendarConfigSetting);
  foundry.helpers.Localization.localizeDataModel(CalendarPreferencesSetting);
  foundry.helpers.Localization.localizeDataModel(TransformationSetting);

  // Spellcasting
  SpellcastingModel.fromConfig();
});

/* -------------------------------------------- */
/*  Foundry VTT Ready                           */
/* -------------------------------------------- */

/**
 * Once the entire VTT framework is initialized, check to see if we should perform a data migration
 */
Hooks.once("ready", function() {
  // 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 ( ["ActiveEffect", "Activity", "Item"].includes(data.type) ) {
      create5eMacro(data, slot);
      return false;
    }
  });

  // Adjust sourced items on actors now that compendium UUID redirects have been initialized
  game.actors.forEach(a => a.sourcedItems._redirectKeys());

  // Register items by type
  dnd5e.registry.classes.initialize();
  dnd5e.registry.subclasses.initialize();

  // Chat message listeners
  ChatMessage5e.activateListeners();

  // Bastion initialization
  game.dnd5e.bastion.initializeUI();

  // Display the calendar HUD
  if ( CONFIG.DND5E.calendar.application ) {
    dnd5e.ui.calendar = new CONFIG.DND5E.calendar.application();
    dnd5e.ui.calendar.render({ force: true });
  }

  // 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 && !foundry.utils.isNewerVersion(game.system.flags.needsMigrationVersion, cv) ) return;

  // Compendium pack folder migration.
  if ( foundry.utils.isNewerVersion("3.0.0", cv) ) {
    reparentCompendiums("DnD5e SRD Content", "D&D SRD Content");
  }

  // Perform the migration
  if ( cv && foundry.utils.isNewerVersion(game.system.flags.compatibleMigrationVersion, cv) ) {
    ui.notifications.error("MIGRATION.5eVersionTooOldWarning", {localize: true, permanent: true});
  }
  migrateWorld();
});

/* -------------------------------------------- */
/*  System Styling                              */
/* -------------------------------------------- */

Hooks.on("renderGamePause", (app, html) => {
  if ( Hooks.events.renderGamePause.length > 1 ) return;
  html.classList.add("dnd5e2");
  const container = document.createElement("div");
  container.classList.add("flexcol");
  container.append(...html.children);
  html.append(container);
  const img = html.querySelector("img");
  img.src = "systems/dnd5e/ui/official/ampersand.svg";
  img.className = "";
});

Hooks.on("renderSettings", (app, html) => renderSettings(html));

/* -------------------------------------------- */
/*  Other Hooks                                 */
/* -------------------------------------------- */

Hooks.on("applyCompendiumArt", (documentClass, ...args) => documentClass.applyCompendiumArt?.(...args));

Hooks.on("renderChatPopout", ChatMessage5e.onRenderChatPopout);
Hooks.on("getChatMessageContextOptions", ChatMessage5e.addChatMessageContextOptions);

Hooks.on("renderChatLog", (app, html, data) => {
  Item5e.chatListeners(html);
  ChatMessage5e.onRenderChatLog(html);
});
Hooks.on("renderChatPopout", (app, html, data) => Item5e.chatListeners(html));

Hooks.on("chatMessage", (app, message, data) => Award.chatMessage(message));
Hooks.on("createChatMessage", RequestMessageData.onCreateMessage);
Hooks.on("updateChatMessage", RequestMessageData.onUpdateResultMessage);

Hooks.on("renderActorDirectory", (app, html, data) => Actor5e.onRenderActorDirectory(html));

Hooks.on("getActorContextOptions", Actor5e.addDirectoryContextOptions);
Hooks.on("getItemContextOptions", Item5e.addDirectoryContextOptions);

Hooks.on("renderCompendiumDirectory", (app, html) => CompendiumBrowser.injectSidebarButton(html));

Hooks.on("renderJournalEntryPageSheet", JournalEntrySheet5e.onRenderJournalPageSheet);

Hooks.on("renderActiveEffectConfig", ActiveEffect5e.onRenderActiveEffectConfig);

Hooks.on("renderDocumentSheetConfig", (app, html) => {
  const { document } = app.options;
  if ( (document instanceof Actor) && document.system.isGroup ) {
    MultiActorSheet.addDocumentSheetConfigOptions(app, html);
  }
});

Hooks.on("targetToken", Token5e.onTargetToken);

Hooks.on("renderCombatTracker", (app, html, data) => app.renderGroups(html));

Hooks.on("preCreateScene", (doc, createData, options, userId) => {
  // Set default grid units based on metric length setting
  const units = defaultUnits("length");
  if ( (units !== dnd5e.grid.units) && !foundry.utils.getProperty(createData, "grid.distance")
    && !foundry.utils.getProperty(createData, "grid.units") ) {
    doc.updateSource({
      grid: { distance: convertLength(dnd5e.grid.distance, dnd5e.grid.units, units, { strict: false }), units }
    });
  }
});

Hooks.on("updateWorldTime", (...args) => {
  CalendarData5e.onUpdateWorldTime(...args);
  CONFIG.DND5E.calendar.application?.onUpdateWorldTime?.(...args);
});

export { DND5E, Filter, applications, canvas$1 as canvas, dataModels, dice, documents, enrichers, migrations, registry, utils };
//# sourceMappingURL=dnd5e-compiled.mjs.map
