getSectionIDs and the structure of a Repeating Section

Repeating Sections often require you to do something counter-intuitive, so we’ll describe that here. But first, we have to start with

Let’s say you have a repeating section containing a bunch of weapons. Each weapon has a stat bonus (like @{str_bonus} or @{dex_bonus}) which is added to a weapon bonus, to give a total bonus. The HTML looks like this:

<fieldset class="repeating_weapons">
    <span>Weapon Name</span>
    <input type="text" value="">
    <span>Which Stat?</span>
    <select name="attr_stat_bonus">
       <option selected value="0">None</option>
       <option value="@{str_bonus}">Strength</option>
       <option value="@{dex_bonus}">Dexterity</option>
    </select>
    <span>Weapon Bonus</span>
    <input type="number" name="attr_weapon_bonus" value="0">
    <span>Total Bonus</span>
    <input type="number" name="attr_total_bonus" value="0">
</fieldset>Code language: HTML, XML (xml)

This unrealistically simple repeating section has a bunch of rows, one per weapon, and for each one, adds the stat_bonus to weapon_bonus to give a total_bonus. Now we need a sheet worker to calculate that.

The Bad Example

Our first attempt to do this does a lot of things write. See if you can spot where it goes wrong.

on('change:repeating_weapons:stat_bonus change:repeating_weapons:weapon_bonus  change:dex_bonus change:str_bonus', function() {
    getSectionIDs('repeating_weapons', function(ids) {
        for(var i = 0; i < ids.length; i++) {
            getAttrs(['repeating_weapons_' + ids[i] + '_stat_bonus', 'repeating_weapons_' + ids[i] + '_weapon_bonus'], function(values) {
                var stat = +values['repeating_weapons_' + ids[i] + '_stat_bonus'] || 0;
                var weapon = +values['repeating_weapons_' + ids[i] + '_weapon_bonus'] || 0;
                var total = stat + weapon;
                setAttrs({
                    ['repeating_weapons_' + ids[i] + '_total_bonus']: total
                });
            });
        };
    });
});Code language: JavaScript (javascript)

Updating The Code

Let’s go through several code improvements, and after we shall see the real problem. The example above does work, and so will the version we’ll update here.

First, we use var when defining variables, and should really use let or const.

We also use a for loop, when forEach does the same thing (most of the time) but produces better code:

on('change:repeating_weapons:stat_bonus change:repeating_weapons:weapon_bonus change:dex_bonus change:str_bonus', function() {
    getSectionIDs('repeating_weapons', function(ids) {
        ids.forEach(function (id) {
            getAttrs(['repeating_weapons_' + id + '_stat_bonus',
                      'repeating_weapons_' + id + '_weapon_bonus'], function(values) {
                const stat = +values['repeating_weapons_' + id + '_stat_bonus'] || 0;
                const weapon = +values['repeating_weapons_' + id + '_weapon_bonus'] || 0;
                const total = stat + weapon;
                setAttrs({
                    ['repeating_weapons_' + id + '_total_bonus']: total
                });
            });
        });
    });
});Code language: JavaScript (javascript)

Now, we can replace string concatenation with template literals, like so:

on('change:repeating_weapons:stat_bonus change:repeating_weapons:weapon_bonus  change:dex_bonus change:str_bonus', function() {
    getSectionIDs('repeating_weapons', function(ids) {
        ids.forEach(function (id) {
            getAttrs([`repeating_weapons_${id}_stat_bonus`,
                      `repeating_weapons_${id}_weapon_bonus`], function(values) {
                const stat = +values[`repeating_weapons_${id}_stat_bonus`] || 0;
                const weapon = +values[`repeating_weapons_${id}_weapon_bonus`] || 0;
                const total = stat + weapon;
                setAttrs({
                    [`repeating_weapons_${id}_total_bonus`]: total
                });
            });
        });
    });
});Code language: JavaScript (javascript)

Finally, we can replace all those function statements with arrow functions, like this:

on('change:repeating_weapons:stat_bonus change:repeating_weapons:weapon_bonus change:dex_bonus change:str_bonus', () => {
    getSectionIDs('repeating_weapons', ids => {
        ids.forEach(id => {
            getAttrs([`repeating_weapons_${id}_stat_bonus`,
                      `repeating_weapons_${id}_weapon_bonus`], values => {
                const stat = +values[`repeating_weapons_${id}_stat_bonus`] || 0;
                const weapon = +values[`repeating_weapons_${id}_weapon_bonus`] || 0;
                const total = stat + weapon;
                setAttrs({
                    [`repeating_weapons_${id}_total_bonus`]: total
                });
            });
        });
    });
});Code language: JavaScript (javascript)

All of these examples look different, but they all work. Which one you use is very much a matter of preference. So, what’s the problem?

The Efficiency Problem: A Cardinal Rule

  • A sheet worker should only ever contain one each of getAttrs and setAttrs

All of the examples above work, but they contain a massive problem. They perform one getAttrs and one setAttrs for every row of the repeating section. If you have ten weapons, that’s ten getAttrs and ten setAttrs every time this sheet worker is triggered.

The problem here is that these two functions are asynchronous functions: they cannot run until the roll20 servers are contacted. That means the campaign sends a request to the roll20 servers and waits for a reply every time one of those functions is run. This is much, much slower than the rest of the code in your sheet, and if the roll20 servers are congested (lots of people playing that night), they may be put in a long queue and struggle to finish in a timely manner. When this happens, your sheet will lag – it cannot complete the worker until it gets a reply – twenty replies in this case.

By reducing the number of asynchronous functions, you massively reduce this source of lag. Also, there is no functional difference between a getAttrs or setAttrs that asks for a hundred attributes over one that asks for a single attribute, so you may as well bundle them up and run them all at once.

(There is a difference, but it is not really noticeable.)

The Fixed Sheet Worker: Loop and Loop Again

So, we have gone through a series of changes to update the code to more modern standards. There are reasons for those changes, but they won’t change the performance of the sheet. The original code is just as good as the final code in all practical ways.

But changing the way asynchronous functions are used makes a massive difference. You should always obey that cardinal rule. Here we’ll show how to do that.

Here we’ll do it with the original sheet worker, and then the final worker.

on('change:repeating_weapons:stat_bonus change:repeating_weapons:weapon_bonus change:dex_bonus change:str_bonus', function() {
    getSectionIDs('repeating_weapons', function(ids) {
        var fields = [];
        for(var i = 0; i < ids.length; i++) {
            fields.push(
                'repeating_weapons_' + ids[i] + '_stat_bonus', 
                'repeating_weapons_' + ids[i] + '_weapon_bonus'
            );
        };
        getAttrs(fields, function(values) {
            for(var i = 0; i < ids.length; i++) {
                var stat = +values['repeating_weapons_' + ids[i] + '_stat_bonus'] || 0;
                var weapon = +values[w'repeating_weapons_' + ids[i] + '_weapon_bonus'] || 0;
                output['repeating_weapons_' + ids[i] + '_total_bonus'] = stat + weapon;
            };
            setAttrs(output);
        });
    });
});Code language: JavaScript (javascript)

Look carefully at this worker to see what we’ve done. Notice that we created one loop to create an array of all the used attributes inside the repeating section, then in getAttrs grabbed all of those attributes at once.

Then inside the getAttrs we performed another loop to go through the rows of the repeating section again. We need to build the relevant attribute names again, but we know they are saved in values because we have done it once.

Finally, we save any changed attributes in a single setAttrs call – this is outside the loop. We do this by creating a special object variable, output, to hold the totals for all the rows, setAttrs expects a variable in the same format, so we supply it with output, and there we go, It’s done.

Here’s the same sheet worker using more modern code.

const section_name = (section, id, field) => `repeating_${section}_${id}_${field}`;
on('change:repeating_weapons:stat_bonus change:repeating_weapons:weapon_bonus change:dex_bonus change:str_bonus', function() {
    getSectionIDs('repeating_weapons', ids => {
        const fields = ids.reduce((all, id) => [...all, section_name('weapons', id, 'stat_bonus'), 
                section_name('weapons', id, 'weapon_bonus')], []);
        getAttrs(fields, values => {
            const output = {};
            ids.forEach(id => {
                const stat = +values[section_name('weapons', id, 'stat_bonus')] || 0;
                const weapon = +values[section_name('weapons', id, 'weapon_bonus')] || 0;
                output[section_name('weapons', id, 'total_bonus')] = stat + weapon;
            });
            setAttrs(output);
        });
    });
});Code language: JavaScript (javascript)

This version includes the custom function section_name and an example of the reduce function. But you can see the procedure is the same.

The Basic Method: Loop then Loop Again

The basic idea is quite complicated to explain, but it’s fairly simple in practice.

  1. Go a getSectionIDs first
  2. Immediately use the section IDs to loop through every row, and grab all the attributes you need from every row, and push them to a fields array.
  3. Now do getAttrs, using the fields array you just created.
  4. Create a variable, output, to hold anything you want to save.
  5. Now loop again, using the attributes you just created to perform whatever calculations you need, and save them to your output variable.
  6. Finally, call setAttrs with your output variable, and you’re done.

The basic structure looks something like this:

on('change:repeating_section:field', () => {
    getSectionIDs('repeating_section', ids => {
        const fields = [];
        ids.forEach(id => {
            fields.push(`repeating_section_${id}_field`);
            /* this can be repeated for each attribute you need */
        });
        getAttrs(fields, values => {
            const output = {};
            ids.forEach(id => {
                /* do whatever you need to do, including saving to output */
                output[`repeating_section_${id}_final`] = calculation;
            });
            setAttrs(output);
        });
    });
});Code language: JavaScript (javascript)

If you are saving to a stat outside the repeating section, that output line might instead be something like:

                output[`stat_name`] = calculation + output[`stat_name`];Code language: JavaScript (javascript)

If you have extra attributes outside of the repeating section, you need to change the getAttrs line like this:

        getAttrs([...fields, 'global_stat'], values => {Code language: JavaScript (javascript)

This uses the spread operator (…) to ‘spread’ out each item in the fields array, and let you use getAttrs normally.

And there you have it – a ‘simple’ way to build repeating sections efficiently.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.