Stendarpaval / mob-attack-tool

A module for Foundry VTT that offers a tool for handling mob attacks in the dnd5e system.
GNU General Public License v3.0
7 stars 14 forks source link

Improve multiattack detection algorithm #18

Open Stendarpaval opened 3 years ago

Stendarpaval commented 3 years ago

One of Mob Attack Tool's features is to automatically interpret how the multiattack ability works of monsters. I wrote an algorithm to accomplish this, which you can find in multiattack.js.

The algorithm currently doesn't always parse the correct number of weapons, or it selects only some of the weapons that are part of the multiattack. In some cases it even selects the wrong weapons. This is currently the case for lycanthrope monsters (so werewolves, wererats, werebears, wereboars and weretigers), veteran, bandit captain, the erinyes, and so on.

There are 142 monsters in the SRD with the multiattack ability and I have not systematically checked which ones the algorithm works for and for which ones it doesn't. I suspect there to be at least a dozen, possibly several dozen multiattack descriptions that the algorithm interprets incorrectly. You might wonder why I don't just add a big table of monsters and check by name. The reason is that many DMs change or make their own monsters, and I want Mob Attack Tool to correctly interpret the multiattack descriptions in these cases too.

To state the goal clearly: I would like to improve the accuracy of the algorithm, especially for monsters with a lower Challenge Rating (CR). I welcome pull requests that make the algorithm interpret more multiattack descriptions correctly, without it becoming worse at interpreting the descriptions it currently is good at interpreting. (Edit: Please note that it doesn't need to be perfect. I'm already really happy if 2 or 3 of the "Bad" examples from the examples below are interpreted correctly.)

I don't have specific style guides to follow, since I barely know of their existence. I'm not a professional programmer, after all. As long as I can generally interpret what your code is doing, I'll be more than content. In fact, I'll probably learn more from you than the other way around!

Examples

Click on the expandable section below to see some examples. In the "Good" examples, the algorithm worked as intended. In the "Bad" examples, the algorithm got something wrong.

Table of examples
Good examples Bad examples
MAT-good-example-spy MAT-bad-example-werewolf
MAT-good-example-scout MAT-bad-example-erinyes
MAT-good-example-thug MAT-bad-example-lizardfolk
MAT-good-example-dragon MAT-bad-example-veteran
MAT-good-example-brown-bear MAT-bad-example-bandit-captain

Multiattack description data

The "training data" I used to design the current algorithm is based on the multiattack descriptions of all the monsters in Systems Reference Document (SRD) of D&D5e. The dnd5e system for Foundry VTT contains a compendium of these monsters.

You can specifically extract these descriptions using this macro in a Foundry VTT game world that uses the dnd5e system:

// read compendium
const compendiumName = 'dnd5e.monsters';
const compMonsters = await game.packs.get(compendiumName).getDocuments();

// extract multiattack descriptions (this may take a few seconds)
let multiattackCollection = [];
for (let monster of compMonsters) {
    if (monster.items.filter(i => i.name.startsWith("Multiattack")).length > 0) {
        multiattackCollection.push({name: monster.name, multiattack: monster.items.filter(i => i.name.startsWith("Multiattack"))[0].data.data.description.value});
    }
}
console.log(multiattackCollection);

// create a new item and set the multiattack description data as a flag on that item
const data = [{name: "Multiattack data", type: "weapon"}];
const created = await Item.create(data);
await game.items.getName("Multiattack data").setFlag("mob-attack-tool","multiattackData",multiattackCollection);

The macro above creates a new item called "Multiattack data". Right-click the item and select "Export Data" to obtain a JSON file. You'll find the multiattack descriptions in that JSON file.

For the sake of convenience, I'll also add a txt-file with the descriptions: dnd5eSRDmultiattackDescriptions.txt

benbarbour commented 3 years ago

I'll try to get to this sometime, but I really can't promise anything. I'm a programmer, but also a new(ish) father and barely have time to play D&D with my wife and friends, let alone code in my spare time. :( However, I can suggest an alternate approach you could use if I can't get to it. Can't promise it'll work better, but it's what I'd try first. Here's some dirty psuedocode:

function parseMultiattack(actor)
   if actor does not have multiattack then return

   multiattack_items = []
   for each of the actor's items, item
       if actor.multiattack.text.lowercase() contains item.name.lowercase()
           num = parseMultiattackForItem(actor.multiattack, item)
           if num === undefined
               num = 1 // To cover cases like "The dragon can use it's Frightful Presence"
           if num > 0:
               multiattack_items.append({num, item})

   if multiattack_items is empty: // couldn't find specific matches for any of the actor's items
       // use a regex to look for matches of "makes <num> <melee|ranged> attacks", like the Eryinyes and Lizardfolk entries.
       // Then just add one match for every attack item that the actor has, filtering on the "melee|ranged" group if it was found.

  function parseMultiattackForItem(ma, item)
    itemName = item.name.lowercase()

    // This extracts any number that appears between one and three words before the itemName.
    // This matches strings like "one with its bite" or "two with its claws"
    // Note using "a" and "an" as aliases for 1 should catch the Veteran's "a shortsword attack".
    regex = new Regex("(an?|one|two|three|...)\W+(?:\w+\W+){0,2}?" + itemName)

    if regex.match(ma.text.lowercase())
        return stringToNum[regex.group(1)]

How well it works will really depend on how consistent the multiattack text is with using the exact name of the features it uses. You might need to also search for optional pluralizations or whatever.

For the Bandit Captain, you might want to split the string on ". Or" before running each section through the algorithm, and then you could show each section with an line between them or something. I'm not a UX guy. :P

What's wrong with the Erinyes entry, by the way? It just says she makes three attacks, presumably with any weapon, right?

benbarbour commented 3 years ago

Oh, if you're new to regex, here's what I used to mock out my example: regexr.com/5vv4k - just change the item name in the expression at the top (the last word) to try it with different items. Then mouse over the highlighted matches to see that group 1 equals the number part of the match.

Stendarpaval commented 3 years ago

Hey! I'm happy to have someone help me out with this, even if it's just brainstorming. Thanks for already taking a look! Edit: almost forgot, congratz on becoming a father! I totally understand that you don't have as much time to code. My available coding time is also reduced due to resuming DM duties as well as school projects. (Which is why this message is pretty long, because just like code it takes more time to write a compact message.)

I'm going to step through your pseudocode and give my thoughts on your input. You can read that below, but it's mostly a stream of my consciousness rather than an in-depth review.

Click to show my thoughts on your pseudocode
``` function parseMultiattack(actor) if actor does not have multiattack then return ``` So the current method `getMultiattackFromActor` specifically looks at a single item and returns an array with two values: the number of attacks the creature can do with that item and a boolean that indicates whether or not the attack should be automatically checked when starting Mob Attack Tool. From what I can tell your pseudocode would return all the multiattack weapon options of a single actor at once, instead of per weapon. That's an interesting idea, though it'd need minor changes outside of `multiattack.js`. (Not opposed to that.) If the actor doesn't have multiattack, then the function should at least return something to indicate that. (Bit of a nitpick, I'll admit.) ``` multiattack_items = [] for each of the actor's items, item if actor.multiattack.text.lowercase() contains item.name.lowercase() num = parseMultiattackForItem(actor.multiattack, item) if num === undefined num = 1 // To cover cases like "The dragon can use it's Frightful Presence" if num > 0: multiattack_items.append({num, item}) ``` As I addressed above, the current function looks at a single item which was passed to it as an argument. That single item actually stems from an array of items that were already selected outside of `multiattack.js`. Perhaps I should make a new function in `utils.js` that does that and export it so that it's available in `multiattack.js` too. Anyway, if analyzing all multiattack options for a single actor at once then this structure would be suitable. Probably still needs to return the `multiattack_items` array. Might need to use an Object instead in order to also denote which item should be checked. Unless the assumption is that `multiattack_array` is compared to an array of all weapon options outside of `multiattack.js`... But I'd prefer it if that was still done in there. ``` if multiattack_items is empty: // couldn't find specific matches for any of the actor's items // use a regex to look for matches of "makes attacks", like the Eryinyes and Lizardfolk entries. // Then just add one match for every attack item that the actor has, filtering on the "melee|ranged" group if it was found. function parseMultiattackForItem(ma, item) itemName = item.name.lowercase() // This extracts any number that appears between one and three words before the itemName. // This matches strings like "one with its bite" or "two with its claws" // Note using "a" and "an" as aliases for 1 should catch the Veteran's "a shortsword attack". regex = new Regex("(an?|one|two|three|...)\W+(?:\w+\W+){0,2}?" + itemName) if regex.match(ma.text.lowercase()) return stringToNum[regex.group(1)] ``` I'll just take the rest of the pseudocode as a single block, since the contents are centered around using regular expressions. I've heard about and experimented with regex before, but I've never really gotten proficient with them. Back when I made this issue, I thought that regex would probably make it simpler, but I didn't know how to even start with it, lol.

So it's great that you suggest to use regex, since you're likely more knowledgeable about them than I am! That RegExr website is a great tool, btw, which I'll look into deeper.

How well it works will really depend on how consistent the multiattack text is with using the exact name of the features it uses.

Unfortunately, there is not perfect consistency between names that the multiattack text uses for weapons, spells, and features and the names that the actual weapon, spell, and feature names on actor and NPC sheets. The lycanthropes are an example of this mismatch, because the item names are appended with usage limitations.

You might need to also search for optional pluralizations or whatever.

Thankfully the English language is fairly simple when it comes to plural versions of nouns: just add an s (most of the time). The current implementation accounts for that.

For the Bandit Captain, you might want to split the string on ". Or" before running each section through the algorithm, and then you could show each section with an line between them or something. I'm not a UX guy. :P

Good that you bring up the question of how to display different multiattack options. One reason I decided to add Multiattack descriptions inside Mob Attack Tool was as a stopgap measure to allow users to know what the alternative option is, if any. For the Bandit Captain, stopping the analysis at ". Or" would work, but there are other creatures (like the Tarrasque) that only discuss some weapon options after starting a new phrase that includes "instead of", or "in place of" (like the Wight). These will cause similar issues.

For now I think it's fine if the first described multiattack option (let's call that the "default" multiattack option) is selected correctly. That brings me to your next sentence:

What's wrong with the Erinyes entry, by the way? It just says she makes three attacks, presumably with any weapon, right?

The Erinyes makes three attacks, so if she used a single weapon type than than the number to the left of the weapon icon should be 3. I suspect that all her weapon options have "1" as the number of attacks, because the current implementation will set them to "1" if the number of weapons in their inventory matches the number of attacks they get with their multiattack ability. Specifically, this probably only happens if the multiattack only says how many attacks they can make in general, without being more specific. The "correct" way should have been to have the strongest weapon used 3 times, or to check all the checkboxes on the right to ensure that 3 attacks are made.

Interestingly enough, the Lizardfolk has the opposite problem. :)

TL;DR

Once again, thanks for looking into this! I'm definitely interested in using regex to solve this. I'd enjoy it if you could provide more tips or examples on how to use them for this.