mirror of
https://github.com/RoY7x/worldbuilding.git
synced 2025-04-30 02:31:41 -04:00
- Updated `rollData` in helper.js to use actor as the data source rather than the object. This allows for items to reference attributes by assuming the parent actor is the source of truth and item attributes would be referenced as either `@item.attr` or `@items.ITEMNAME.attr` - Refactored formula replacement to prevent values from being replaced with 0s unintentionally.
547 lines
No EOL
19 KiB
JavaScript
547 lines
No EOL
19 KiB
JavaScript
import { ATTRIBUTE_TYPES } from "./constants.js";
|
|
|
|
export class EntitySheetHelper {
|
|
|
|
static getAttributeData(data) {
|
|
data.dtypes = ATTRIBUTE_TYPES;
|
|
|
|
// Determine attribute type.
|
|
for ( let attr of Object.values(data.data.attributes) ) {
|
|
if ( attr.dtype ) {
|
|
attr.isCheckbox = attr.dtype === "Boolean";
|
|
attr.isResource = attr.dtype === "Resource";
|
|
attr.isFormula = attr.dtype === "Formula";
|
|
}
|
|
}
|
|
|
|
// Initialize ungrouped attributes for later.
|
|
data.data.ungroupedAttributes = {};
|
|
|
|
// Build an array of sorted group keys.
|
|
const groups = data.data.groups || {};
|
|
let groupKeys = Object.keys(groups).sort((a, b) => {
|
|
let aSort = groups[a].label ?? a;
|
|
let bSort = groups[b].label ?? b;
|
|
return aSort.localeCompare(bSort);
|
|
});
|
|
|
|
// Iterate over the sorted groups to add their attributes.
|
|
for ( let key of groupKeys ) {
|
|
let group = data.data.attributes[key] || {};
|
|
|
|
// Initialize the attributes container for this group.
|
|
if ( !data.data.groups[key]['attributes'] ) data.data.groups[key]['attributes'] = {};
|
|
|
|
// Sort the attributes within the group, and then iterate over them.
|
|
Object.keys(group).sort((a, b) => a.localeCompare(b)).forEach(attr => {
|
|
// Avoid errors if this is an invalid group.
|
|
if ( typeof group[attr] != "object" || !group[attr]) return;
|
|
// For each attribute, determine whether it's a checkbox or resource, and then add it to the group's attributes list.
|
|
group[attr]['isCheckbox'] = group[attr]['dtype'] === 'Boolean';
|
|
group[attr]['isResource'] = group[attr]['dtype'] === 'Resource';
|
|
group[attr]['isFormula'] = group[attr]['dtype'] === 'Formula';
|
|
data.data.groups[key]['attributes'][attr] = group[attr];
|
|
});
|
|
}
|
|
|
|
// Sort the remaining attributes attributes.
|
|
Object.keys(data.data.attributes).filter(a => !groupKeys.includes(a)).sort((a, b) => a.localeCompare(b)).forEach(key => {
|
|
data.data.ungroupedAttributes[key] = data.data.attributes[key];
|
|
});
|
|
|
|
// Modify attributes on items.
|
|
if ( data.items ) {
|
|
data.items.forEach(item => {
|
|
// Iterate over attributes.
|
|
for ( let [k, v] of Object.entries(item.data.attributes) ) {
|
|
// Grouped attributes.
|
|
if ( !v.dtype ) {
|
|
for ( let [gk, gv] of Object.entries(v) ) {
|
|
if ( gv.dtype ) {
|
|
// Add label fallback.
|
|
if ( !gv.label ) gv.label = gk;
|
|
// Add formula bool.
|
|
if ( gv.dtype == "Formula" ) {
|
|
gv.isFormula = true;
|
|
}
|
|
else {
|
|
gv.isFormula = false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Ungrouped attributes.
|
|
else {
|
|
// Add label fallback.
|
|
if ( !v.label ) v.label = k;
|
|
// Add formula bool.
|
|
if ( v.dtype == "Formula" ) {
|
|
v.isFormula = true;
|
|
}
|
|
else {
|
|
v.isFormula = false;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/** @override */
|
|
static onSubmit(event) {
|
|
// Closing the form/sheet will also trigger a submit, so only evaluate if this is an event.
|
|
if ( event.currentTarget ) {
|
|
// Exit early if this isn't a named attribute.
|
|
if ( event.currentTarget.tagName.toLowerCase() == 'input' && !event.currentTarget.hasAttribute('name')) {
|
|
return false;
|
|
}
|
|
|
|
let attr = false;
|
|
// If this is the attribute key, we need to make a note of it so that we can restore focus when its recreated.
|
|
const el = event.currentTarget;
|
|
if ( el.classList.contains("attribute-key") ) {
|
|
let val = el.value;
|
|
let oldVal = el.closest(".attribute").dataset.attribute;
|
|
let attrError = false;
|
|
// Prevent attributes that already exist as groups.
|
|
let groups = document.querySelectorAll('.group-key');
|
|
for ( let i = 0; i < groups.length; i++ ) {
|
|
if (groups[i].value == val) {
|
|
ui.notifications.error(game.i18n.localize("SIMPLE.NotifyAttrDuplicate") + ` (${val})`);
|
|
el.value = oldVal;
|
|
attrError = true;
|
|
break;
|
|
}
|
|
}
|
|
// Handle value and name replacement otherwise.
|
|
if ( !attrError ) {
|
|
oldVal = oldVal.includes('.') ? oldVal.split('.')[1] : oldVal;
|
|
attr = $(el).attr('name').replace(oldVal, val);
|
|
}
|
|
}
|
|
|
|
// Return the attribute key if set, or true to confirm the submission should be triggered.
|
|
return attr ? attr : true;
|
|
}
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Listen for click events on an attribute control to modify the composition of attributes in the sheet
|
|
* @param {MouseEvent} event The originating left click event
|
|
* @private
|
|
*/
|
|
static async onClickAttributeControl(event) {
|
|
event.preventDefault();
|
|
const a = event.currentTarget;
|
|
const action = a.dataset.action;
|
|
|
|
// Perform create and delete actions.
|
|
switch ( action ) {
|
|
case "create":
|
|
EntitySheetHelper.createAttribute(event, this);
|
|
break;
|
|
case "delete":
|
|
EntitySheetHelper.deleteAttribute(event, this);
|
|
break;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Listen for click events and modify attribute groups.
|
|
* @param {MouseEvent} event The originating left click event
|
|
*/
|
|
static async onClickAttributeGroupControl(event) {
|
|
event.preventDefault();
|
|
const a = event.currentTarget;
|
|
const action = a.dataset.action;
|
|
|
|
switch ( action ) {
|
|
case "create-group":
|
|
EntitySheetHelper.createAttributeGroup(event, this);
|
|
break;
|
|
case "delete-group":
|
|
EntitySheetHelper.deleteAttributeGroup(event, this);
|
|
break;
|
|
}
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Listen for the roll button on attributes.
|
|
* @param {MouseEvent} event The originating left click event
|
|
*/
|
|
static onAttributeRoll(event) {
|
|
event.preventDefault();
|
|
const button = event.currentTarget;
|
|
const label = button.closest(".attribute").querySelector(".attribute-label")?.value;
|
|
const chatLabel = label ?? button.parentElement.querySelector(".attribute-key").value;
|
|
const shorthand = game.settings.get("worldbuilding", "macroShorthand");
|
|
// Use the actor for rollData so that formulas are always in reference to the parent actor.
|
|
const rollData = this.actor.getRollData();
|
|
let formula = button.closest(".attribute").querySelector(".attribute-value")?.value;
|
|
|
|
// If there's a formula, attempt to roll it.
|
|
if ( formula ) {
|
|
// Get the machine safe version of the item name.
|
|
let replacement = null;
|
|
if ( formula.includes('@item.') && this.item ) {
|
|
let itemName = this.item.name.slugify({strict: true});
|
|
replacement = !!shorthand ? `@items.${itemName}.` : `@items.${itemName}.attributes.`;
|
|
formula = formula.replace('@item.', replacement);
|
|
}
|
|
formula = EntitySheetHelper.replaceData(formula, rollData, {missing: null});
|
|
// Replace `@item` shorthand with the item name and make the roll.
|
|
let r = new Roll(formula, rollData);
|
|
r.roll().toMessage({
|
|
user: game.user._id,
|
|
speaker: ChatMessage.getSpeaker({ actor: this.actor }),
|
|
flavor: `${chatLabel}`
|
|
});
|
|
}
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Return HTML for a new attribute to be applied to the form for submission.
|
|
*
|
|
* @param {Object} items Keyed object where each item has a "type" and "value" property.
|
|
* @param {string} index Numeric index or key of the new attribute.
|
|
* @param {string|boolean} group String key of the group, or false.
|
|
*
|
|
* @returns {string} Html string.
|
|
*/
|
|
static getAttributeHtml(items, index, group = false) {
|
|
// Initialize the HTML.
|
|
let result = '<div>';
|
|
// Iterate over the supplied keys and build their inputs (including whether or not they need a group key).
|
|
for (let [key, item] of Object.entries(items)) {
|
|
result = result + `<input type="${item.type}" name="data.attributes${group ? '.' + group : '' }.attr${index}.${key}" value="${item.value}"/>`;
|
|
}
|
|
// Close the HTML and return.
|
|
return result + '</div>';
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Validate whether or not a group name can be used.
|
|
* @param {string} groupName Groupname to validate
|
|
* @returns {boolean}
|
|
*/
|
|
static validateGroup(groupName, entity) {
|
|
let groups = Object.keys(entity.object.data.data.groups);
|
|
let attributes = Object.keys(entity.object.data.data.attributes).filter(a => !groups.includes(a));
|
|
|
|
// Check for duplicate group keys.
|
|
if ( groups.includes(groupName) ) {
|
|
ui.notifications.error(game.i18n.localize("SIMPLE.NotifyGroupDuplicate") + ` (${groupName})`);
|
|
return false;
|
|
}
|
|
|
|
// Check for group keys that match attribute keys.
|
|
if ( attributes.includes(groupName) ) {
|
|
ui.notifications.error(game.i18n.localize("SIMPLE.NotifyGroupAttrDuplicate") + ` (${groupName})`);
|
|
return false;
|
|
}
|
|
|
|
// Check for whitespace or periods.
|
|
if ( groupName.match(/[\s|\.]/i) ) {
|
|
ui.notifications.error(game.i18n.localize("SIMPLE.NotifyGroupAlphanumeric"));
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Create new attributes.
|
|
* @param {MouseEvent} event The originating left click event
|
|
* @param {Object} app The form application object.
|
|
* @private
|
|
*/
|
|
static async createAttribute(event, app) {
|
|
const a = event.currentTarget;
|
|
const group = a.dataset.group;
|
|
let dtype = a.dataset.dtype;
|
|
const attrs = app.object.data.data.attributes;
|
|
const groups = app.object.data.data.groups;
|
|
const form = app.form;
|
|
|
|
// Determine the new attribute key for ungrouped attributes.
|
|
let objKeys = Object.keys(attrs).filter(k => !Object.keys(groups).includes(k));
|
|
let nk = Object.keys(attrs).length + 1;
|
|
let newValue = `attr${nk}`;
|
|
let newKey = document.createElement("div");
|
|
while ( objKeys.includes(newValue) ) {
|
|
++nk;
|
|
newValue = `attr${nk}`;
|
|
};
|
|
|
|
// Build options for construction HTML inputs.
|
|
let htmlItems = {
|
|
key: {
|
|
type: "text",
|
|
value: newValue
|
|
}
|
|
};
|
|
|
|
// Grouped attributes.
|
|
if ( group ) {
|
|
objKeys = attrs[group] ? Object.keys(attrs[group]) : [];
|
|
nk = objKeys.length + 1;
|
|
newValue = `attr${nk}`;
|
|
while ( objKeys.includes(newValue) ) {
|
|
++nk;
|
|
newValue = `attr${nk}`;
|
|
}
|
|
|
|
// Update the HTML options used to build the new input.
|
|
htmlItems.key.value = newValue;
|
|
htmlItems.group = {
|
|
type: "hidden",
|
|
value: group
|
|
};
|
|
htmlItems.dtype = {
|
|
type: "hidden",
|
|
value: dtype
|
|
};
|
|
}
|
|
// Ungrouped attributes.
|
|
else {
|
|
// Choose a default dtype based on the last attribute, fall back to "String".
|
|
if (!dtype) {
|
|
let lastAttr = document.querySelector('.attributes > .attributes-group .attribute:last-child .attribute-dtype')?.value;
|
|
dtype = lastAttr ? lastAttr : "String";
|
|
htmlItems.dtype = {
|
|
type: "hidden",
|
|
value: dtype
|
|
};
|
|
}
|
|
}
|
|
|
|
// Build the form elements used to create the new grouped attribute.
|
|
newKey.innerHTML = EntitySheetHelper.getAttributeHtml(htmlItems, nk, group);
|
|
|
|
// Append the form element and submit the form.
|
|
newKey = newKey.children[0];
|
|
form.appendChild(newKey);
|
|
await app._onSubmit(event);
|
|
}
|
|
|
|
/**
|
|
* Delete an attribute.
|
|
* @param {MouseEvent} event The originating left click event
|
|
* @param {Object} app The form application object.
|
|
* @private
|
|
*/
|
|
static async deleteAttribute(event, app) {
|
|
const a = event.currentTarget;
|
|
const li = a.closest(".attribute");
|
|
if ( li ) {
|
|
li.parentElement.removeChild(li);
|
|
await app._onSubmit(event);
|
|
}
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Create new attribute groups.
|
|
* @param {MouseEvent} event The originating left click event
|
|
* @param {Object} app The form application object.
|
|
* @private
|
|
*/
|
|
static async createAttributeGroup(event, app) {
|
|
const a = event.currentTarget;
|
|
const form = app.form;
|
|
let newValue = $(a).siblings('.group-prefix').val();
|
|
// Verify the new group key is valid, and use it to create the group.
|
|
if ( newValue.length > 0 && EntitySheetHelper.validateGroup(newValue, app) ) {
|
|
let newKey = document.createElement("div");
|
|
newKey.innerHTML = `<input type="text" name="data.groups.${newValue}.key" value="${newValue}"/>`;
|
|
// Append the form element and submit the form.
|
|
newKey = newKey.children[0];
|
|
form.appendChild(newKey);
|
|
await app._onSubmit(event);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete an attribute group.
|
|
* @param {MouseEvent} event The originating left click event
|
|
* @param {Object} app The form application object.
|
|
* @private
|
|
*/
|
|
static async deleteAttributeGroup(event, app) {
|
|
const a = event.currentTarget;
|
|
let groupHeader = a.closest(".group-header");
|
|
let groupContainer = groupHeader.closest(".group");
|
|
let group = $(groupHeader).find('.group-key');
|
|
// Create a dialog to confirm group deletion.
|
|
new Dialog({
|
|
title: game.i18n.localize("SIMPLE.DeleteGroup"),
|
|
content: `${game.i18n.localize("SIMPLE.DeleteGroupContent")} <strong>${group.val()}</strong>`,
|
|
buttons: {
|
|
confirm: {
|
|
icon: '<i class="fas fa-trash"></i>',
|
|
label: game.i18n.localize("Yes"),
|
|
callback: async () => {
|
|
groupContainer.parentElement.removeChild(groupContainer);
|
|
await app._onSubmit(event);
|
|
}
|
|
},
|
|
cancel: {
|
|
icon: '<i class="fas fa-times"></i>',
|
|
label: game.i18n.localize("No"),
|
|
}
|
|
}
|
|
}).render(true);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Update attributes when updating an actor object.
|
|
*
|
|
* @param {Object} formData Form data object to modify keys and values for.
|
|
* @returns {Object} updated formData object.
|
|
*/
|
|
static updateAttributes(formData, entity) {
|
|
let groupKeys = [];
|
|
|
|
// Handle the free-form attributes list
|
|
const formAttrs = expandObject(formData).data.attributes || {};
|
|
const attributes = Object.values(formAttrs).reduce((obj, v) => {
|
|
let attrs = [];
|
|
let group = null;
|
|
// Handle attribute keys for grouped attributes.
|
|
if ( !v["key"] ) {
|
|
attrs = Object.keys(v);
|
|
attrs.forEach(attrKey => {
|
|
group = v[attrKey]['group'];
|
|
groupKeys.push(group);
|
|
let attr = v[attrKey];
|
|
let k = v[attrKey]["key"] ? v[attrKey]["key"].trim() : attrKey.trim();
|
|
if ( /[\s\.]/.test(k) ) return ui.notifications.error("Attribute keys may not contain spaces or periods");
|
|
delete attr["key"];
|
|
// Add the new attribute if it's grouped, but we need to build the nested structure first.
|
|
if ( !obj[group] ) {
|
|
obj[group] = {};
|
|
}
|
|
obj[group][k] = attr;
|
|
});
|
|
}
|
|
// Handle attribute keys for ungrouped attributes.
|
|
else {
|
|
let k = v["key"].trim();
|
|
if ( /[\s\.]/.test(k) ) return ui.notifications.error("Attribute keys may not contain spaces or periods");
|
|
delete v["key"];
|
|
// Add the new attribute only if it's ungrouped.
|
|
if ( !group ) {
|
|
obj[k] = v;
|
|
}
|
|
}
|
|
return obj;
|
|
}, {});
|
|
|
|
// Remove attributes which are no longer used
|
|
for ( let k of Object.keys(entity.object.data.data.attributes) ) {
|
|
if ( !attributes.hasOwnProperty(k) ) attributes[`-=${k}`] = null;
|
|
}
|
|
|
|
// Remove grouped attributes which are no longer used.
|
|
for ( let group of groupKeys) {
|
|
if ( entity.object.data.data.attributes[group] ) {
|
|
for ( let k of Object.keys(entity.object.data.data.attributes[group]) ) {
|
|
if ( !attributes[group].hasOwnProperty(k) ) attributes[group][`-=${k}`] = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Re-combine formData
|
|
formData = Object.entries(formData).filter(e => !e[0].startsWith("data.attributes")).reduce((obj, e) => {
|
|
obj[e[0]] = e[1];
|
|
return obj;
|
|
}, {_id: entity.object._id, "data.attributes": attributes});
|
|
|
|
return formData;
|
|
}
|
|
|
|
/**
|
|
* Update attribute groups when updating an actor object.
|
|
*
|
|
* @param {Object} formData Form data object to modify keys and values for.
|
|
* @returns {Object} updated formData object.
|
|
*/
|
|
static updateGroups(formData, entity) {
|
|
// Handle the free-form groups list
|
|
const formGroups = expandObject(formData).data.groups || {};
|
|
const groups = Object.values(formGroups).reduce((obj, v) => {
|
|
// If there are duplicate groups, collapse them.
|
|
if ( Array.isArray(v["key"]) ) {
|
|
v["key"] = v["key"][0];
|
|
}
|
|
// Trim and clean up.
|
|
let k = v["key"].trim();
|
|
if ( /[\s\.]/.test(k) ) return ui.notifications.error("Group keys may not contain spaces or periods");
|
|
delete v["key"];
|
|
obj[k] = v;
|
|
return obj;
|
|
}, {});
|
|
|
|
// Remove groups which are no longer used
|
|
for ( let k of Object.keys(entity.object.data.data.groups) ) {
|
|
if ( !groups.hasOwnProperty(k) ) groups[`-=${k}`] = null;
|
|
}
|
|
|
|
// Re-combine formData
|
|
formData = Object.entries(formData).filter(e => !e[0].startsWith("data.groups")).reduce((obj, e) => {
|
|
obj[e[0]] = e[1];
|
|
return obj;
|
|
}, {_id: entity.object._id, "data.groups": groups});
|
|
|
|
return formData;
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Replace referenced data attributes in the roll formula with the syntax `@attr` with the corresponding key from
|
|
* the provided `data` object. This is a temporary helper function that will be replaced with Roll.replaceFormulaData()
|
|
* in Foundry 0.7.1.
|
|
*
|
|
* @param {String} formula The original formula within which to replace.
|
|
* @param {Object} data Data object to use for value replacements.
|
|
* @param {Object} missing Value to use as missing replacements, such as {missing: "0"}.
|
|
* @return {String} The formula with attributes replaced with values.
|
|
*/
|
|
static replaceData(formula, data, {missing=null,depth=1}={}) {
|
|
// Exit early if the formula is invalid.
|
|
if ( typeof formula != "string" || depth < 1) {
|
|
return 0;
|
|
}
|
|
// Replace attributes with their numeric equivalents.
|
|
let dataRgx = new RegExp(/@([a-z.0-9_\-]+)/gi);
|
|
let rollFormula = formula.replace(dataRgx, (match, term) => {
|
|
let value = getProperty(data, term);
|
|
// If there was a value returned, trim and return it.
|
|
if ( value ) {
|
|
return String(value).trim();
|
|
}
|
|
// Otherwise, return either the missing replacement value, or the original @attr string for later replacement.
|
|
else {
|
|
return missing != null ? missing : `@${term}`;
|
|
}
|
|
});
|
|
return rollFormula;
|
|
}
|
|
|
|
} |