Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[#1405] Modify trait utilities to support prefixed trait keys & add SelectChoices #2499

Merged
merged 4 commits into from
Oct 25, 2023
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
7 changes: 7 additions & 0 deletions lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions module/applications/actor/base-sheet.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
});
Expand Down Expand Up @@ -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;
}, {});

Expand Down
3 changes: 2 additions & 1 deletion module/applications/actor/proficiency-config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
}

Expand Down
6 changes: 3 additions & 3 deletions module/applications/actor/trait-selector.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions module/documents/_module.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
2 changes: 1 addition & 1 deletion module/documents/actor/actor.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
Expand Down
227 changes: 227 additions & 0 deletions module/documents/actor/select-choices.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
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<string, SelectChoicesEntry>} [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<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 ) set.add(key);
else choice.children.asSet(set);
arbron marked this conversation as resolved.
Show resolved Hide resolved
}
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();
arbron marked this conversation as resolved.
Show resolved Hide resolved
return foundry.utils.mergeObject(this, other);
}

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

/**
* Internal sorting method.
* @param {object} lhs
* @param {object} rhs
* @returns {number}
arbron marked this conversation as resolved.
Show resolved Hide resolved
*/
_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<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]));
arbron marked this conversation as resolved.
Show resolved Hide resolved
*
* // 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<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;
}
}