Search code examples
pythonenums

Proper way to utilize python's "enums" as flags in an easily entendable manner


I'm picking back up an old personal project involving a dice calculator for TTRPGs like DnD. I'm looking to use a python Enum object as a means of pre-defining several common roll distributions, such as a straight d20 roll, advantage, disadvantage, elven accuracy, etc. The exact details of how I generate these aren't important, but I have some code that looks like this:

class d20(Enum):
    NORMAL = d(20)
    ADVANTAGE = d(20).advantage()
    DISADVANTAGE = d(20).disadvantage()
    ELVEN_ACCURACY = intdist.max(d(20).advantage(), d(20))

So we have this enum, and I am using it for an associated AttackRoll class. Here is some pseudo code for a small part of my attack roll class:

class AttackRoll:

    def __init__(self, attack_modifier, crit_range = 20):
        
        self.crit_chances = [] # some kind of container, this is related to what I'm asking about

        for distribution in d20:
            self.crit_chances.add(distribution, crit_chance(distribution, crit_range))

    def get_crit_chance(self, d20_enum):
        return self.crit_chances[d20_enum]

Here what I'm essentially trying to do is associate a critical hit chance to each kind of d20 roll defined in my enum. But the issue is that I'm having a hard time coming up with a good way of recalling this information in the get function that is both fast and also feels 'pythonic' in a way I'll elaborate on in a sec.

Some of these derived attributes have decent computational overhead compared to the methods they're used in (not crit chance, but other ones I need definitely will be the speed bottleneck) so I definitely do want to cache these commonly used ones*.

One thing I can do is use a dictionary with the enum objects as keys and the values being the derived attributes. This way, it should be O(1) overhead to call my crit chance once my AttackRoll object is made, which is very nice. But now, I lose out on "duck typing" since these functions could in principle work for any d20-like distribution and not just the ones in my d20 enum class. Implementing things this way would amount to saying that my functions work only for the d20 distributions I define them for. A user who wants to use a different one is SOL.

As a novice, I think that a good compromise is to use the dictionary, but use a try/except clause that will run the necessary higher-overhead functions should the provided distribution be outside the scope of my d20 enum. So perhaps something like this psuedo-code:

class AttackRoll:

    def __init__(self, attack_modifier, crit_range = 20):
        
        self.crit_chances = dict()

        for distribution in d20:
            self.crit_chances[distribution] = crit_chance(distribution, crit_range)

    def get_crit_chance(self, d20_enum):
        try:
            return self.crit_chances[d20_enum]
        except KeyError:
            if is_d20_roll_like(d20_enum):
                return crit_chance(distribution, crit_range)
            raise ValueError(...)

Is there something obvious that's better I'm missing? I do quite like this approach because if I wanted to add more kinds of d20 rolls I just need to add their integer distribution object to my enum and everything will just work how I want it to. (I actually forgot about elven accuracy at first and the method I used originally was NOT easy to extend... I built up the different kinds of d20 rolls in the AttackRoll class and used the d20 enum as a proxy for human-readable array indices... This realization was my motivation for writing this question). It also works in a duck-typey way, as anything that looks like a d20 distribution will also just work. I naively think its good but wanted to check before I build everything up around this approach and realize there's some easy-to-see issue that's gonna be a PITA to fix later (which I seem to run into a lot)

*As a sort of part 2 question, I've heard that premature optimization is the root of all evil. I mentioned that the 'setup' methods are going to be the speed bottleneck for a lot of the applications of this attack roll class. But at the end of the day, what you're really doing is adding about 200 microseconds in the worst case (for the more complicated bits I'd cache) to a 10 microsecond function call. These functions are probably being called at most a few thousand times in practice. Should I even be worrying about this to begin with?


Solution

  • The logic of what you are trying to do is not very clear to me. Typically an enum hold identifiers. Your implementation for the d20 looks more like a lookup table. Instead, the enum should simply hold the enumerated types of rolls for any die. If each attack roll is an event, then the attack roll should contain the modifier for that roll.

    from enum import Enum, auto
    
    class RollModifier(Enum):
        NORMAL = auto()
        ADVANTAGE = auto()
        DISADVANTAGE = auto()
        ELVEN_ACCURACY = auto()
    
    
    class Roll:
        def __init__(
            self, 
            die: int=20, 
            modifier: RollModifier=RollModifier.NORMAL, 
            crit_range: int=20
        ) -> None:
        """
        Creates an die roll event.
        """
        self.die = die
        self.modifier = modifier
        self.crit_range = crit_range
        if crit_range > die:
            raise ValueError('Crit range cannot be greater than the die')
    
        def get_crit_chance(self, d20_enum):
            # handle the logic for each enum here.
    
    class AttackRoll(Roll):
        ...
    
    class DefenseRoll(Roll):
        ...
    
    class DamageRoll(Roll):
        ...
    
    class SaveRoll(Roll):
        ...