diff --git a/lang/en.json b/lang/en.json index bf0dc97491..f39e202ef8 100644 --- a/lang/en.json +++ b/lang/en.json @@ -1156,12 +1156,19 @@ "DND5E.TraitCIPlural.one": "Condition Immunity", "DND5E.TraitCIPlural.other": "Condition Immunities", "DND5E.TraitConfig": "Configure {trait}", +"DND5E.TraitConfigChooseAnyCounted": "any {count} {type}", +"DND5E.TraitConfigChooseAnyUncounted": "any {type}", +"DND5E.TraitConfigChooseList": "{count} from {list}", +"DND5E.TraitConfigChooseOther": "{count} other {type}", +"DND5E.TraitConfigChooseWrapper": "Choose {choices}", "DND5E.TraitDIPlural.one": "Damage Immunity", "DND5E.TraitDIPlural.other": "Damage Immunities", "DND5E.TraitDRPlural.one": "Damage Resistance", "DND5E.TraitDRPlural.other": "Damage Resistances", "DND5E.TraitDVPlural.one": "Damage Vulnerability", "DND5E.TraitDVPlural.other": "Damage Vulnerabilities", +"DND5E.TraitGenericPlural.one": "Trait", +"DND5E.TraitGenericPlural.other": "Traits", "DND5E.TraitLanguagesPlural.one": "Language", "DND5E.TraitLanguagesPlural.other": "Languages", "DND5E.TraitSave": "Update", diff --git a/module/applications/actor/base-sheet.mjs b/module/applications/actor/base-sheet.mjs index 466b1316ed..b520654c28 100644 --- a/module/applications/actor/base-sheet.mjs +++ b/module/applications/actor/base-sheet.mjs @@ -158,7 +158,7 @@ export default class ActorSheet5e extends ActorSheet { entry.abbreviation = CONFIG.DND5E.abilities[entry.ability]?.abbreviation; entry.icon = this._getProficiencyIcon(entry.value); entry.hover = CONFIG.DND5E.proficiencyLevels[entry.value]; - entry.label = prop === "skills" ? CONFIG.DND5E.skills[key]?.label : Trait.keyLabel("tool", key); + entry.label = prop === "skills" ? CONFIG.DND5E.skills[key]?.label : Trait.keyLabel(key, {trait: "tool"}); entry.baseValue = source.system[prop]?.[key]?.value ?? 0; } }); @@ -417,7 +417,7 @@ export default class ActorSheet5e extends ActorSheet { } data.selected = values.reduce((obj, key) => { - obj[key] = Trait.keyLabel(trait, key) ?? key; + obj[key] = Trait.keyLabel(key, { trait }) ?? key; return obj; }, {}); diff --git a/module/applications/actor/proficiency-config.mjs b/module/applications/actor/proficiency-config.mjs index 012ad98616..14fce443be 100644 --- a/module/applications/actor/proficiency-config.mjs +++ b/module/applications/actor/proficiency-config.mjs @@ -49,7 +49,8 @@ export default class ProficiencyConfig extends BaseConfigSheet { /** @inheritdoc */ get title() { - const label = this.isSkill ? CONFIG.DND5E.skills[this.options.key].label : Trait.keyLabel("tool", this.options.key); + const label = this.isSkill ? CONFIG.DND5E.skills[this.options.key].label + : Trait.keyLabel(this.options.key, { trait: "tool" }); return `${game.i18n.format("DND5E.ProficiencyConfigureTitle", {label})}: ${this.document.name}`; } diff --git a/module/applications/actor/trait-selector.mjs b/module/applications/actor/trait-selector.mjs index 542a00670b..0d2a9bf4ff 100644 --- a/module/applications/actor/trait-selector.mjs +++ b/module/applications/actor/trait-selector.mjs @@ -61,7 +61,7 @@ export default class TraitSelector extends BaseConfigSheet { /** @inheritdoc */ async getData() { - const path = `system.${Trait.actorKeyPath(this.trait)}`; + const path = Trait.actorKeyPath(this.trait); const data = foundry.utils.getProperty(this.document, path); if ( !data ) return super.getData(); @@ -94,7 +94,7 @@ export default class TraitSelector extends BaseConfigSheet { /** @inheritdoc */ _getActorOverrides() { const overrides = super._getActorOverrides(); - const path = `system.${Trait.actorKeyPath(this.trait)}.value`; + const path = Trait.changeKeyPath(this.trait); const src = new Set(foundry.utils.getProperty(this.document._source, path)); const current = foundry.utils.getProperty(this.document, path); const delta = current.difference(src); @@ -151,7 +151,7 @@ export default class TraitSelector extends BaseConfigSheet { /** @override */ async _updateObject(event, formData) { - const path = `system.${Trait.actorKeyPath(this.trait)}`; + const path = Trait.actorKeyPath(this.trait); const data = foundry.utils.getProperty(this.document, path); this._prepareChoices("choices", `${path}.value`, formData); diff --git a/module/documents/_module.mjs b/module/documents/_module.mjs index 5bf6c3c23d..cdcf7855c0 100644 --- a/module/documents/_module.mjs +++ b/module/documents/_module.mjs @@ -7,6 +7,7 @@ export {default as TokenDocument5e} from "./token.mjs"; // Helper Methods export {default as Proficiency} from "./actor/proficiency.mjs"; +export {default as SelectChoices} from "./actor/select-choices.mjs"; export * as Trait from "./actor/trait.mjs"; export * as chat from "./chat-message.mjs"; export * as combat from "./combat.mjs"; diff --git a/module/documents/actor/actor.mjs b/module/documents/actor/actor.mjs index 13d33e94d2..968436f48e 100644 --- a/module/documents/actor/actor.mjs +++ b/module/documents/actor/actor.mjs @@ -1141,7 +1141,7 @@ export default class Actor5e extends Actor { data.toolBonus = bonus.join(" + "); } - const flavor = game.i18n.format("DND5E.ToolPromptTitle", {tool: Trait.keyLabel("tool", toolId) ?? ""}); + const flavor = game.i18n.format("DND5E.ToolPromptTitle", {tool: Trait.keyLabel(toolId, {trait: "tool"}) ?? ""}); const rollData = foundry.utils.mergeObject({ data, flavor, title: `${flavor}: ${this.name}`, diff --git a/module/documents/actor/select-choices.mjs b/module/documents/actor/select-choices.mjs new file mode 100644 index 0000000000..4f023d69a3 --- /dev/null +++ b/module/documents/actor/select-choices.mjs @@ -0,0 +1,230 @@ +import { sortObjectEntries } from "../../utils.mjs"; + +/** + * Object representing a nested set of choices to be displayed in a grouped select list or a trait selector. + * + * @typedef {object} SelectChoicesEntry + * @property {string} label Label, either pre- or post-localized. + * @property {boolean} [chosen] Has this choice been selected? + * @property {boolean} [sorting=true] Should this value be sorted? If there are a mixture of this value at + * a level, unsorted values are listed first followed by sorted values. + * @property {SelectChoices} [children] Nested choices. If wildcard filtering support is desired, then trait keys + * should be provided prefixed for children (e.g. `parent:child`, rather than + * just `child`). + */ + +/** + * Object with a number of methods for performing actions on a nested set of choices. + * + * @param {Object} [choices={}] Initial choices for the object. + */ +export default 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); + } + + /* -------------------------------------------- */ + + /** + * Create a set of available choice keys. + * @param {Set} [set] Existing set to which the values will be added. + * @returns {Set} + */ + 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; + } + + /* -------------------------------------------- */ + + /** + * 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); + } + + /* -------------------------------------------- */ + + /** + * 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|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 ( foundry.utils.isEmpty(trait.children ?? {}) ) 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} 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; + } +} diff --git a/module/documents/actor/trait.mjs b/module/documents/actor/trait.mjs index 78a9611c5c..4d0fccd3df 100644 --- a/module/documents/actor/trait.mjs +++ b/module/documents/actor/trait.mjs @@ -1,3 +1,5 @@ +import SelectChoices from "./select-choices.mjs"; + /** * Cached version of the base items compendia indices with the needed subtype fields. * @type {object} @@ -5,8 +7,20 @@ */ 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; +} + /* -------------------------------------------- */ -/* Trait Lists */ +/* Application */ /* -------------------------------------------- */ /** @@ -17,46 +31,81 @@ const _cachedIndices = {}; export function actorKeyPath(trait) { const traitConfig = CONFIG.DND5E.traits[trait]; if ( traitConfig.actorKeyPath ) return traitConfig.actorKeyPath; - return `traits.${trait}`; + return `system.traits.${trait}`; } /* -------------------------------------------- */ /** - * Fetch the categories object for the specified trait. - * @param {string} trait Trait as defined in `CONFIG.DND5E.traits`. - * @returns {object} Trait categories defined within `CONFIG.DND5E`. + * 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} */ -export function categories(trait) { - const traitConfig = CONFIG.DND5E.traits[trait]; - return CONFIG.DND5E[traitConfig.configKey ?? trait]; +export function actorValues(actor, trait) { + const keyPath = actorKeyPath(trait); + const data = foundry.utils.getProperty(actor, keyPath); + if ( !data ) return {}; + const values = {}; + + if ( ["skills", "tool"].includes(trait) ) { + Object.entries(data).forEach(([k, d]) => values[`${trait}:${k}`] = d.value); + } else if ( trait === "saves" ) { + Object.entries(data).forEach(([k, d]) => values[`${trait}:${k}`] = d.proficient); + } else { + data.value.forEach(v => values[`${trait}:${v}`] = 1); + } + + return values; } /* -------------------------------------------- */ /** - * Get a list of choices for a specific trait. - * @param {string} trait Trait as defined in `CONFIG.DND5E.traits`. - * @param {Set} [chosen=[]] Optional list of keys to be marked as chosen. - * @returns {object} Object mapping proficiency ids to choice objects. + * 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} */ -export async function choices(trait, chosen=new Set()) { +export function changeKeyPath(key, trait) { + const split = key.split(":"); + if ( !trait ) trait = split.shift(); + const traitConfig = CONFIG.DND5E.traits[trait]; - if ( foundry.utils.getType(chosen) === "Array" ) chosen = new Set(chosen); + if ( !traitConfig ) return; + + let keyPath = actorKeyPath(trait); + + if ( trait === "saves" ) { + return `${keyPath}.${split.pop()}.proficient`; + } else if ( ["skills", "tools"].includes(trait) ) { + return `${keyPath}.${split.pop()}.value`; + } else { + return `${keyPath}.value`; + } +} - let data = Object.entries(categories(trait)).reduce((obj, [key, label]) => { - obj[key] = { label, chosen: chosen.has(key) }; - return obj; - }, {}); - - if ( traitConfig.children ) { - for ( const [categoryKey, childrenKey] of Object.entries(traitConfig.children) ) { - const children = CONFIG.DND5E[childrenKey]; - if ( !children || !data[categoryKey] ) continue; - data[categoryKey].children = Object.entries(children).reduce((obj, [key, label]) => { - obj[key] = { label, chosen: chosen.has(key) }; - return obj; - }, {}); +/* -------------------------------------------- */ +/* 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 {object} Object with trait categories and children. + */ +export 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 }; + } } } @@ -64,9 +113,9 @@ export async function choices(trait, chosen=new Set()) { const keyPath = `system.${traitConfig.subtypes.keyPath}`; const map = CONFIG.DND5E[`${trait}ProficienciesMap`]; - // Merge all IDs lists together + // Merge all ID lists together const ids = traitConfig.subtypes.ids.reduce((obj, key) => { - if ( CONFIG.DND5E[key] ) Object.assign(obj, CONFIG.DND5E[key]); + foundry.utils.mergeObject(obj, CONFIG.DND5E[key] ?? {}); return obj; }, {}); @@ -84,29 +133,93 @@ export async function choices(trait, chosen=new Set()) { let type = foundry.utils.getProperty(index, keyPath); if ( map?.[type] ) type = map[type]; - const entry = { label: index.name, chosen: chosen.has(key) }; - // No category for this type, add at top level - if ( !data[type] ) data[key] = entry; + if ( !config[type] ) config[key] = { label: index.name }; - // Add as child to appropriate category + // Add as child of appropriate category else { - data[type].children ??= {}; - data[type].children[key] = entry; + config[type].children ??= {}; + config[type].children[key] = { label: index.name }; } } } - // Sort Categories - if ( traitConfig.sortCategories ) data = dnd5e.utils.sortObjectEntries(data, "label"); + return config; +} + +/* -------------------------------------------- */ - // Sort Children - for ( const category of Object.values(data) ) { - if ( !category.children ) continue; - category.children = dnd5e.utils.sortObjectEntries(category.children, "label"); +/** + * Get a list of choices for a specific trait. + * @param {string} trait Trait as defined in `CONFIG.DND5E.traits`. + * @param {object} [options={}] + * @param {Set} [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 {SelectChoices} Object mapping proficiency ids to choice objects. + */ +export 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 ( prefixed && any ) { + const key = `${trait}:*`; + result[key] = { + label: keyLabel(key).titleCase(), + chosen: chosen.has(key), sorting: false, wildcard: true + }; } - return data; + const prepareCategory = (key, data, result, prefix) => { + let label = _innerLabel(data, traitConfig); + if ( !label ) label = key; + if ( prefixed ) key = `${prefix}:${key}`; + result[key] = { + label: game.i18n.localize(label), + chosen: chosen.has(key), + sorting: traitConfig.sortCategories === 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)); + + 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} keys Prefixed trait keys. + * @returns {SelectChoices} + */ +export 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; } /* -------------------------------------------- */ @@ -190,39 +303,113 @@ export function traitIndexFields() { * @returns {string} Localized label. */ export function traitLabel(trait, count) { - let typeCap; - if ( trait.length === 2 ) typeCap = trait.toUpperCase(); - else typeCap = trait.capitalize(); - - const pluralRule = ( count !== undefined ) ? new Intl.PluralRules(game.i18n.lang).select(count) : "other"; - return game.i18n.localize(`DND5E.Trait${typeCap}Plural.${pluralRule}`); + 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. - * @param {string} trait Trait as defined in `CONFIG.DND5E.traits`. - * @param {string} key Key for which to generate the label. - * @returns {string} Retrieved label. + * 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" }); */ -export function keyLabel(trait, key) { - const traitConfig = CONFIG.DND5E.traits[trait]; - if ( categories(trait)[key] ) { - const category = categories(trait)[key]; - if ( !traitConfig.labelKey ) return category; - return foundry.utils.getProperty(category, traitConfig.labelKey); +export function keyLabel(key, config={}) { + if ( foundry.utils.getType(config) === "string" ) { + foundry.utils.logCompatibilityWarning("Trait.keyLabel(trait, key) is now Trait.keyLabel(key, { trait }).", { + since: "DnD5e 2.4", until: "DnD5e 2.6" + }); + const tmp = config; + config = { trait: key }; + key = tmp; } + let { count, trait, final } = config; - for ( const childrenKey of Object.values(traitConfig.children ?? {}) ) { - if ( CONFIG.DND5E[childrenKey]?.[key] ) return CONFIG.DND5E[childrenKey]?.[key]; + 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; + 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; + + // Wildcards (e.g. "Artisan's Tools", "any Artisan's Tools", "any 2 Artisan's Tools", or "2 other Artisan's Tools") + if ( lastKey === "*" ) { + let type; + if ( parts.length ) { + const category = CONFIG.DND5E[traitConfig.configKey ?? trait]?.[parts.pop()]; + if ( !category ) return key; + 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 }); } - for ( const idsKey of traitConfig.subtypes?.ids ?? [] ) { - if ( !CONFIG.DND5E[idsKey]?.[key] ) continue; - const index = getBaseItem(CONFIG.DND5E[idsKey][key], { indexOnly: true }); - if ( index ) return index.name; - else break; + else { + // Category (e.g. "Gaming Sets") + const category = CONFIG.DND5E[traitConfig.configKey ?? trait]?.[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 ?? [] ) { + const baseItemId = CONFIG.DND5E[idsKey]?.[lastKey]; + if ( !baseItemId ) continue; + const index = getBaseItem(baseItemId, { indexOnly: true }); + if ( index ) return index.name; + break; + } } return key; @@ -232,24 +419,109 @@ export function keyLabel(trait, key) { /** * Create a human readable description of the provided choice. - * @param {string} trait Trait as defined in `CONFIG.DND5E.traits`. - * @param {TraitChoice} choice Data for a specific choice. + * @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:*"]) }); + * */ -export function choiceLabel(trait, choice) { - // Select from any trait values - if ( !choice.pool ) { - return game.i18n.format("DND5E.TraitConfigChooseAny", { - count: choice.count, - type: traitLabel(trait, choice.count).toLowerCase() +export 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 }); } - // Select from a list of options - const choices = choice.pool.map(key => keyLabel(trait, key)); - const listFormatter = new Intl.ListFormat(game.i18n.lang, { type: "disjunction" }); + const listFormatter = game.i18n.getListFormatter({ 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(choice.pool.map(key => keyLabel(key))); + } + + // Select from a list of options (e.g. "2 from Thieves' Tools or any skill proficiency") + const choices = choice.pool.map(key => keyLabel(key)); 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} [config.grants] Guaranteed trait grants. + * @param {TraitChoice[]} [config.choices=[]] Trait choices. + * @param {"inclusive"|"exclusive"} [config.choiceMode="inclusive"] Choice mode. + * @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:*"])}] }); + * + * @example + * // Returns "Choose any 2 languages or any 1 skill proficiency" + * localizedList({ choices: [ + * {count: 2, pool: new Set(["languages:*"])}, { count: 1, pool: new Set(["skills:*"])} + * ], choiceMode: "exclusive" }); + */ +export function localizedList({ grants=new Set(), choices=[], choiceMode="inclusive" }) { + const choiceSections = []; + + for ( const [index, choice] of choices.entries() ) { + const final = choiceMode === "exclusive" ? false : index === choices.length - 1; + choiceSections.push(choiceLabel(choice, { final, only: !grants.size && choices.length === 1 })); + } + + let sections = Array.from(grants).map(g => keyLabel(g)); + if ( choiceMode === "inclusive" ) { + sections = sections.concat(choiceSections); + } else { + sections.push(game.i18n.getListFormatter({ style: "long", type: "disjunction" }).format(choiceSections)); + } + + const listFormatter = game.i18n.getListFormatter({ style: "long", type: "conjunction" }); + if ( !sections.length || grants.size ) return listFormatter.format(sections); + return game.i18n.format("DND5E.TraitConfigChooseWrapper", { + choices: listFormatter.format(sections) + }); +} diff --git a/module/utils.mjs b/module/utils.mjs index 38ab91b52c..1c28444efe 100644 --- a/module/utils.mjs +++ b/module/utils.mjs @@ -25,16 +25,31 @@ export function simplifyBonus(bonus, data={}) { /* 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. + */ +export function filteredKeys(obj, filter) { + filter ??= e => e; + return Object.entries(obj).filter(e => filter(e[1])).map(e => e[0]); +} + +/* -------------------------------------------- */ + /** * Sort the provided object by its values or by an inner sortKey. - * @param {object} obj The object to sort. - * @param {string} [sortKey] An inner key upon which to sort. - * @returns {object} A copy of the original object that has been sorted. + * @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. */ export function sortObjectEntries(obj, sortKey) { let sorted = Object.entries(obj); - if ( sortKey ) sorted = sorted.sort((a, b) => a[1][sortKey].localeCompare(b[1][sortKey])); - else sorted = sorted.sort((a, b) => a[1].localeCompare(b[1])); + const sort = (lhs, rhs) => foundry.utils.getType(lhs) === "string" ? lhs.localeCompare(rhs) : 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); } @@ -146,6 +161,58 @@ export async function preloadHandlebarsTemplates() { /* -------------------------------------------- */ +/** + * A helper to create a set of