import warnings
import random
from collections import namedtuple, Counter
from copy import copy, deepcopy
from typing import Tuple, List
from itertools import product
from nltk.probability import FreqDist
DICE_TUPLE = Tuple[int, int] # pylint: disable=invalid-name
TYPE_ROLL_RESULT = Tuple[int, int] # pylint: disable=invalid-name
[docs]
class Dice:
"""
This class represents a die
"""
__hash__ = None # Tell the interpreter that instances of this class cannot be hashed
def __init__(self, **kwargs):
"""
Validate the input and set the instance variables
:param kwargs: keyword arguments. Valid arguments are as follows:
:param dice_num: the number of dice
:type dice_num: positive integer
:param dice_type: the number of faces on the die
:type dice_type: positive integer
:param dice_tuple: a tuple of format *dice_num*, *dice_type*
:type dice_tuple: tuple of 2 positive integers
:param str_val: a string representation; e.g., "1d6"
:type str_val: str
:param modifier: modifier to rolls with this die
:type modifier: int
:param adv: indicates the advantage the die rolls with (positive is advantage, negative is disadvantage, 0 is neither)
:type adv: int
:param critable: indicates whether the dice can roll a critical success or critical fail
:type critable: bool
:raise: ValueError if input is invalid
"""
self._dice_tuple = kwargs.get("str_val")
if self._dice_tuple:
try:
self._dice_tuple = tuple(int(x) for x in self._dice_tuple.split("d"))
except ValueError:
raise ValueError("Must provide dice as tuple of two ints dice_tuple=(1, 6) "
"or dice_num=1 and dice_type=6 or string format str_val=1d6")
else:
self._dice_tuple = kwargs.get("dice_tuple", (kwargs.get("dice_num", 1), kwargs.get("dice_type")))
try:
if len(self._dice_tuple) != 2:
raise ValueError("Must provide dice as tuple of exactly two ints dice_tuple=(1, 6) "
"or dice_num=1 and dice_type=6 or string format str_val=1d6")
except TypeError:
raise ValueError("Must provide dice as tuple of exactly two ints dice_tuple=(1, 6) "
"or dice_num=1 and dice_type=6 or string format str_val=1d6")
if not isinstance(self._dice_tuple[0], int) or not isinstance(self._dice_tuple[1], int) \
or self._dice_tuple[0] < 0 or self._dice_tuple[1] < 0:
raise ValueError("Must provide dice as tuple of two non-negative integers dice_tuple=(1, 6) "
"or dice_num=1 and dice_type=6 or string format str_val=1d6")
self._modifier = kwargs.get("modifier", 0)
if not isinstance(self._modifier, int):
raise ValueError("Modifier must be an int")
self._adv = kwargs.get("adv", 0)
if not isinstance(self._adv, int):
raise ValueError("Adv must be an int")
self._critable = bool(kwargs.get("critable"))
self._freqdist = None
self.create_probdist()
def __eq__(self, other) -> bool:
"""
Compare *self* and *other* to determine if they are equal based on the following characteristics:
dice_tuple, critable, modifier
:param other: the Dice to be compared
:type other: Dice
:return: True if *self* equals *other*, False otherwise
:rtype: bool
"""
if other is self:
return True
if type(other) != type(self): # pylint: disable=unidiomatic-typecheck
return False
return self.get_dice_tuple() == other.get_dice_tuple() \
and self.get_critable() == other.get_critable() \
and self.get_adv() == other.get_adv() \
and self.get_modifier() == other.get_modifier()
[docs]
def get_dice_tuple(self) -> DICE_TUPLE:
"""
:return: self._dice_tuple
:rtype: tuple of positive integers (dice num, dice type)
"""
return self._dice_tuple
[docs]
def get_dice_num(self) -> int:
"""
:return: dice num (the first part of the dice tuple)
:rtype: positive integer
"""
return self.get_dice_tuple()[0]
[docs]
def get_dice_type(self) -> int:
"""
:return: dice type (the second part of the dice tuple)
:rtype: positive integer
"""
return self.get_dice_tuple()[1]
[docs]
def get_modifier(self) -> int:
"""
:return: modifier
:rtype: int
"""
return self._modifier
[docs]
def get_adv(self) -> int:
"""
:return: adv
:rtype: int
"""
return self._adv
[docs]
def get_critable(self) -> bool:
"""
:return: True if a critical roll makes sense, False otherwise
:rtype: bool
"""
return self._critable
[docs]
def get_max_value(self) -> int:
"""
:return: the maximum number that could result from a roll
:rtype: int
"""
return self.get_dice_num() * self.get_dice_type() + self.get_modifier()
[docs]
def get_min_value(self) -> int:
"""
:return: the minimum number that could result from a roll
:rtype: int
"""
return self.get_dice_num() + self.get_modifier()
[docs]
def get_average_value(self) -> float:
"""
Get the average value of the dice
.. Note::
this assumes a fair die (i.e., each number the die can roll, or outcome, is equally likely)
if self._adv is 0 (i.e., if the dice does not have advantage or disadvantage).
If self._adv is not 0, there is a small error margin (less than 10^-16 for a d20) due to floating point arithmetic
Formula and explanation found at https://anydice.com/articles/dice-and-averages/
:return: the average number that would result from a roll
:rtype: float
"""
if self.get_adv() == 0: # in this case, all outcomes are equally likely
dice_num = self.get_dice_num()
dice_type = self.get_dice_type()
result = (dice_num + (dice_num * dice_type)) / 2
result += self.get_modifier()
else:
result = 0
for num in range(self.get_min_value(), self.get_max_value() + 1):
result += num * self.get_freq(num)
result /= self.get_freqdist().N()
return result
[docs]
def get_freqdist(self) -> FreqDist:
"""
:return: the probability distribution for the rolls of this dice
:rtype: FreqDist
"""
return self._freqdist
[docs]
def get_freq(self, sample: int) -> int:
"""
Get the number of possible outcomes (roll combinations) that give a desired result
:param sample: a number that could be rolled on this dice
:type sample: int
:return: the number of possible outcomes (roll combinations) that give a result of *sample*
"""
return self.get_freqdist()[sample]
[docs]
def get_prob(self, sample: int, kind: str = "eq") -> float:
"""
Get the probability of rolling a given number on this dice.
:param sample: a number that could be rolled on this dice
:param kind: eq (equal), lt (less than), le (less than or equal to), gt, or ge.
:return: the probability of rolling *sample*
:rtype: float
"""
prob = 0
if kind == "eq":
prob = self.get_freqdist().freq(sample)
else:
if kind == "lt":
min_val = self.get_min_value()
max_val = sample
elif kind == "le":
min_val = self.get_min_value()
max_val = sample + 1
elif kind == "gt":
min_val = sample + 1
max_val = self.get_max_value() + 1
else: # kind == "ge"
min_val = sample
max_val = self.get_max_value() + 1
for num in range(min_val, max_val):
prob += self.get_freq(num) # count the number of desired roll outcomes
prob /= self.get_freqdist().N() # divide by the number of possible roll outcomes
return prob
[docs]
def create_probdist(self):
"""
Create the dict that stores the (numerators for) probability of getting a certain result from the dice
"""
self._freqdist = FreqDist()
possible_nums = list(range(1, self.get_dice_type() + 1))
rep = self.get_dice_num()
if self.get_adv() != 0: # for advantage or disadvantage, roll an extra die
rep *= 2 # roll every die twice (and choose one of the two)
for roll_combo in product(possible_nums, repeat=rep):
if self.get_adv() == 0:
total = sum(roll_combo)
else:
rev = self.get_adv() > 0 # in case of advantage, give n largest. in disadvantage, give n smallest.
total = sum(sorted(roll_combo, reverse=rev)[:self.get_dice_num()])
total += self.get_modifier()
self._freqdist[total] += 1
[docs]
def set_modifier(self, modifier: int):
"""
Set :py:attr:`_modifier` to the given value
:param modifier: the new modifier
:type modifier: int
:return: None
:raise: ValueError if *modifier* is invalid
"""
if not isinstance(modifier, int):
raise ValueError("Modifier must be an int")
self._modifier = modifier
self.create_probdist()
[docs]
def shift_modifier(self, modifier: int):
"""
Add *modifier* to :py:attr:`_modifier`
:param modifier: the amount to shift by
:type modifier: int
:return: None
:raise: ValueError if *modifier* is invalid
"""
if not isinstance(modifier, int):
raise ValueError("Modifier must be an int")
self._modifier += modifier
self.create_probdist()
[docs]
def set_adv(self, adv: int):
"""
Set :py:attr:`_adv` to the given value
:param adv: the new advantage
:type adv: int
:return: None
"""
if not isinstance(adv, int):
raise ValueError("Adv must be an int")
self._adv = adv
self.create_probdist()
[docs]
def shift_adv(self, adv: int):
"""
Add *adv* to :py:attr:`_adv`
:param adv: the amount to shift by
:type adv: int
:return:
"""
if not isinstance(adv, int):
raise ValueError("Adv must be an int")
if adv == 0:
return # don't do extra work
self._adv += adv
self.create_probdist()
[docs]
def roll_dice(self, modifier: int = 0, adv: int = 0, crit: int = 0, crit_multiplier: int = 2) -> TYPE_ROLL_RESULT:
"""
Roll dice (applying advantage as specified), add the modifier (but don't go below 0),
and return a result that includes the total number and an integer that indicates whether or not the roll was a crit
:param modifier: the modifier to add after all dice have been rolled
:type modifier: int
:param adv: indicates whether this roll has advantage
:type adv: one of these integers: -1, 0, 1
:param crit: indicates whether to roll more dice (e.g., because a crit was rolled)
:type crit: one of these integers: -1, 0, 1
:param crit_multiplier: the multiplier for dice num if the hit is a crit
:type crit_multiplier: positive integer
:return: a tuple that contains the total roll value as well as an indicator of whether the roll was a crit or not
:rtype: :py:const:`TYPE_ROLL_RESULT`
"""
roll_val = 0
crit_status = 0
adv += self.get_adv()
num = self.get_dice_num()
dice_type = self.get_dice_type()
try:
modifier += self.get_modifier()
except TypeError:
raise ValueError("Modifier must be an int")
if num > 1 and self.get_critable():
warnings.warn("Rolling multiple critable dice in one go is currently not supported. "
"Crit information will match the last die rolled.")
if crit == 1: # crit success
num *= crit_multiplier # roll more dice as specified
for i in range(num): # pylint: disable=unused-variable
nat_roll = random.randint(1, dice_type)
if adv != 0:
roll_2 = random.randint(1, dice_type)
if adv > 0:
nat_roll = max(nat_roll, roll_2)
else:
nat_roll = min(nat_roll, roll_2)
if self.get_critable():
if nat_roll == dice_type:
crit_status = 1 # crit success
elif nat_roll == 1:
crit_status = -1 # crit fail. WARNING: -1 evaluates to true. Use this method appropriately.
roll_val += nat_roll
total_val = roll_val + modifier
total_val = max(total_val, 0)
return total_val, crit_status
[docs]
class NullDice(Dice):
"""
For when you need a dice, but you don't actually care about the results
"""
def __init__(self, **kwargs):
kwargs.update({"dice_tuple": (0, 0), "modifier": 0}) # ensure that the dice will be rolled a total of 0 times
super().__init__(**kwargs)
[docs]
class DamageDice(Dice):
"""
This class represents a damage die (includes a regular die and a damage type)
"""
def __init__(self, **kwargs):
"""
Validate the input and set the instance variables
:param kwargs: keyword arguments. Some of the keyword arguments are overridden by this class.
:param damage_type: damage type
:type damage_type: str
:param dice_num: the number of dice
:type dice_num: positive integer
:param dice_type: the number of faces on the die
:type dice_type: positive integer
:param dice_tuple: a tuple of format *dice_num*, *dice_type*
:type dice_tuple: tuple of 2 positive integers
:param str_val: a string representation; e.g., "1d6"
:type str_val: str
:param modifier: modifier to rolls with this die
:type modifier: int
:param critable: indicates whether the dice can roll a critical success or critical fail
:type critable: bool
:raise: ValueError if input is invalid
"""
super().__init__(**kwargs) # review later to see if this should be super or if it should be Dice
self._damage_type = kwargs.get("damage_type")
if not isinstance(self._damage_type, str):
self._damage_type = ""
def __eq__(self, other) -> bool:
"""
Compare *self* and *other* to determine if they are equal based on the superclass method and these attributes:
damage_type
:param other: the DamageDice to compare
:type other: DamageDice
:return: True if *self* equals *other*, False otherwise
:rtype: bool
"""
return super().__eq__(other) \
and self.get_damage_type() == other.get_damage_type()
[docs]
def get_damage_type(self) -> str:
"""
:return: damage type
:rtype: str
"""
return self._damage_type
[docs]
def has_damage_type(self, damage_type: str) -> bool:
"""
:param damage_type: the damage type searched for
:type damage_type: str
:return: True if *self* has the damage type specified
:rtype: bool
"""
return damage_type == self.get_damage_type()
[docs]
def roll_dice(self, modifier: int = 0, adv: int = 0, crit: int = 0, crit_multiplier: int = 2) -> Tuple:
"""
Roll dice (applying advantage as specified), add the modifier (but don't go below 0),
and return a namedtuple that contains the roll number and damage type
:param modifier: the modifier to add after all dice have been rolled
:type modifier: int
:param adv: indicates whether this roll has advantage
:type adv: one of these integers: -1, 0, 1
:param crit: indicates whether to roll more dice
:type crit: one of these integers: -1, 0, 1
:param crit_multiplier: the multiplier for dice num if the hit is a crit
:type crit_multiplier: positive integer
:return: a tuple that contains the total roll value as well as an indicator of whether the roll was a crit or not
:rtype: namedtuple that contains fields "roll_number" and "damage_type"
"""
template_nametup = namedtuple("damage_tuple", ["roll_number", "damage_type"])
result_nametup = template_nametup(roll_number=super().roll_dice(modifier=modifier, adv=adv,
crit=crit, crit_multiplier=crit_multiplier)[0], damage_type=self.get_damage_type())
return result_nametup
[docs]
class NullDamageDice(NullDice, DamageDice):
"""
For when you need DamageDice but don't care about the result (e.g., in an Attack that doesn't deal damage)
"""
def __init__(self, **kwargs):
"""
Create the dice
"""
if not kwargs.get("damage_type"):
kwargs.update({"damage_type": ""})
super().__init__(**kwargs)
[docs]
class DiceBag(Dice):
"""
Container class that holds multiple Dice
"""
def __init__(self, **kwargs): # pylint: disable=super-init-not-called
"""
Validate the input and set the instance variables
:param kwargs: keyword arguments. Valid arguments are as follows:
:param dice_list: list of Dice, in the order in which they will be rolled
:type dice_list: list of Dice
:raise: ValueError if input is invalid
"""
dice_list = kwargs.get("dice_list")
if not isinstance(dice_list, (list, tuple)):
raise ValueError("Dice list must be a list or tuple")
self._dice_list = []
for die in dice_list:
self.add_dice(die)
if len(self._dice_list) < 2:
raise ValueError("DiceBag must contain 2 or more Dice")
self.create_probdist()
def __eq__(self, other) -> bool:
"""
Compare *self* and *other* to determine if they are equal based on these attributes:
:py:attr:`dice_list`
:param other: the DiceBag to compare
:type other: DiceBag
:return: True if *self* equals *other*, False otherwise
:rtype: bool
"""
if other is self:
return True
if type(other) != type(self): # pylint: disable=unidiomatic-typecheck
return False
return self.get_dice_list() == other.get_dice_list()
[docs]
def get_dice_list(self) -> list:
"""
:return: dice list
:rtype: list of Dice
"""
return self._dice_list
[docs]
def get_max_value(self) -> int:
"""
:return: the maximum number that could result from rolls of all dice combined
:rtype: int
"""
result = 0
for die in self.get_dice_list():
result += die.get_max_value()
return result
[docs]
def get_min_value(self) -> int:
"""
:return: the minimum number that could result from rolls of all dice combined
:rtype: int
"""
result = 0
for die in self.get_dice_list():
result += die.get_min_value()
return result
[docs]
def get_average_value(self) -> float:
"""
:return: the average number that would result from rolls of all dice combined
:rtype: float
"""
result = 0
for die in self.get_dice_list():
result += die.get_average_value()
return result
[docs]
def create_probdist(self):
"""
Create the dict that stores the (numerators for) probability of getting a certain result from the dice
:return:
"""
self._freqdist = FreqDist()
single_rolls = [die.get_freqdist().keys() for die in self.get_dice_list()]
for roll_combo in product(*single_rolls):
total = sum(roll_combo)
freq = 1
for i in range(len(roll_combo)): # pylint: disable=consider-using-enumerate
freq *= self.get_dice_list()[i].get_freq(roll_combo[i])
self._freqdist[total] += freq
[docs]
def add_dice(self, die):
"""
Append the given die to :py:attr:`_dice_list`
:param die: the die to add
:return: None
:raise: ValueError if input is invalid
"""
if not isinstance(die, Dice):
raise ValueError("Die must be a Dice")
self._dice_list.append(die)
[docs]
def set_modifier(self, modifier: int):
"""
Set modifier for all Dice in the bag to the specified modifier
:param modifier: modifier
:type modifier: int
:return: None
"""
for die in self.get_dice_list():
die.set_modifier(modifier)
self.create_probdist()
[docs]
def shift_modifier(self, modifier: int):
"""
Add *modifier* to each die's modifier
:param modifier: the amount to shift by
:type modifier: int
:return: None
:raise: ValueError if *modifier* is invalid
"""
for die in self.get_dice_list():
die.shift_modifier(modifier)
self.create_probdist()
[docs]
def set_adv(self, adv: int):
"""
Set adv for all Dice in the bag to the specified value
:param adv: adv
:type adv: int
:return: None
"""
for die in self.get_dice_list():
die.set_adv(adv)
self.create_probdist()
[docs]
def shift_adv(self, adv: int):
"""
Add *adv* to each die's adv
:param adv: the amount to shift by
:type adv: int
:return: None
:raise: ValueError if *adv* is invalid
"""
for die in self.get_dice_list():
die.shift_adv(adv)
self.create_probdist()
[docs]
def roll_dice(self, modifier: int = 0, adv: int = 0, crit: int = 0, crit_multiplier: int = 2) -> Tuple:
"""
Roll dice (applying advantage as specified), add the modifier (but don't go below 0),
and return the result
:param modifier: the modifier to add after all dice have been rolled
:type modifier: int
:param adv: indicates whether this roll has advantage
:type adv: one of these integers: -1, 0, 1
:param crit: indicates whether to roll more dice (applies to each individual die)
:type crit: one of these integers: -1, 0, 1
:param crit_multiplier: the multiplier for dice num if the hit is a crit (applies to each individual die)
:type crit_multiplier: positive integer
:return: a tuple that contains the total roll value as well as an indicator of whether the roll was a crit or not
:rtype: tuple (total number, list of all numbers rolled)
"""
total_num = 0
result_list = []
for die in self.get_dice_list():
num = die.roll_dice(modifier=modifier, adv=adv, crit=crit, crit_multiplier=crit_multiplier)[0]
total_num += num
result_list.append(num)
return total_num, result_list
[docs]
def get_dice_tuple(self):
"""
:raise: NotImplementedError
"""
raise NotImplementedError("get_dice_tuple not implemented for DiceBag")
[docs]
def get_dice_num(self):
"""
:raise: NotImplementedError
"""
raise NotImplementedError("get_dice_num not implemented for DiceBag")
[docs]
def get_dice_type(self):
"""
:raise: NotImplementedError
"""
raise NotImplementedError("get_dice_type not implemented for DiceBag")
[docs]
def get_modifier(self):
"""
:raise: NotImplementedError
"""
raise NotImplementedError("get_modifier not implemented for DiceBag")
[docs]
def get_adv(self):
"""
:raise: NotImplementedError
"""
raise NotImplementedError("get_adv not implemented for DiceBag")
[docs]
def get_critable(self):
"""
:raise: NotImplementedError
"""
raise NotImplementedError("get_critable not implemented for DiceBag")
[docs]
class DamageDiceBag(DiceBag, DamageDice):
"""
A class for a DiceBag of DamageDice
"""
def __init__(self, **kwargs):
"""
Validate the input and set the instance variables
"""
kwargs.update(damage_type="") # placeholder string so DamageDice constructor doesn't fail
super().__init__(**kwargs)
for die in self.get_dice_list():
if not isinstance(die, DamageDice):
raise ValueError("DamageDiceBag can only contain DamageDice")
[docs]
def get_damage_type_list(self) -> list:
"""
:return: damage types of all dice in the bag
:rtype: list
"""
damage_types = []
for die in self.get_dice_list():
damage_types.append(die.get_damage_type())
return damage_types
[docs]
def get_damage_type_set(self) -> set:
"""
:return: damage types of all dice in the bag
:rtype: set
"""
damage_types = set()
for die in self.get_dice_list():
damage_types.add(die.get_damage_type())
return damage_types
[docs]
def has_damage_type(self, damage_type: str):
"""
:param damage_type: the damage type searched for
:type damage_type: str
:return: True if *self* has the damage type specified
:rtype: bool
"""
# worst case this is O(n^2) because get_damage_type_set is O(n),
# which may be ok for small values, but could compound over many iterations.
return damage_type in self.get_damage_type_set()
[docs]
def add_dice(self, die):
"""
Append the given die to *self._dice_list*
:param die: the die to add
:return: None
:raise: ValueError if input is invalid
"""
if not isinstance(die, DamageDice):
raise ValueError("Can only add DamageDice to a DamageDiceBag")
super().add_dice(die)
[docs]
def roll_dice(self, modifier: int = 0, adv: int = 0, crit: int = 0, crit_multiplier: int = 2) -> Tuple:
"""
Roll dice (applying advantage as specified), add the modifier (but don't go below 0),
and return the result
:param modifier: the modifier to add after all dice have been rolled
:type modifier: int
:param adv: indicates whether this roll has advantage
:type adv: one of these integers: -1, 0, 1
:param crit: indicates whether to roll more dice (applies to each individual die)
:type crit: one of these integers: -1, 0, 1
:param crit_multiplier: the multiplier for dice num if the hit is a crit (applies to each individual die)
:type crit_multiplier: positive integer
:return: a tuple that contains the total roll value as well as an indicator of whether the roll was a crit or not
:rtype: tuple (total number, namedtuple with roll number and damage type)
"""
total_num = 0
result_list = []
for die in self.get_dice_list():
result_tup = die.roll_dice(modifier=modifier, adv=adv, crit=crit, crit_multiplier=crit_multiplier)
total_num += result_tup[0]
result_list.append(result_tup)
return total_num, result_list
[docs]
def get_damage_type(self):
raise NotImplementedError("Use get_damage_type_list or get_damage_type_set")