Skip to content

Commit

Permalink
[#1405] Add inplace to SelectChoices methods, resolve code style sugg…
Browse files Browse the repository at this point in the history
…estions
  • Loading branch information
arbron committed Oct 20, 2023
1 parent 0a96ace commit 4de6617
Show file tree
Hide file tree
Showing 2 changed files with 119 additions and 103 deletions.
113 changes: 44 additions & 69 deletions module/documents/actor/select-choices.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,14 @@ export default class SelectChoices {

/**
* Create a set of available choice keys.
* @type {Set<string>}
* @param {Set<string>} [set] Existing set to which the values will be added.
* @returns {Set<string>}
*/
get set() {
const set = new Set();
asSet(set) {
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));
else choice.children.asSet(set);
}
return set;
}
Expand All @@ -61,35 +62,27 @@ export default class SelectChoices {
/* -------------------------------------------- */

/**
* Merge another SelectOptions object into this one.
* @param {SelectOptions} other
* @returns {SelectOptions}
* 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) {
merge(other, { inplace=true }={}) {
if ( !inplace ) return this.clone().merge();
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) && (rhs.sorting === false) ) return 0;
if ( lhs.sorting === false ) return -1;
if ( rhs.sorting === false ) return 1;
return lhs.label.localeCompare(rhs.label);
Expand All @@ -99,50 +92,51 @@ export default class SelectChoices {

/**
* Sort the entries using the label.
* @returns {SelectOptions}
* @param {object} [options={}]
* @param {boolean} [options.inplace=true] Should this SelectChoices be mutated or a new one returned?
* @returns {SelectChoices}
*/
sort() {
sort({ inplace=true }={}) {
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;
}

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

/**
* 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();
else {
for ( const entry of Object.values(sorted) ) {
if ( entry.children ) entry.children = entry.children.sort({ inplace });
}
return sorted;
}
return sorted;
}

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

/**
* Filters choices in place to only include the provided keys.
* @param {Set<string>|SelectChoices} filter Keys of traits to retain or another SelectOptions object.
* @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.
*/
filter(filter) {
if ( filter instanceof SelectChoices ) filter = filter.set;
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*");
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
// 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];
Expand All @@ -154,38 +148,19 @@ export default class SelectChoices {

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

/**
* Filters choices to only include the provided keys, returning a new SelectChoices object.
* @param {Set<string>|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<string>} keys Set of keys to remove from the choices.
* @returns {SelectChoices} This SelectChoices with excluded keys removed.
* @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.
*/
exclude(keys) {
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;
}

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

/**
* Removes any traits or categories the keys of which are included in the exclusion set, returning a copy.
* @param {Set<string>} 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);
}
}
109 changes: 75 additions & 34 deletions module/documents/actor/trait.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ import SelectChoices from "./select-choices.mjs";
*/
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 */
/* -------------------------------------------- */
Expand All @@ -26,7 +38,7 @@ export function actorKeyPath(trait) {

/**
* Get the current trait values for the provided actor.
* @param {BlackFlagActor} actor Actor from which to retrieve the values.
* @param {Actor5e} actor Actor from which to retrieve the values.
* @param {string} trait Trait as defined in `CONFIG.DND5E.traits`.
* @returns {Object<number>}
*/
Expand All @@ -52,7 +64,7 @@ export function actorValues(actor, trait) {
/**
* 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.
* @param {string} [trait] Trait as defined in `CONFIG.DND5E.traits`, only needed if key isn't prefixed.
* @returns {string|void}
*/
export function changeKeyPath(key, trait) {
Expand Down Expand Up @@ -81,7 +93,6 @@ export function changeKeyPath(key, trait) {
* 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];
Expand Down Expand Up @@ -163,14 +174,13 @@ export async function choices(trait, { chosen=new Set(), prefixed=false, any=fal
}

const prepareCategory = (key, data, result, prefix) => {
let label = foundry.utils.getType(data) === "Object"
? foundry.utils.getProperty(data, traitConfig.labelKeyPath ?? "label") : data;
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 === false
sorting: traitConfig.sortCategories === true
};
if ( data.children ) {
const children = result[key].children = {};
Expand Down Expand Up @@ -311,7 +321,17 @@ export function traitLabel(trait, count) {
* @param {boolean} [config.final] Is this the final in a list?
* @returns {string} Retrieved label.
*/
export function keyLabel(key, { count, trait, final }={}) {
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;

let parts = key.split(":");
const pluralRules = new Intl.PluralRules(game.i18n.lang);

Expand All @@ -321,47 +341,51 @@ export function keyLabel(key, { count, trait, final }={}) {
let categoryLabel = game.i18n.localize(`${traitConfig.labels.localization}.${
pluralRules.select(count ?? 1)}`);

// Trait
// Example: "tool" => "Tool Proficiency"
const lastKey = parts.pop();
if ( !lastKey ) return categoryLabel;

if ( lastKey !== "*" ) {
// Wildcards
// Example: "tool:art" => "Artisan's Tools"
// Example: "tool:art:*" => "any Artisan's Tools"
// Example: "tool:art:*" (count 2) => "any 2 Artisan's Tools"
// Example: "tool:art:*" (count 2, final) => "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 key = `DND5E.TraitConfigChoose${final ? "Other" : `Any${count ? "Counted" : "Uncounted"}`}`;
return game.i18n.format(key, { count: count ?? 1, type });
}

else {
// Category
// Example: "tool:game" => "Gaming Sets"
const category = CONFIG.DND5E[traitConfig.configKey ?? trait]?.[lastKey];
if ( category ) {
return foundry.utils.getType(category) === "Object"
? foundry.utils.getProperty(category, traitConfig.labelKey ?? "label") : category;
}
if ( category ) return _innerLabel(category, traitConfig);

// Child
// Example: "tool:vehicle:land" => "Land Vehicle"
for ( const childrenKey of Object.values(traitConfig.children ?? {}) ) {
const childLabel = CONFIG.DND5E[childrenKey]?.[lastKey];
if ( childLabel ) return childLabel;
}

// Base item
// Example: "weapon:shortsword" or "weapon:simple:shortsword" => "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;
else break;
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;
}

Expand All @@ -388,7 +412,7 @@ export function choiceLabel(choice, { only=false, final=false }={}) {
});
}

const listFormatter = new Intl.ListFormat(game.i18n.lang, { type: "disjunction" });
const listFormatter = game.i18n.getListFormatter({ type: "disjunction" });

// Singular count
// { count: 1, pool: ["skills:*"] } -> any skill
Expand All @@ -411,11 +435,29 @@ export function choiceLabel(choice, { only=false, final=false }={}) {

/**
* Create a human readable description of trait grants & choices.
* @param {Set<string>} grants Guaranteed trait grants.
* @param {TraitChoice[]} [choices=[]] Trait choices.
* @param {Set<string>} grants Guaranteed trait grants.
* @param {TraitChoice[]} [choices=[]] Trait choices.
* @param {object} [options={}]
* @param {string} [options.choiceMode="inclusive"] Choice mode.
* @param {"inclusive"|"exclusive"} [options.choiceMode="inclusive"] Choice mode.
* @returns {string}
*
* @example
* // Returns "Acrobatics and Athletics"
* localizedList(new Set(["skills:acr", "skills:ath"]));
*
* @example
* // Returns "Acrobatics and one other skill proficiency"
* localizedList(new Set(["skills:acr"]), [{ count: 1, pool: new Set(["skills:*"])}]);
*
* @example
* // Returns "Choose any skill proficiency"
* localizedList(new Set(), [{ count: 1, pool: new Set(["skills:*"])}]);
*
* @example
* // Returns "Choose any 2 languages or any 1 skill proficiency"
* localizedList(new Set(), [
* {count: 2, pool: new Set(["languages:*"])}, { count: 1, pool: new Set(["skills:*"])}
* ], {choiceMode: "exclusive"});
*/
export function localizedList(grants, choices=[], { choiceMode="inclusive" }={}) {
const choiceSections = [];
Expand All @@ -429,11 +471,10 @@ export function localizedList(grants, choices=[], { choiceMode="inclusive" }={})
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));
sections.push(game.i18n.getListFormatter({ style: "long", type: "disjunction" }).format(choiceSections));
}

const listFormatter = new Intl.ListFormat(game.i18n.lang, { style: "long", type: "conjunction" });
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)
Expand Down

0 comments on commit 4de6617

Please sign in to comment.