CRP: True Opposed Rolls in Pendragon

Opposed Rolls are laborious to implement in a VTT, but Rolls With Memory offer an interesting way to manage these – though it’s turning out to be pretty complex! This post is the culmination of a month-long series of posts. In the first post, we built a roll for the game, Pendragon. The next post showed how to save that roll and recall it. The last post, we saw how to select a target, and compare their last roll against your last roll.

One drawback there was that both you and your opponent had to have already made the roll to be compared. In this post, we’ll see how to combine all these elements – make a roll, select a target, and compare your roll against theirs. If they haven’t made a previous roll, yours is the only one shown in chat. If they have made a roll, both rolls are shown and the winner is described.

In this way, it doesn’t matter who rolls first. If they roll first, it just shows their roll and when you roll, your roll is compared to theirs. If you roll first, it is shown in chat, and when they roll against you, your roll is shown along with theirs and the winner is revealed.

To go along with this, we need a button to clear the last roll. Either your the GM will use this between roll sequences – say, at the end of a scene. When you make a roll, your last roll is overwritten, but there are times you’ll want to make sure your last roll is cleared.

Clearing the Last Roll

This is really simple so we’ll look at it first. We just need an action button that clears out the saved attribute values, either setting the values to 0 or “”. There are only four values – see the output object.

<button type="action" name="act_clear">Clear Last Roll</button>
<script type="text/worker">
   on('clicked:clear', () => {
      const output = {
         last_rating: '',
         last_score: 0,
         last_modifier: 0,
         last_die: ''
      };
      setAttrs(output);
   });
</script>Code language: HTML, XML (xml)

Other Global Properties

We need to create some global properties that will be used by our functions. The who property is put at the start so we can just define whether the standard operation is to use a character’s name, or their token name, when printing the name. In practice, this might be a configuration setting, or you might want to change which is used based on the situation, but for testing the script it’s nice to have it here at the start of the script block.

<button type="action" name="act_clear">Clear Last Roll</button>
<script type="text/worker">
const who = 'character_name'; // can replace this with 'selected|token_name'
const ratings = {
      sword: "Sword",
      haft: "Hafted Weapon",
      valourous: "Valourous",
      cowardly: "Cowardly",
      loyalty_liege: "Loyalty (Liege)",
      love_family: "Love (Family)"
   }

   on('clicked:clear', () => {
      const output = {
         last_rating: '',
         last_score: 0,
         last_modifier: 0,
         last_die: ''
      };
      setAttrs(output);
   });
</script>Code language: HTML, XML (xml)

The ratings section is just a list of buttons and attributes, and their public name. You’ll need to have a bunch of buttons and attributes- this is described in the first post in this series.

And finally, we have the clear worker. We won’t repeat code like this for the rest of the post – everything from here on goes inside the script block.

The Targeted Roll

We have already seen most of the parts we need to make this work – we just have to combine them here.

The New Rolltemplate

First, we’ll use the rolltemplate from previous posts, with a very slight revision.

<rolltemplate class="sheet-rolltemplate-pendragon">
    <div class="heading">{{computed::who}} rolls {{rating}} </div>
    {{#^rollTotal() computed::opposed 0}}
        <div class="heading">
            vs {{computed::target_who}} ({{computed::target_rating}})
        </div>
    {{/^rollTotal() computed::opposed 0}}
    
    <div class="properties">
        <div class="title">{{rating}}</div>
        <div class="attempt">{{computed::score}}</div>
        <div class="die">{{computed::die}}</div>
        <div class="result">{{computed::modifier}}</div>
        
        {{#^rollTotal() computed::opposed 0}}
        <div class="title">{{computed::target_rating}}</div>
        <div class="attempt">{{computed::target_score}}</div>
        <div class="die">{{computed::target_die}}</div>
        <div class="result">{{computed::target_modifier}}</div>
        <div class="result outcome">{{computed::outcome}}</div>
        {{/^rollTotal() computed::opposed 0}}
    </div>
</rolltemplate>Code language: HTML, XML (xml)

Notice, an expansion to the heading section. We have a new second line, which is conditional (this is why there’s a new opposed key in the roll_string). When the roll is targeted vs an enemy, it now tells us who that enemy is. That will look like this:

Since attacker and defender might be using different skills, we show them both here. But now we need to modify the roll worker to generate those.

The Roll String

When making a skill, stat, trait, or passion roll, it is assumed you are making an opposed roll, and those have a target. (We’ll see later how to built the same roll without targets.)

Object.keys(ratings).forEach(rating => {
    on(`clicked:${rating}`, () => {
        const roll_string = `&{template:pendragon} {{title=[[0]]}} {{rating=${ratings[rating]}}} {{die=[[1d20]]}} {{score=[[@{${rating}}]]}} {{modifier=[[?{modifier|0}]]}} {{who=[[0@{${who}}]] }} {{target_rating=[[0@{target|last_rating}]]}} {{target_die=[[@{target|last_die}]]}} {{target_score=[[@{target|last_score}]]}} {{target_modifier=[[@{target|last_modifier}]]}} {{who=[[0@{character_name}]] }} {{target_who=[[0@{target|token_name}]] }} {{outcome=[[0]]}} {{opposed=[[0]]}}`;
        
        do_roll(roll_string, rating);
    });
});Code language: JavaScript (javascript)

Look at the roll_string – that’s a massive string. We can use the same function to retrieve the last roll, and we don’t need all that.

on('clicked:last', () => {
    getAttrs(['last_rating', 'last_die', 'last_score', 'last_modifier'], v => {
        const roll_string = `&{template:pendragon} {{rating=${v.last_rating}}} {{die=[[${v.last_die}]]}} {{score=[[${v.last_score}]]}} {{modifier=[[${v.last_modifier}]]}} {{who=[[0@{${who}}]] }} {{opposed=[[0]]}}`;

        do_roll(roll_string);
    });
});Code language: JavaScript (javascript)

If we are just doing unopposed rolls, we could use the same worker as the first, but replace the roll_string like so:

        const roll_string = `&{template:pendragon} {{title=[[0]]}} {{rating=${ratings[rating]}}} {{die=[[1d20]]}} {{score=[[@{${rating}}]]}} {{modifier=[[?{modifier|0}]]}} {{who=[[0@{${who}}]] }}`;Code language: JavaScript (javascript)

Notice how all workers now use this format: {{who=[[0@{character_name}]] }}. This is why we use {{computed::who}} in the rolltemplate, and not just {{who}}.

The do_roll Function

The workers above call the do_roll function, which handles the actual roll itself. We put this ins a separate function to avoid duplicating a lot of code. The roll has to handle two different cases:

  1. You are rolling, and the target has not yet rolled. Treat as an unopposed roll.
  2. You are rolling, and the target has rolled. Retrieve target’s values, and present both results and show the winner.

We can also account for situations where you are not rolling, but simply retrieving a saved value. (These are called with do_roll(roll_string) instead of do_roll(roll_string,rating).

This is a mammoth function. We’d normally break the code down into chunks to explain it, but it’s all been done in previous posts. Instead, there are useful comments in the code – here’ the full thing.

const do_roll = (roll_string, rating = '') => { 
    startRoll(roll_string, pendragon => {
        // a custom function to grab an expression like [[0@{character_name}]] and get read of the leading 0.
        const name = (expression) => expression.substring(1);

        // some code is repeated, so we create more functions to simpliy them
        const score_calc = (score_base) => 
            (String(score_base).indexOf("+") !== -1) ? 
            (score_base.split('+')[0] ) : +score_base;
        const score_finish = (score, modifier) => 
            (score > 20 ? `20 +${score-20}` : score) + 
            (modifier != 0 ? ` ${modifier >= 0 ? '+' : ''}${modifier}`: ''); 
        const die_calc = die => String.fromCharCode(96 + die);
        
        //the finishRoll function expects an object.
        // it is convenient to build that separately, so we initialise it here.
        const chat_output = {};

        // lets build the ACTIVE roll        
        const die = pendragon.results.die.result;
        const score = score_calc(pendragon.results.score.result);
        const modifier = pendragon.results.modifier ? 
            pendragon.results.modifier.result : 0;

        const temp_total = score + modifier;
        const target = Math.max(1, Math.min(20, temp_total));
        const bonus = Math.min(20, Math.max(0, temp_total -20));
        const roll = Math.max(0, Math.min(20, die + bonus));
        const result = roll === target ? 'Critical' : 
            roll === 20 ? 'Fumble' : 
            roll > target ? 'Failure' : `Success (${roll})`;

        // if its a new roll, we want to save that.
        if (rating) {
            const output = {
                last_rating: ratings[rating],
                last_score: score,
                last_modifier: modifier,
                last_die: die
            };
            setAttrs(output);
        }
        
        
        const attacker = name(pendragon.results.who.expression);
        chat_output.score = score_finish(score, modifier);
        chat_output.die = die_calc(die);
        chat_output.modifier = result;
        chat_output.who = attacker;
        
        
        // check if target die exists
        const target_die = pendragon.results.target_die ?
           (+pendragon.results.target_die.result || 0) : 0;
        chat_output.opposed = target_die;
        if(target_die > 0) {
            // add in the target ratings
            const defender = name(pendragon.results.target_who.expression);
            
            const target_rating =             
               name(pendragon.results.target_rating.expression);
            const target_score = 
               score_calc(pendragon.results.target_score.result);
            
            const target_modifier = pendragon.results.target_modifier ? 
               (+pendragon.results.target_modifier.result || 0) : 0;

            const result_calc = (score, modifier, die ) => {
                const temp_total = score + modifier;
                const target = Math.max(1, Math.min(20, temp_total));
                const bonus = Math.min(20, Math.max(0, temp_total -20));
                const roll = Math.max(0, Math.min(20, die + bonus));
                const result = roll === target ? 'Critical' : 
                        roll === 20 ? 'Fumble' : 
                        roll > target ? 'Failure' : `Success (${roll})`;
                const effect = roll == target ? 20 : 
                    roll == 20 ? -1 : roll > target ? 0 : Math.min(roll, 20);
                return {result: result, effect: effect}    
            }
            const roll = result_calc(score, modifier, die );
            const target_roll = 
                result_calc(target_score, target_modifier, target_die );
            const outcome = 
                ((!roll.effect || roll.effect == -1) && 
                (!target_roll.effect || target_roll.effect -1)) ? 'Both Fail' :
                (roll.effect ==20 &&  target_roll.effect == 20) ? 'Both Critical' :
                (roll.effect == target_roll.effect) ? "Draw" :
                (roll.effect > target_roll.effect) ? `${attacker} Wins` :
                `${defender} Wins`;

            chat_output.target_rating = 
               target_rating;
            chat_output.target_score = 
               score_finish(target_score, target_modifier);
            chat_output.target_die = die_calc(target_die);
            chat_output.target_modifier = target_roll.result;
            chat_output.outcome = outcome;
            chat_output.target_who = defender;
        }
        finishRoll(pendragon.rollId, chat_output);
    });
};Code language: JavaScript (javascript)

When using this code and the previous sheet workers, the function will fulfil all the previous demands. You can show the last roll, or make new rolls and pick a target for those rolls.

Something To Be Careful Of

When making an opposed roll, you need to make sure the target’s last roll is relevant to your roll. Someone might attack someone, then you attack them and see their last attack is displayed alongside yours. But they have already used that roll! You or the GM should make sure to clear used rolls. That’s the purpose of the Clear Last Roll button above.

Where To Go from Here?

There are some Pendragon-specific complications (like, if you fail a Chaste roll, you must make a Lustful roll – and either of those may still be opposed by an opponent). And the way Passion values drop after a failure. And maybe create the ability to add a modifier to a saved roll – like, you forgot to apply a modifier and want to quickly see what the new roll would be. And maybe the ability to initiate a roll against an opponent, automatically clearing their last roll.

These are things that would be fairly easy to add, but they aren’t necessary to show the basic techniques. They are left as an exercise for the reader!

Leave a Reply

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