Sigh. Had a nice forum post set up here and then it deleted itself.
But, I'm going to post an example of my technique to see if that helps. Most of the explanation is going to be in the comments in the code, but I'll make a few points at the end.
//An array of objects, each of which holds the details on what fields need to be grabbed for a given repeating section. Note that this can have as many or as few as you need.
const repeating_section_details = [
{
section:'repeating_attacks'
fields:['mod','misc','damage']
}
];
/*
Expansion functions to update the cascades to match the sheet
*/
//Expands the repeating section templates in cascades to reflect the rows actually available
const expandCascade = function(cascade,sections,attributes){
return _.keys(cascade).reduce((memo,key)=>{//iterate through cascades and replace references to repeating attributes with correct row ids.
if(/^repeating/.test(key)){//If the attribute is a repeating attribute, do special logic
expandRepeating(memo,key,cascade,sections,attributes);
}else{//for non repeating attributes do this logic
expandNormal(memo,key,cascade,sections);
}
return memo;
},{});
};
const expandRepeating = function(memo,key,cascade,sections,attributes){
key.replace(/(repeating_[^_]+)_[^_]+?_(.+)/,(match,section,field)=>{
sections[section].forEach((id)=>{
memo[`${section}_${id}_${field}`]=_.clone(cascade[key]);//clone the details so that each row's attributes have correct ids
memo[`${section}_${id}_${field}`].name = `${section}_${id}_${field}`;
memo[`${section}_${id}_${field}`].affects = memo[`${section}_${id}_${field}`].affects.reduce((m,affected)=>{
if(section === affected){//otherwise if the affected attribute is in the same section, simply set the affected attribute to have the same row id.
m.push(applyID(affected,id));
}else if(/repeating/.test(affected)){//If the affected attribute isn't in the same repeating section but is still a repeating attribute, add all the rows of that section
addAllRows(affected,m,sections);
}else{//otherwise the affected attribute is a non repeating attribute. Simply add it to the computed affected array
m.push(affected);
}
return m;
},[]);
});
});
};
const applyID = function(affected,id){
return affected.replace(/(repeating_[^_]+_)[^_]+(.+)/,`$1${id}$2`);
};
const expandNormal = function(memo,key,cascade,sections){
memo[key] = _.clone(cascade[key]);
memo[key].affects = memo[key].affects.reduce((m,a)=>{
if(/^repeating/.test(a)){//if the attribute affects a repeating attribute, it should affect all rows in that section.
addAllRows(a,m,sections);
}else{
m.push(a);
}
return m;
},[]);
};
const addAllRows = function(affected,memo,sections){
affected.replace(/(repeating_[^_]+?)_[^_]+?_(.+)/,(match,section,field)=>{
sections[section].forEach(id=>memo.push(`${section}_${id}_${field}`));
});
};
//# Calculation Functions # These could be whatever you need for your system and these functions are just dummy placeholders for this demo
const abilityMod = function(statObj,attributes,sections){
//returns the calculation for ability points
};
const derivativeStatCalc = function(statObj,attributes,sections){
//returns the calculation for a derivative stat
};
const attackTotal = function(statObj,attributes,sections){
//returns the calculation for an attack total
};
//accesses the sheet and iterates through all changes necessary before calling setAttrs
const accessSheet = function(attributes,sections,trigger){
const setObj = {};//initialize our object to hold our updates
const casc = expandCascade(cascades,sections,attributes);//Create a copy of the cascades updated to match the repeating sections present on the sheet
let trigger = casc[trigger];//Get the object from the cascade that matches the attribute that was changed
//Now we need to work through the cascades of affected attributes. This will be another queue worker or burn down pattern
let queue = trigger ? [...trigger.affects] : [];//initialize the queue with the affects array of the triggering attribute.
while(queue.length){//While queue is not empty, keep working
let name = queue.shift();//pull an attribute that needs to be worked off of the queue
let obj = casc[name];//get the cascade object for that attribute
setObj[name] = obj.calculation(obj,attributes,sections);//call the attribute's calculation
attributes[name] = setObj[name];//update the attributes array to match the new value so it can be used in future calculations
queue = [...queue,...obj.affects];//add any attributes that this attribute affects to the queue. rinse and repeat until nothing remains in the queue.
}
setAttrs(setObj,{silent:true});//Apply all of our changes silently.
};
//An object that is used as a lookup for what a given attribute affects and how to calculate it. The object is indexed by attribute name.
//The attribute name for repeating sections is a template for how that repeating attribute should be expanded
//This allows us to avoid having a ton of if/else chains
const cascades = {
strength_mod:{//name index
name:'strength',//name as a property for easier use when needed
defaultValue:0,//the default value of the attribute for use in calculations in case something goes wrong
type:'number',//What type the attribute is. This isn't necessarily needed, but I've frequently found it nice to have for logic on how to assemble attributes
affects:['athletics','repeating_attacks_$X_mod'],//An array of the attributes that this attribute affects
calculation:abilityMod//what function to call to calculate this value
},
dexterity_mod:{
name:'dexterity',
defaultValue:0,
type:'number',
affects:['initiative'],
calculation:abilityMod
},
strength:{
name:'strength',
defaultValue:0,
type:'number',
affects:['strength_mod']
},
dexterity:{
name:'dexterity',
defaultValue:0,
type:'number',
affects:['dexterity_mod']
},
initiative:{
name:'initiative',
defaultValue:0,
type:'number',
affects:[],
calculation:derivativeStatCalc
},
athletics:{
name:'athletics',
defaultValue:0,
type:'number',
affects:[],
calculation:derivativeStatCalc
},
athletics_rank:{
name:'athletics_rank',
defaultValue:0,
type:'number',
affects:['athletics']
},
repeating_attacks_$X_mod:{
name:'repeating_attacks_$X_mod',
defaultValue:0,
type:'number',
affects:[],
calculation:attackTotal
},
repeating_attacks_$X_misc:{
name:'repeating_attacks_$X_misc',
defaultValue:0,
type:'number',
affects:['repeating_attacks_$X_mod']
}
};
//Assemble our array of attributes/sections to monitor
const toMonitor = Object.keys(cascades).reduce((m,k)=>{
if(!/repeating/.test(k)){
m.push(k);
}
return m;
},[]);
const baseGet = [...toMonitor,'sheet_version'];//assemble our array of attributes to be gotten
repeating_section_details.forEach((obj)=>toMonitor.push(obj.section));//Add the repeating sections to the monitor array. Done here so they don't pollute the baseGet
//Gets all the section IDs. This is function uses the queue worker pattern, sometimes also called a burn down
const getSections = function(callback,getArray = [],trigger,sections = {},queue){
queue = queue || _.clone(repeating_section_details);//make a copy of the section details array so that we don't corrupt it for future calls.
let section = queue.shift();//Get the details for a section to work
getSectionIDs(section.section,(idArray)=>{//Actual call to the getSectionIDs sheetworker function
sections[section.section]=[];//initialize the array for this section in sections
idArray.forEach((id)=>{//iterate through the ids given by getSectionIDs
sections[section.section].push(id);//push the row id to the array
section.fields.forEach((field)=>{//iterate through the fields for the section
getArray.push(`${section.section}_${id}_${field}`);//push the full repeating attribute name to the getArray
});
});
if(_.isEmpty(queue)){//If there are no further sections to work through get the attributes and call the callback
getAttrs(getArray,(attributes)=>callback(attributes,sections,trigger));
}else{//otherwise keep getting IDs for sections
getSections(callback,getArray,trigger,sections,queue);
}
});
};
//Stores all the event listeners
const registerEventHandlers = function(){
toMonitor.forEach((m)=>{
on(`change:${m}`,(event)=>{
getSections(accessSheet,[...baseGet],event.sourceAttribute);//Note that I'm expanding baseGet here so that we don't corrupt the global variable version of it for future events.
});
});
};
//Initialize the event listeners.
registerEventHandlers();
So, the points I want to make:
1) This is fast! All the cascade expansion and logic to figure out what to do is orders of magnitude quicker than waiting on a setAttrs to resolve, maybe 5 ms. Even the getSectionIDs calls are incredibly quick, on the order of 10's of ms per call, about like a getAttrs. So for a single repeating section like I have here, we're looking at a total calculation time (including the setAttrs resolution) of maybe 130ms. 2) This is actually pretty easy to expand and edit for future changes to the sheet. You'll need to add or edit an entry in the cascades, and then you might need to do the same for the repeating_section_details, and maybe you'll need a new calculation function. 3) We have no issues with synchronicity because all of our calculations are actually done within a single callback which allows us to mostly ignore the ugliness of working with asynchronous functions. 4) With some additional logic, we can actually do truly dynamic assignment of what attributes affect what other attributes to handle buffs/conditions/homebrew rules. For instance, a user could put in an input +@{strength_mod}, and because we have all the data we can suddenly make initiative dependent on strength as well as dexterity without needing an additional getAttrs. 5) GiGs is correct that this is overkill for many sheets, and even highly interconnected sheets will probably have a couple event listeners that lead down different paths; you don't need to lump your PC listeners and vehicle listeners together if they have wildly different attribute sets. However, I think that there are still many sheets that could benefit from this. Any sheet that approaches the complexity of the 5e sheet would probably benefit from this.