From 0a96acec0a4b6bd5a8f4fdd21e0e0b74c9f724c6 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Thu, 19 Oct 2023 13:13:06 -0700 Subject: [PATCH 1/4] [#1405] Modify trait utilities to support prefixed trait keys & add SelectChoices --- lang/en.json | 7 + module/applications/actor/base-sheet.mjs | 4 +- .../applications/actor/proficiency-config.mjs | 3 +- module/applications/actor/trait-selector.mjs | 6 +- module/documents/_module.mjs | 1 + module/documents/actor/actor.mjs | 2 +- module/documents/actor/select-choices.mjs | 191 ++++++++++ module/documents/actor/trait.mjs | 336 ++++++++++++++---- module/utils.mjs | 78 +++- 9 files changed, 541 insertions(+), 87 deletions(-) create mode 100644 module/documents/actor/select-choices.mjs 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..d296651744 --- /dev/null +++ b/module/documents/actor/select-choices.mjs @@ -0,0 +1,191 @@ +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. + */ + +/** + * 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. + * @type {Set} + */ + get set() { + const set = new Set(); + for ( const [key, choice] of Object.entries(this) ) { + if ( !choice.children ) set.add(key); + else choice.children.set.forEach(k => set.add(k)); + } + 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 SelectOptions object into this one. + * @param {SelectOptions} other + * @returns {SelectOptions} + */ + merge(other) { + return foundry.utils.mergeObject(this, other); + } + + /* -------------------------------------------- */ + + /** + * Merge another SelectOptions object into this one, returning a new SelectOptions object. + * @param {SelectOptions} other + * @returns {SelectOptions} + */ + merged(other) { + return this.clone().merge(other); + } + + /* -------------------------------------------- */ + + /** + * Internal sorting method. + * @param {object} lhs + * @param {object} rhs + * @returns {number} + */ + _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. + * @returns {SelectOptions} + */ + sort() { + const sorted = new SelectChoices(sortObjectEntries(this, this._sort)); + 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; + } + + /* -------------------------------------------- */ + + /** + * Sort the entries using the label, returning a new SelectOptions object. + * @returns {SelectOptions} + */ + sorted() { + const sorted = new SelectChoices(sortObjectEntries(this, this._sort)); + for ( const entry of Object.values(sorted) ) { + if ( entry.children ) entry.children = entry.children.sorted(); + } + return sorted; + } + + /* -------------------------------------------- */ + + /** + * Filters choices in place to only include the provided keys. + * @param {Set|SelectChoices} filter Keys of traits to retain or another SelectOptions object. + * @returns {SelectChoices} This SelectChoices with filter applied. + */ + filter(filter) { + if ( filter instanceof SelectChoices ) filter = filter.set; + + 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 not 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; + } + + /* -------------------------------------------- */ + + /** + * Filters choices to only include the provided keys, returning a new SelectChoices object. + * @param {Set|SelectChoices} filter Keys of traits to retain or another SelectOptions object. + * @returns {SelectChoices} Clone of SelectChoices with filter applied. + */ + filtered(filter) { + return this.clone().filter(filter); + } + + /* -------------------------------------------- */ + + /** + * Removes in place any traits or categories the keys of which are included in the exclusion set. + * @param {Set} keys Set of keys to remove from the choices. + * @returns {SelectChoices} This SelectChoices with excluded keys removed. + */ + 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; + } + + /* -------------------------------------------- */ + + /** + * Removes any traits or categories the keys of which are included in the exclusion set, returning a copy. + * @param {Set} keys Set of keys to remove from the choices. + * @returns {SelectChoices} Clone of SelectChoices with excluded keys removed. + */ + excluded(keys) { + return this.clone().exclude(keys); + } +} diff --git a/module/documents/actor/trait.mjs b/module/documents/actor/trait.mjs index 78a9611c5c..fb11a63c1d 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} @@ -6,7 +8,7 @@ const _cachedIndices = {}; /* -------------------------------------------- */ -/* Trait Lists */ +/* Application */ /* -------------------------------------------- */ /** @@ -17,46 +19,82 @@ 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 {BlackFlagActor} 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.BlackFlag.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 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; - }, {}); + 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`; + } +} + +/* -------------------------------------------- */ +/* 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. + * @private + */ +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 +102,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 +122,94 @@ 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 = foundry.utils.getType(data) === "Object" + ? foundry.utils.getProperty(data, traitConfig.labelKeyPath ?? "label") : data; + if ( !label ) label = key; + if ( prefixed ) key = `${prefix}:${key}`; + result[key] = { + label: game.i18n.localize(label), + chosen: chosen.has(key), + sorting: traitConfig.sortCategories === false + }; + 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).sorted(); +} + +/* -------------------------------------------- */ + +/** + * 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 })).filtered(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 +293,73 @@ 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. */ -export function keyLabel(trait, key) { +export function keyLabel(key, { count, trait, final }={}) { + let parts = key.split(":"); + const pluralRules = new Intl.PluralRules(game.i18n.lang); + + if ( !trait ) trait = parts.shift(); 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); - } + if ( !traitConfig ) return key; + let categoryLabel = game.i18n.localize(`${traitConfig.labels.localization}.${ + pluralRules.select(count ?? 1)}`); + + const lastKey = parts.pop(); + if ( !lastKey ) return categoryLabel; + + if ( lastKey !== "*" ) { + // Category + const category = CONFIG.DND5E[traitConfig.configKey ?? trait]?.[lastKey]; + if ( category ) { + return foundry.utils.getType(category) === "Object" + ? foundry.utils.getProperty(category, traitConfig.labelKey ?? "label") : category; + } - for ( const childrenKey of Object.values(traitConfig.children ?? {}) ) { - if ( CONFIG.DND5E[childrenKey]?.[key] ) return CONFIG.DND5E[childrenKey]?.[key]; + // Child + for ( const childrenKey of Object.values(traitConfig.children ?? {}) ) { + const childLabel = CONFIG.DND5E[childrenKey]?.[lastKey]; + if ( childLabel ) return childLabel; + } + + // Base item + 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; + else break; + } } - 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; + // Wildcards + else { + let type; + if ( !parts.length ) type = categoryLabel.toLowerCase(); + else { + const category = CONFIG.DND5E[traitConfig.configKey ?? trait]?.[parts.pop()]; + if ( !category ) return key; + type = foundry.utils.getType(category) === "Object" + ? foundry.utils.getProperty(category, traitConfig.labelKey ?? "label") : category; + } + const key = `DND5E.TraitConfigChoose${final ? "Other" : `Any${count ? "Counted" : "Uncounted"}`}`; + return game.i18n.format(key, { count: count ?? 1, type }); } return key; @@ -232,24 +369,73 @@ 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} */ -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 + // { count: 3, pool: ["skills:*"] } -> any three skills + // { count: 3, pool: ["skills:*"] } (final) -> three other skills + 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" }); + + // Singular count + // { count: 1, pool: ["skills:*"] } -> any skill + // { count: 1, pool: ["thief", "skills:*"] } -> Thieves Tools or any skill + // { count: 1, pool: ["thief", "tools:artisan:*"] } -> 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 + // { count: 2, pool: ["thief", "skills:*"] } -> Choose two from thieves tools or any skill + 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 {Set} grants Guaranteed trait grants. + * @param {TraitChoice[]} [choices=[]] Trait choices. + * @param {object} [options={}] + * @param {string} [options.choiceMode="inclusive"] Choice mode. + * @returns {string} + */ +export function localizedList(grants, 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 { + const choiceListFormatter = new Intl.ListFormat(game.i18n.lang, { style: "long", type: "disjunction" }); + sections.push(choiceListFormatter.format(choiceSections)); + } + + const listFormatter = new Intl.ListFormat(game.i18n.lang, { 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