├── .github └── workflows │ └── tests.yaml ├── .gitignore ├── LICENSE.md ├── README.md ├── dice ├── __init__.py ├── __main__.py ├── command.py ├── constants.py ├── elements.py ├── exceptions.py ├── grammar.py ├── tests │ ├── __init__.py │ ├── test_command.py │ ├── test_elements.py │ ├── test_grammar.py │ └── test_utilities.py └── utilities.py ├── pyproject.toml └── tox.ini /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: [push] 3 | jobs: 4 | pytest: 5 | runs-on: ubuntu-latest 6 | strategy: 7 | max-parallel: 4 8 | matrix: 9 | python-version: 10 | - '3.8' 11 | - '3.9' 12 | - '3.10' 13 | - '3.11' 14 | steps: 15 | - uses: actions/checkout@v2 16 | - uses: actions/setup-python@v2 17 | with: 18 | python-version: '${{ matrix.python-version }}' 19 | - run: python -m pip install pytest black . 20 | - run: pytest 21 | - run: black --check . 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info/ 2 | *.pyc 3 | .cache/ 4 | .coverage 5 | .idea/ 6 | .pytest_cache/ 7 | .tox/ 8 | .venv/ 9 | __pycache__/ 10 | build/ 11 | dist/ 12 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013-2023 Sam Clements, 2017 Caleb Johnson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dice 2 | 3 | A Python library and command line tool for parsing and evaluating dice notation. 4 | 5 | _I consider this library "finished", and don't expect to add any more features to it. Bug and security fixes may still be released, especially if you find any bugs after all this time. If you're interested in other libraries in this space, [dyce] has [a great comparison table][comparison-to-alternatives] of Python dice rolling libraries._ 6 | 7 | [dyce]: https://posita.github.io/dyce/latest/ 8 | [comparison-to-alternatives]: https://posita.github.io/dyce/0.6/#comparison-to-alternatives 9 | 10 | ## Quickstart 11 | 12 | ### Command-line 13 | 14 | ```shell 15 | $ roll 3d6 16 | ``` 17 | 18 | The command line arguments are as follows: 19 | 20 | * `-m` `--min` Make all rolls the lowest possible result 21 | * `-M` `--max` Make all rolls the highest possible result 22 | * `-h` `--help` Show this help text 23 | * `-v` `--verbose` Show additional output 24 | * `-V` `--version` Show the package version 25 | 26 | If your expression begins with a dash (`-`), then put a double dash (`--`) 27 | before it to prevent the parser from trying to process it as a command option. 28 | Example: `roll -- -10d6`. Alternatively, use parenthesis: `roll (-10d6)`. 29 | 30 | ### Python API 31 | 32 | Invoking from python: 33 | 34 | ```python 35 | import dice 36 | dice.roll('3d6') 37 | ``` 38 | 39 | This returns an `Element` which is the result of the roll, which can be a 40 | `list`, `int`, or subclass thereof, depending on the top-level operator. 41 | 42 | ## Usage 43 | 44 | ### Installation 45 | 46 | This library is available as `dice` on PyPI. Install it with your Python 47 | package or dependency manager of choice — if you're installing it as a 48 | command-line tool, I recommend [pipx]. 49 | 50 | A recent version of Python 3 (3.8 or above) is required. You can probably run 51 | it or easily adapt it for older versions of Python, but I don't support any 52 | end-of-life Python versions. Beyond that, the only dependency is the `pyparsing` library. 53 | 54 | [pipx]: https://pypa.github.io/pipx/ 55 | 56 | ### Notation 57 | 58 | The expression works like a simple equation parser with some extra operators. 59 | 60 | *The following operators are listed in order of precedence. Parentheses may 61 | be used to force an alternate order of evaluation.* 62 | 63 | The dice (`[N]dS`) operator takes an amount (N) and a number of sides (S), and 64 | returns a list of N random numbers between 1 and S. For example: `4d6` may 65 | return `[6, 3, 2, 4]`. Using a `%` as the second operand is shorthand for 66 | rolling a d100, and a using `f` is shorthand for ±1 fudge dice. 67 | 68 | The fudge dice (`[N]uS`) operator is interchangeable with the dice operator, 69 | but makes the dice range from -S to S instead of 1 to S. This includes 0. 70 | 71 | A wild dice (`[N]wS`) roll is special. The last roll in this set is called the 72 | "wild die". If this die's roll is the maximum value, the second-highest roll 73 | in the set is set to the maximum value. If its roll is the minimum, then 74 | both it and the highest roll in the set aer set to zero. Then another die is 75 | rolled. If this roll is the minimum value again, then ALL die are set to zero. 76 | If a single-sided wild die is rolled, the roll behaves like a normal one. 77 | 78 | If N is not specified, it is assumed you want to roll a single die. 79 | `d6` is equivalent to `1d6`. 80 | 81 | Rolls can be exploded with the `x` operator, which adds additional dice 82 | to the set for each roll above a given threshold. If a threshold isn't given, 83 | it defaults to the maximum possible roll. If the extra dice exceed this 84 | threshold, they "explode" again! Safeguards are in place to prevent this from 85 | crashing the parser with infinite explosions. 86 | 87 | You can make the parser re-roll dice below a certain threshold with the `r` 88 | and `rr` operators. The single `r` variety allows the new roll to be below 89 | the threshold, whereas the double variety's roll *changes* the roll range to 90 | have a minimum of the threshold. The threshold defaults to the minimum roll. 91 | 92 | The highest, middle or lowest rolls or list entries can be selected with 93 | (`^` or `h`), (`m` or `o`), or (`v` or `l`) respectively. 94 | `6d6^3` will keep the highest 3 rolls, whereas `6d6v3` will select 95 | the lowest 3 rolls. If a number isn't specified, it defaults to keeping all 96 | but one for highest and lowest, and all but two for the middle. If a negative 97 | value is given as the operand for any of these operators, this operation will 98 | drop that many elements from the result. For example, `6d6^-2` will drop the 99 | two lowest values from the set, leaving the 4 highest. Zero has no effect. 100 | 101 | A variant of the "explode" operator is the `a` ("again") operator. Instead of 102 | re-rolling values equal to or greater than the threshold (or max value), this 103 | operator doubles values *equal* to the provided threshold (or max value). When 104 | no right-side operand is specified, the left side must be a dice expression. 105 | 106 | There are two operators for taking a set of rolls or numbers and counting the 107 | number of elements at or above a certain threshold, or "successes". Both 108 | require a right-hand operand for the threshold. The first, `e`, only counts 109 | successes. The second, `f`, counts successes minus failures, which are when 110 | a roll is the minimum possible value for the die element, or 1 for lists. 111 | 112 | A list or set of rolls can be turned into an integer with the total (`t`) 113 | operator. `6d1t` will return `6` instead of `[1, 1, 1, 1, 1, 1]`. 114 | Applying integer operations to a list of rolls will total them automatically. 115 | 116 | A set of dice rolls can be sorted with the sort (`s`) operator. `4d6s` 117 | will not change the return value, but the dice will be sorted from lowest to 118 | highest. 119 | 120 | The `+-` operator is a special prefix for sets of rolls and lists. It 121 | negates odd roles within a list. Example: `[1, 2, 3]` -> `[-1, 2, -3]`. 122 | There is also a negate (`-`) operator, which works on either single 123 | elements, sets or rolls, or lists. There is also an identity `+` operator. 124 | 125 | Values can be added or subtracted from each element of a list or set of rolls 126 | with the point-wise add (`.+`) and subtract (`.-`) operators. For example: 127 | `4d1 .+ 3` will return `[4, 4, 4, 4]`. 128 | 129 | Basic integer operations are also available: `(16 / 8 * 4 - 2 + 1) % 4 -> 3`. 130 | 131 | 132 | Finally, there are two operators for building and extending lists. To build a 133 | list, use a comma to separate elements. If any comma-seperated item isn't a 134 | scalar (e.g. a roll), it is flattened into one by taking its total. The 135 | "extend" operator (`|`) is used to merge two lists into one, or append single 136 | elements to the beginning or end of a list. 137 | 138 | ### Python API 139 | 140 | The calls to `dice.roll()` above may be replaced with `dice.roll_min()` or 141 | `dice.roll_max()` to force ALL rolls to their highest or lowest values 142 | respectively. This might be useful to see what the minimum and maximum 143 | possible values for a given expression are. Beware that this causes wild dice 144 | rolls to act like normal ones, and rolls performed as explosions are not 145 | forced high or low. 146 | 147 | The `roll()` function and variants take a boolean `raw` parameter which 148 | makes the library return the element instead of the result. Note that the 149 | `evaluate_cached` method is called as part of `roll()`, which populates 150 | `element.result`. Calling `element.evaluate()` will not reset this value. 151 | 152 | To display a verbose breakdown of the element tree, the 153 | `dice.utilities.verbose_print(element)` function is available. 154 | If `element.result` has not yet been populated, the function calls 155 | `evaluate_cached()` first. Keep this in mind if you want to print the result 156 | of an evaluation with custom arguments. `verbose_print()` returns a `str`. 157 | 158 | Most evaluation errors will raise `DiceError` or `DiceFatalError`, both of 159 | which are subclasses of `DiceBaseError`. These exceptions have a method 160 | named `pretty_print`, which will output a string indicating where the error 161 | happened:: 162 | 163 | ```python-repl 164 | >>> try: 165 | ... dice.roll('1/0') 166 | ... except dice.DiceBaseException as e: 167 | ... print(e.pretty_print()) 168 | ... 169 | 1/0 170 | ^ Division by zero 171 | >>> 172 | ``` 173 | -------------------------------------------------------------------------------- /dice/__init__.py: -------------------------------------------------------------------------------- 1 | """A library for parsing and evaluating dice notation.""" 2 | 3 | from pyparsing import ParseBaseException 4 | 5 | import dice.elements 6 | import dice.grammar 7 | import dice.utilities 8 | from dice.constants import DiceExtreme 9 | from dice.exceptions import DiceBaseException, DiceException, DiceFatalException 10 | 11 | __all__ = [ 12 | "roll", 13 | "roll_min", 14 | "roll_max", 15 | "elements", 16 | "grammar", 17 | "utilities", 18 | "command", 19 | "DiceBaseException", 20 | "DiceException", 21 | "DiceFatalException", 22 | "DiceExtreme", 23 | ] 24 | __author__ = "Sam Clements , Caleb Johnson " 25 | __version__ = "4.0.0" 26 | 27 | 28 | def roll(string, **kwargs): 29 | """Parses and evaluates a dice expression""" 30 | return _roll(string, **kwargs) 31 | 32 | 33 | def roll_min(string, **kwargs): 34 | """Parses and evaluates the minimum of a dice expression""" 35 | return _roll(string, force_extreme=DiceExtreme.EXTREME_MIN, **kwargs) 36 | 37 | 38 | def roll_max(string, **kwargs): 39 | """Parses and evaluates the maximum of a dice expression""" 40 | return _roll(string, force_extreme=DiceExtreme.EXTREME_MAX, **kwargs) 41 | 42 | 43 | def parse_expression(string): 44 | return dice.grammar.expression.parseString(string, parseAll=True) 45 | 46 | 47 | def _roll(string, single=True, raw=False, return_kwargs=False, **kwargs): 48 | try: 49 | ast = parse_expression(string) 50 | elements = list(ast) 51 | 52 | if not raw: 53 | elements = [element.evaluate_cached(**kwargs) for element in elements] 54 | 55 | if single: 56 | elements = dice.utilities.single(elements) 57 | 58 | if return_kwargs: 59 | return elements, kwargs 60 | 61 | return elements 62 | except ParseBaseException as e: 63 | raise DiceBaseException.from_other(e) 64 | -------------------------------------------------------------------------------- /dice/__main__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import dice.command 3 | 4 | if __name__ == "__main__": 5 | dice.command.main(sys.argv[1:]) 6 | -------------------------------------------------------------------------------- /dice/command.py: -------------------------------------------------------------------------------- 1 | """ 2 | Usage: 3 | roll [--verbose] [--min | --max] [--max-dice=] [--] ... 4 | 5 | Options: 6 | -m --min Make all rolls the lowest possible result 7 | -M --max Make all rolls the highest possible result 8 | -D --max-dice= Set the maximum number of dice per element 9 | -h --help Show this help text 10 | -v --verbose Show additional output 11 | -V --version Show the package version 12 | """ 13 | 14 | import argparse 15 | 16 | import dice 17 | import dice.exceptions 18 | 19 | __version__ = "dice v{0} by {1}.".format(dice.__version__, dice.__author__) 20 | 21 | parser = argparse.ArgumentParser( 22 | prog="dice", description="Parse and evaluate dice notation.", epilog=__version__ 23 | ) 24 | parser.add_argument( 25 | "-m", 26 | "--min", 27 | action="store_true", 28 | help="Make all rolls the lowest possible result.", 29 | ) 30 | parser.add_argument( 31 | "-M", 32 | "--max", 33 | action="store_true", 34 | help="Make all rolls the highest possible result.", 35 | ) 36 | parser.add_argument( 37 | "-D", 38 | "--max-dice", 39 | action="store", 40 | type=int, 41 | metavar="N", 42 | help="Set the maximum number of dice per element.", 43 | ) 44 | parser.add_argument( 45 | "-v", "--verbose", action="store_true", help="Show additional output." 46 | ) 47 | parser.add_argument( 48 | "-V", 49 | "--version", 50 | action="version", 51 | version=__version__, 52 | help="Show the package version.", 53 | ) 54 | parser.add_argument( 55 | "expression", 56 | nargs="+", 57 | help="One or more expressions in dice notation", 58 | ) 59 | 60 | 61 | def main(args=None): 62 | """Run roll() from a command line interface""" 63 | args = parser.parse_args(args=args) 64 | f_kwargs = {} 65 | 66 | if args.min: 67 | f_roll = dice.roll_min 68 | elif args.max: 69 | f_roll = dice.roll_max 70 | else: 71 | f_roll = dice.roll 72 | 73 | if args.max_dice: 74 | f_kwargs["max_dice"] = args.max_dice 75 | 76 | f_expr = " ".join(args.expression) 77 | 78 | try: 79 | roll, kwargs = f_roll(f_expr, raw=True, return_kwargs=True, **f_kwargs) 80 | 81 | if args.verbose: 82 | print("Result: ", end="") 83 | 84 | print(str(roll.evaluate_cached(**kwargs))) 85 | 86 | if args.verbose: 87 | print("Breakdown:") 88 | print(dice.utilities.verbose_print(roll, **kwargs)) 89 | except dice.exceptions.DiceBaseException as e: 90 | print("Whoops! Something went wrong:") 91 | print(e.pretty_print()) 92 | exit(1) 93 | -------------------------------------------------------------------------------- /dice/constants.py: -------------------------------------------------------------------------------- 1 | # Python 2 doesn't nave enum, but this is close enough. 2 | class DiceExtreme: 3 | EXTREME_MIN = "MIN" 4 | EXTREME_MAX = "MAX" 5 | 6 | 7 | MAX_ROLL_DICE = 2**20 8 | MAX_EXPLOSIONS = 2**8 9 | VERBOSE_INDENT = 2 10 | -------------------------------------------------------------------------------- /dice/elements.py: -------------------------------------------------------------------------------- 1 | """Objects used in the evaluation of the parse tree""" 2 | 3 | import random 4 | import operator 5 | from pyparsing import ParseFatalException 6 | from copy import copy 7 | 8 | from dice.constants import MAX_EXPLOSIONS, MAX_ROLL_DICE, DiceExtreme 9 | from dice.exceptions import DiceFatalException 10 | from dice.utilities import classname, add_even_sub_odd, dice_switch 11 | 12 | 13 | class Element: 14 | @classmethod 15 | def parse(cls, string, location, tokens): 16 | try: 17 | return cls(*tokens).set_parse_attributes(string, location, tokens) 18 | # The following only matters on python2 platforms, so is marked nocover 19 | except ArithmeticError as e: # nocover 20 | exc = DiceFatalException(string, location, e.args[0]) # nocover 21 | exc.__cause__ = None # nocover 22 | raise exc # nocover 23 | 24 | def set_parse_attributes(self, string, location, tokens): 25 | self.string = string 26 | self.location = location 27 | self.tokens = tokens 28 | return self 29 | 30 | def fatal(self, description, location=None, offset=0, cls=DiceFatalException): 31 | if location is None: 32 | location = self.location 33 | return cls(self.string, location + offset, description) 34 | 35 | def evaluate(self, **kwargs): 36 | """Evaluate the current object - a no-op by default""" 37 | return self 38 | 39 | @staticmethod 40 | def evaluate_object(obj, cls=None, cache=False, **kwargs): 41 | """Evaluates elements, and coerces objects to a class if needed""" 42 | old_obj = obj 43 | if isinstance(obj, Element): 44 | if cache: 45 | obj = obj.evaluate_cached(**kwargs) 46 | else: 47 | obj = obj.evaluate(cache=cache, **kwargs) 48 | 49 | if cls is not None and type(obj) != cls: 50 | obj = cls(obj) 51 | 52 | for attr in ("string", "location", "tokens"): 53 | if hasattr(old_obj, attr): 54 | setattr(obj, attr, getattr(old_obj, attr)) 55 | 56 | return obj 57 | 58 | def evaluate_cached(self, **kwargs): 59 | """Wraps evaluate(), caching results""" 60 | if not hasattr(self, "result"): 61 | self.result = self.evaluate(cache=True, **kwargs) 62 | 63 | return self.result 64 | 65 | 66 | class Integer(int, Element): 67 | """A wrapper around the int class""" 68 | 69 | pass 70 | 71 | 72 | class String(str, Element): 73 | """A wrapper around the str class""" 74 | 75 | pass 76 | 77 | 78 | class IntegerList(list, Element): 79 | "Augments the standard list with an __int__ operator" 80 | 81 | def __str__(self): 82 | ret = "[%s]" % ", ".join(map(str, self)) 83 | if hasattr(self, "sum") and len(self) > 1: 84 | ret += " -> %i" % self 85 | return ret 86 | 87 | def copy(self): 88 | return type(self)(self) 89 | 90 | def clear(self): 91 | self[:] = [] 92 | 93 | def __int__(self): 94 | ret = sum(self) 95 | self.sum = ret 96 | return ret 97 | 98 | 99 | class Roll(IntegerList): 100 | """Represents a randomized result from a random element""" 101 | 102 | @classmethod 103 | def roll_single(cls, min_value, max_value, **kwargs): 104 | integer_min = cls.evaluate_object(min_value, Integer, **kwargs) 105 | integer_max = cls.evaluate_object(max_value, Integer, **kwargs) 106 | 107 | if integer_min > integer_max: 108 | raise ValueError( 109 | "Roll must have a valid range (got %s - %s, " 110 | "which evaluated to %i - %i). Are you trying to " 111 | "use a fudge roll as the sides?" 112 | % (min_value, max_value, integer_min, integer_max) 113 | ) 114 | rnd_engine = kwargs.get("random", random) 115 | return rnd_engine.randint(integer_min, integer_max) 116 | 117 | @classmethod 118 | def roll(cls, orig_amount, min_value, max_value, **kwargs): 119 | amount = cls.evaluate_object(orig_amount, Integer, **kwargs) 120 | 121 | max_dice = kwargs.get("max_dice", MAX_ROLL_DICE) 122 | 123 | if amount > max_dice: 124 | raise ValueError("Too many dice! (max is %i)" % max_dice) 125 | elif amount < 0: 126 | raise ValueError("Cannot roll less than zero dice!") 127 | 128 | return [cls.roll_single(min_value, max_value, **kwargs) for i in range(amount)] 129 | 130 | def do_roll_single(self, min_value=None, max_value=None, **kwargs): 131 | element = self.random_element 132 | 133 | if self.force_extreme is DiceExtreme.EXTREME_MIN: 134 | return element.min_value 135 | elif self.force_extreme is DiceExtreme.EXTREME_MAX: 136 | return element.max_value 137 | 138 | if min_value is None: 139 | min_value = element.min_value 140 | 141 | if max_value is None: 142 | max_value = element.max_value 143 | try: 144 | return self.roll_single(min_value, max_value, **kwargs) 145 | except ValueError as e: 146 | exc = self.random_element.fatal(e.args[0]) 147 | raise exc 148 | 149 | def do_roll(self, amount=None, min_value=None, max_value=None, **kwargs): 150 | element = self.random_element 151 | if amount is None: 152 | amount = element.amount 153 | if min_value is None: 154 | min_value = element.min_value 155 | if max_value is None: 156 | max_value = element.max_value 157 | 158 | try: 159 | return self.roll(amount, min_value, max_value, **kwargs) 160 | except ValueError as e: 161 | exc = self.random_element.fatal(e.args[0]) 162 | exc.__cause__ = None 163 | raise exc 164 | 165 | def __init__(self, element, rolled=None, **kwargs): 166 | self.random_element = element 167 | self.force_extreme = kwargs.get("force_extreme") 168 | 169 | amount = self.evaluate_object(element.amount, Integer, **kwargs) 170 | min_value = self.evaluate_object(element.min_value, Integer, **kwargs) 171 | max_value = self.evaluate_object(element.max_value, Integer, **kwargs) 172 | 173 | if rolled is None: 174 | max_dice = kwargs.get("max_dice", MAX_ROLL_DICE) 175 | 176 | if amount > max_dice: 177 | msg = "Too many dice! (max is %i)" % max_dice 178 | exc = self.random_element.fatal(msg) 179 | exc.__cause__ = None 180 | raise exc 181 | elif amount < 0: 182 | msg = "Cannot roll less than zero dice!" 183 | 184 | if not isinstance(element.amount, int): 185 | msg += " (%s evaluated to %s)" % (element.amount, amount) 186 | 187 | exc = self.random_element.fatal(msg) 188 | exc.__cause__ = None 189 | raise exc 190 | 191 | elif self.force_extreme is DiceExtreme.EXTREME_MIN: 192 | rolled = [min_value] * amount 193 | elif self.force_extreme is DiceExtreme.EXTREME_MAX: 194 | rolled = [max_value] * amount 195 | else: 196 | rolled = self.do_roll(**kwargs) 197 | 198 | super().__init__(rolled) 199 | 200 | def copy(self): 201 | return type(self)( 202 | self.random_element, rolled=self, force_extreme=self.force_extreme 203 | ) 204 | 205 | def __repr__(self): 206 | return "{0}({1}, random_element={2!r})".format( 207 | classname(self), str(self), self.random_element 208 | ) 209 | 210 | 211 | class WildRoll(Roll): 212 | """Represents a roll of wild dice""" 213 | 214 | @classmethod 215 | def roll(cls, amount, min_value, max_value, **kwargs): 216 | amount = cls.evaluate_object(amount, Integer, **kwargs) 217 | min_value = cls.evaluate_object(min_value, Integer, **kwargs) 218 | max_value = cls.evaluate_object(max_value, Integer, **kwargs) 219 | 220 | if amount == 0: 221 | return [] 222 | 223 | rnd_engine = kwargs.get("random", random) 224 | rolls = [rnd_engine.randint(min_value, max_value) for i in range(amount)] 225 | 226 | if min_value == max_value: 227 | return rolls # Continue as if dice were normal 228 | 229 | while rolls[-1] == max_value: 230 | rolls.append(rnd_engine.randint(min_value, max_value)) 231 | 232 | if len(rolls) == amount and rolls[-1] == min_value: # failure 233 | rolls[-1] = 0 234 | rolls[rolls.index(max(rolls))] = 0 235 | 236 | if rnd_engine.randint(min_value, max_value) == min_value: 237 | return [0] * amount 238 | 239 | return rolls 240 | 241 | 242 | class ExplodedRoll(Roll): 243 | """Represents an exploded roll""" 244 | 245 | def __init__(self, original, rolled, **kwargs): 246 | super().__init__(original, rolled=rolled, **kwargs) 247 | 248 | 249 | class RandomElement(Element): 250 | """Represents a set of elements with a random numerical result""" 251 | 252 | DICE_MAP = {} 253 | SEPARATOR = None 254 | 255 | @classmethod 256 | def register_dice(cls, new_cls): 257 | if not issubclass(new_cls, RandomElement): 258 | raise TypeError("can only register subclasses of RandomElement") 259 | elif not new_cls.SEPARATOR: 260 | raise TypeError("must specify separator") 261 | elif new_cls.SEPARATOR in cls.DICE_MAP: 262 | raise RuntimeError("Separator %s already registered" % new_cls.SEPARATOR) 263 | cls.DICE_MAP[new_cls.SEPARATOR] = new_cls 264 | return new_cls 265 | 266 | @classmethod 267 | def parse_unary(cls, string, location, tokens): 268 | return cls.parse(string, location, [1] + list(tokens)) 269 | 270 | @classmethod 271 | def parse(cls, string, location, tokens): 272 | if len(tokens) > 3: 273 | raise ParseFatalException( 274 | string, 275 | tokens[3].location, 276 | ( 277 | "Cannot stack dice operators! Try disambiguating your " 278 | "expression with parentheses." 279 | ), 280 | ) 281 | 282 | amount, kind, dice_type = tokens 283 | try: 284 | ret = dice_switch(amount, dice_type, kind) 285 | return ret.set_parse_attributes(string, location, tokens) 286 | except ValueError as e: 287 | if len(e.args) > 1: 288 | if type(e.args[1]) is int: 289 | location = tokens[e.args[1]].location 290 | # unused as of yet 291 | # elif isinstance(e.args[1], Element): 292 | # location = e.args[1].location 293 | raise ParseFatalException(string, location, e.args[0]) 294 | 295 | @classmethod 296 | def from_iterable(cls, iterable): 297 | return cls(*iterable) 298 | 299 | @classmethod 300 | def from_string(cls, string): 301 | string = string.lower() 302 | for k in cls.DICE_MAP: 303 | ss = string.split(k) 304 | if len(ss) != 2: 305 | continue 306 | ss = [int(x) if x.isdigit() else x for x in ss] 307 | return dice_switch(ss[0], ss[1], k) 308 | 309 | def __init__(self, amount, min_value, max_value, **kwargs): 310 | self.amount = amount 311 | self.min_value = min_value 312 | self.max_value = max_value 313 | 314 | def __neg__(self): 315 | new = copy(self) 316 | new.min_value, new.max_value = -new.max_value, -new.min_value 317 | return new 318 | 319 | def __eq__(self, other): 320 | return ( 321 | type(self) is type(other) 322 | and self.amount == other.amount 323 | and self.min_value == other.min_value 324 | and self.max_value == other.max_value 325 | ) 326 | 327 | def evaluate(self, **kwargs): 328 | return Roll(self, **kwargs) 329 | 330 | 331 | @RandomElement.register_dice 332 | class Dice(RandomElement): 333 | """A group of dice, all with the same number of sides""" 334 | 335 | SEPARATOR = "d" 336 | 337 | def __init__(self, amount, max_value, min_value=1): 338 | super().__init__(amount, min_value, max_value) 339 | self.original_operands = (amount, max_value) 340 | 341 | @property 342 | def sides(self): 343 | return self.max_value 344 | 345 | def __repr__(self): 346 | p = "{0!r}, {1!r}".format(self.amount, self.max_value) 347 | if self.min_value != 1: 348 | p += ", {0!r}".format(self.min_value) 349 | return "{}({})".format(classname(self), p) 350 | 351 | def __str__(self): 352 | return "{0!s}{1}{2!s}".format(self.amount, self.SEPARATOR, self.sides) 353 | 354 | 355 | @RandomElement.register_dice 356 | class WildDice(Dice): 357 | """A group of dice with the last being explodable or a failure mode on 1.""" 358 | 359 | SEPARATOR = "w" 360 | 361 | def evaluate(self, **kwargs): 362 | return WildRoll(self, **kwargs) 363 | 364 | 365 | @RandomElement.register_dice 366 | class FudgeDice(Dice): 367 | """A group of dice whose sides range from -x to x, including 0""" 368 | 369 | SEPARATOR = "u" 370 | 371 | def __init__(self, amount, value): 372 | super().__init__(amount, value, -value) 373 | 374 | def __repr__(self): 375 | p = "{0!r}, {1!r}".format(self.amount, self.max_value) 376 | 377 | if self.min_value != -self.max_value: 378 | p += ", {0!r}".format(self.min_value) 379 | 380 | return "{}({})".format(classname(self), p) 381 | 382 | 383 | class Operator(Element): 384 | PASS_KWARGS = () 385 | 386 | def __init__(self, *operands): 387 | self.operands = self.original_operands = operands 388 | 389 | def __repr__(self): 390 | return "{0}({1})".format( 391 | classname(self), ", ".join(map(str, self.original_operands)) 392 | ) 393 | 394 | def __getnewargs__(self): 395 | return self.original_operands 396 | 397 | def preprocess_operands(self, *operands, **kwargs): 398 | def eval_wrapper(operand): 399 | return self.evaluate_object(operand, **kwargs) 400 | 401 | return [eval_wrapper(o) for o in operands] 402 | 403 | def evaluate(self, **kwargs): 404 | if not kwargs.get("cache", False): 405 | self.operands = self.original_operands 406 | 407 | self.operands = self.preprocess_operands(*self.operands, **kwargs) 408 | 409 | function_kw = {} 410 | 411 | for k in self.PASS_KWARGS: 412 | if k in kwargs: 413 | function_kw[k] = kwargs[k] 414 | 415 | try: 416 | try: 417 | self.rhs_index = -1 418 | value = self.function(*self.operands) 419 | except TypeError: 420 | value = self.operands[0] 421 | 422 | for i, o in enumerate(self.operands[1:]): 423 | self.rhs_index = i 424 | value = self.function(value, o) 425 | 426 | if hasattr(self.__class__, "output_cls"): 427 | return self.evaluate_object(value, self.output_cls, **kwargs) 428 | 429 | return value 430 | 431 | except ZeroDivisionError: 432 | zero = self.operands[1:].index(0) + 1 433 | zero_op = self.original_operands[zero] 434 | offset = zero_op.location - self.location 435 | msg = "Division by zero" 436 | 437 | if not isinstance(zero_op, int): 438 | msg += " (%s evaluated to 0)" % zero_op 439 | 440 | raise self.fatal(msg, offset=offset) 441 | 442 | @property 443 | def function(self): 444 | raise NotImplementedError("Operator subclass has no function") 445 | 446 | 447 | class IntegerOperator(Operator): 448 | def preprocess_operands(self, *operands, **kwargs): 449 | def eval_wrapper(operand): 450 | return self.evaluate_object(operand, Integer, **kwargs) 451 | 452 | return [eval_wrapper(o) for o in operands] 453 | 454 | 455 | class RHSIntegerOperator(IntegerOperator): 456 | """Like IntegerOperator, but doesn't transform the left operator to an int""" 457 | 458 | def preprocess_operands(self, *operands, **kwargs): 459 | ret = [self.evaluate_object(operands[0], **kwargs)] 460 | 461 | for operand in operands[1:]: 462 | ret.append(self.evaluate_object(operand, Integer, **kwargs)) 463 | 464 | return ret 465 | 466 | 467 | class Div(IntegerOperator): 468 | output_cls = Integer 469 | function = operator.floordiv 470 | 471 | 472 | class Mul(IntegerOperator): 473 | output_cls = Integer 474 | function = operator.mul 475 | 476 | 477 | class Sub(IntegerOperator): 478 | output_cls = Integer 479 | function = operator.sub 480 | 481 | 482 | class Add(IntegerOperator): 483 | output_cls = Integer 484 | function = operator.add 485 | 486 | 487 | class Modulo(IntegerOperator): 488 | function = operator.mod 489 | 490 | 491 | class AddEvenSubOdd(Operator): 492 | function = add_even_sub_odd 493 | 494 | 495 | class Total(Operator): 496 | output_cls = Integer 497 | function = sum 498 | 499 | 500 | class Successes(RHSIntegerOperator): 501 | def function(self, iterable, thresh): 502 | if not isinstance(iterable, IntegerList): 503 | iterable = (iterable,) 504 | elif isinstance(iterable, Roll): 505 | max_value = iterable.random_element.max_value 506 | 507 | if isinstance(max_value, RandomElement): 508 | raise self.fatal( 509 | "Nested dice in success not yet supported.", 510 | location=max_value.location, 511 | ) 512 | 513 | if thresh > iterable.random_element.max_value: 514 | raise self.fatal("Success threshold higher than roll result.") 515 | 516 | return sum(x >= thresh for x in iterable) 517 | 518 | 519 | class SuccessFail(RHSIntegerOperator): 520 | def function(self, iterable, thresh): 521 | result = 0 522 | if not isinstance(iterable, IntegerList): 523 | iterable = (iterable,) 524 | elif isinstance(iterable, Roll): 525 | max_value = iterable.random_element.max_value 526 | 527 | if isinstance(max_value, RandomElement): 528 | raise self.fatal( 529 | "Nested dice in success not yet supported.", 530 | location=max_value.location, 531 | ) 532 | 533 | if thresh > iterable.random_element.max_value: 534 | raise self.fatal( 535 | "Success threshold higher than maximum roll " "result." 536 | ) 537 | 538 | if isinstance(iterable, Roll): 539 | fail_level = iterable.random_element.min_value 540 | else: 541 | fail_level = 1 542 | 543 | for x in iterable: 544 | if x >= thresh: 545 | result += 1 546 | elif x <= fail_level: 547 | result -= 1 548 | 549 | return result 550 | 551 | 552 | class Again(RHSIntegerOperator): 553 | def function(self, lhs, rhs=None): 554 | if not isinstance(lhs, IntegerList): 555 | lhs = IntegerList([lhs]) 556 | 557 | if rhs is None: 558 | if not isinstance(lhs, Roll): 559 | raise self.fatal("%s is not a random element" % lhs) 560 | 561 | rhs = lhs.random_element.max_value 562 | 563 | ret = lhs.copy() 564 | ret.clear() 565 | 566 | for elem in lhs: 567 | ret.append(elem) 568 | 569 | if elem == rhs: 570 | ret.append(elem) 571 | 572 | return ret 573 | 574 | 575 | class Sort(Operator): 576 | def function(self, iterable): 577 | if not isinstance(iterable, IntegerList): 578 | raise self.fatal("Cannot sort %s!" % iterable) 579 | 580 | iterable = iterable.copy() 581 | iterable.sort() 582 | return iterable 583 | 584 | 585 | class Extend(Operator): 586 | def function(self, *args): 587 | ret = IntegerList() 588 | for x in args: 589 | try: 590 | ret.extend(x) 591 | except TypeError: 592 | ret.append(x) 593 | 594 | return ret 595 | 596 | 597 | class Array(Operator): 598 | def function(self, *args): 599 | ret = IntegerList() 600 | 601 | for x in args: 602 | try: 603 | x = sum(x) 604 | except TypeError: 605 | pass 606 | ret.append(x) 607 | 608 | return ret 609 | 610 | 611 | # TODO: stable removal instead of sort -> slice -> shuffle 612 | class Lowest(RHSIntegerOperator): 613 | PASS_KWARGS = ("random",) 614 | 615 | def function(self, iterable, n=None, **kwargs): 616 | if not isinstance(iterable, IntegerList): 617 | raise self.fatal("Can't take the lowest values of a scalar!") 618 | 619 | if n is None: 620 | n = len(iterable) - 1 621 | 622 | iterable = iterable.copy() 623 | iterable.sort() 624 | iterable[n:] = [] 625 | kwargs.get("random", random).shuffle(iterable) 626 | return iterable 627 | 628 | 629 | class Highest(RHSIntegerOperator): 630 | PASS_KWARGS = ("random",) 631 | 632 | def function(self, iterable, n=None, **kwargs): 633 | if not isinstance(iterable, IntegerList): 634 | raise self.fatal("Can't take the highest values of a scalar!") 635 | 636 | if n is None: 637 | n = len(iterable) - 1 638 | 639 | iterable = iterable.copy() 640 | iterable.sort() 641 | iterable[:-n] = [] 642 | kwargs.get("random", random).shuffle(iterable) 643 | return iterable 644 | 645 | 646 | class Middle(RHSIntegerOperator): 647 | PASS_KWARGS = ("random",) 648 | 649 | def function(self, iterable, n=None, **kwargs): 650 | if not isinstance(iterable, IntegerList): 651 | raise self.fatal("Can't take the middle values of a scalar!") 652 | 653 | num = len(iterable) 654 | 655 | if n is None: 656 | n = (num - 2) if num > 2 else 1 657 | elif n <= 0: 658 | n += num 659 | 660 | num_remove = num - n 661 | upper = num_remove // 2 662 | lower = num_remove - upper 663 | 664 | iterable = iterable.copy() 665 | iterable.sort() 666 | iterable[:lower], iterable[-upper:] = [], [] 667 | kwargs.get("random", random).shuffle(iterable) 668 | return iterable 669 | 670 | 671 | class Explode(RHSIntegerOperator): 672 | def function(self, roll, thresh=None): 673 | if not isinstance(roll, Roll): 674 | raise self.fatal("Cannot explode {0}".format(roll)) 675 | elif thresh is None: 676 | thresh = roll.random_element.max_value 677 | 678 | if roll.random_element.min_value == roll.random_element.max_value: 679 | raise self.fatal("Cannot explode a roll of one-sided dice.") 680 | 681 | elif thresh <= roll.random_element.min_value: 682 | offset = 0 683 | orig_thresh = self.original_operands[self.rhs_index] 684 | 685 | if thresh is not None: 686 | offset = orig_thresh.location - self.location 687 | 688 | msg = ( 689 | "Refusing to explode with threshold less than or equal to " 690 | "the lowest possible roll." 691 | ) 692 | 693 | if type(orig_thresh) is not Integer: 694 | msg += " (%s evaluated to %s)" % (orig_thresh, thresh) 695 | 696 | raise self.fatal(msg, offset=offset) 697 | 698 | explosions = 0 699 | result = list(roll) 700 | rerolled = roll 701 | 702 | while rerolled: 703 | explosions += 1 704 | 705 | if explosions >= MAX_EXPLOSIONS: 706 | raise self.fatal("Too many explosions!") 707 | 708 | num_rerolls = sum(x >= thresh for x in rerolled) 709 | rerolled = roll.do_roll(num_rerolls) 710 | result.extend(rerolled) 711 | 712 | return ExplodedRoll(roll.random_element, rolled=result) 713 | 714 | 715 | class Reroll(RHSIntegerOperator): 716 | def function(self, roll, thresh=None): 717 | if not isinstance(roll, Roll): 718 | raise self.fatal("Cannot reroll {0}".format(roll)) 719 | 720 | elem = roll.random_element 721 | 722 | if isinstance(elem.min_value, RandomElement): 723 | raise self.fatal( 724 | "Nested dice in reroll not yet supported.", 725 | location=elem.min_value.location, 726 | ) 727 | 728 | if thresh is None: 729 | thresh = elem.min_value 730 | 731 | roll = Roll(elem, rolled=roll, force_extreme=roll.force_extreme) 732 | 733 | for i, x in enumerate(roll): 734 | if x <= thresh: 735 | roll[i] = roll.do_roll_single() 736 | 737 | return roll 738 | 739 | 740 | class ForceReroll(RHSIntegerOperator): 741 | def function(self, roll, thresh=None, force_min=False): 742 | if not isinstance(roll, Roll): 743 | raise self.fatal("Cannot reroll {0}".format(roll)) 744 | 745 | elem = roll.random_element 746 | 747 | if isinstance(elem.max_value, RandomElement): 748 | raise self.fatal( 749 | "Nested dice in force-reroll not yet supported.", 750 | location=elem.max_value.location, 751 | ) 752 | 753 | if thresh is None: 754 | thresh = elem.min_value 755 | 756 | max_min = min((elem.max_value, thresh + 1)) 757 | 758 | roll = Roll(elem, rolled=roll, force_extreme=roll.force_extreme) 759 | 760 | for i, x in enumerate(roll): 761 | if x <= thresh: 762 | roll[i] = roll.do_roll_single(min_value=max_min) 763 | 764 | return roll 765 | 766 | 767 | class Identity(Operator): 768 | # no function defined because of passthrough __new__ 769 | def __new__(self, x): 770 | return x 771 | 772 | 773 | class Negate(Operator): 774 | def __new__(cls, x): 775 | if isinstance(x, int): 776 | # Passthrough to prevent Negate() clutter 777 | return Integer(-x) 778 | 779 | return super().__new__(cls) 780 | 781 | def function(self, operand): 782 | operand = IntegerList(operand) 783 | 784 | for i, x in enumerate(operand): 785 | operand[i] = -x 786 | 787 | return operand 788 | 789 | 790 | class ArrayAdd(RHSIntegerOperator): 791 | def function(self, iterable, scalar): 792 | try: 793 | scalar = int(scalar) 794 | iterable = IntegerList(iterable) 795 | 796 | for i, x in enumerate(iterable): 797 | iterable[i] = x + scalar 798 | 799 | return iterable 800 | except TypeError: 801 | raise self.fatal("Invalid operands for array add") 802 | 803 | 804 | class ArraySub(RHSIntegerOperator): 805 | def function(self, iterable, scalar): 806 | try: 807 | scalar = int(scalar) 808 | iterable = IntegerList(iterable) 809 | 810 | for i, x in enumerate(iterable): 811 | iterable[i] = x - scalar 812 | 813 | return iterable 814 | except TypeError: 815 | raise self.fatal("Invalid operands for array sub") 816 | -------------------------------------------------------------------------------- /dice/exceptions.py: -------------------------------------------------------------------------------- 1 | from pyparsing import ParseException, ParseFatalException 2 | 3 | 4 | class DiceBaseException(Exception): 5 | @classmethod 6 | def from_other(cls, other): 7 | if isinstance(other, ParseException): 8 | return DiceException(*other.args) 9 | elif isinstance(other, ParseFatalException): 10 | return DiceFatalException(*other.args) 11 | raise NotImplementedError( 12 | "DiceBaseException can only wrap ParseException or ParseFatalException" 13 | ) 14 | 15 | def pretty_print(self): 16 | string, location, description = self.args 17 | lines = string.split("\n") 18 | 19 | if len(description) < (self.col - 1): 20 | line = (description + " ^").rjust(self.col) 21 | else: 22 | line = "^ ".rjust(self.col + 1) + description 23 | 24 | lines.insert(self.lineno + 1, line) 25 | return "\n".join(lines) 26 | 27 | 28 | class DiceException(DiceBaseException, ParseException): 29 | pass 30 | 31 | 32 | class DiceFatalException(DiceBaseException, ParseFatalException): 33 | pass 34 | -------------------------------------------------------------------------------- /dice/grammar.py: -------------------------------------------------------------------------------- 1 | """ 2 | Dice notation grammar 3 | 4 | PyParsing is patched to make it easier to work with, by removing features 5 | that get in the way of development and debugging. See the dice.utilities 6 | module for more information. 7 | """ 8 | 9 | import warnings 10 | 11 | from pyparsing import ( 12 | CaselessLiteral, 13 | Forward, 14 | Literal, 15 | OneOrMore, 16 | Or, 17 | ParserElement, 18 | StringStart, 19 | StringEnd, 20 | Suppress, 21 | Word, 22 | nums, 23 | opAssoc, 24 | ) 25 | 26 | from dice.elements import ( 27 | Integer, 28 | Successes, 29 | Mul, 30 | Div, 31 | Modulo, 32 | Sub, 33 | Add, 34 | Identity, 35 | AddEvenSubOdd, 36 | Total, 37 | Sort, 38 | Lowest, 39 | Middle, 40 | Highest, 41 | Array, 42 | Extend, 43 | Explode, 44 | Reroll, 45 | ForceReroll, 46 | Negate, 47 | SuccessFail, 48 | ArrayAdd, 49 | ArraySub, 50 | RandomElement, 51 | Again, 52 | ) 53 | 54 | from dice.utilities import wrap_string 55 | 56 | # Enables pyparsing's packrat parsing, which is much faster 57 | # for the type of parsing being done in this library. 58 | warnings.warn("Enabled pyparsing packrat parsing", ImportWarning) 59 | ParserElement.enablePackrat() 60 | 61 | 62 | def operatorPrecedence(base, operators): 63 | """ 64 | This re-implements pyparsing's operatorPrecedence function. 65 | 66 | It gets rid of a few annoying bugs, like always putting operators inside 67 | a Group, and matching the whole grammar with Forward first (there may 68 | actually be a reason for that, but I couldn't find it). It doesn't 69 | support trinary expressions, but they should be easy to add if it turns 70 | out I need them. 71 | """ 72 | 73 | # The full expression, used to provide sub-expressions 74 | expression = Forward() 75 | 76 | # The initial expression 77 | last = base | Suppress("(") + expression + Suppress(")") 78 | 79 | def parse_operator(expr, arity, association, action=None, extra=None): 80 | return expr, arity, association, action, extra 81 | 82 | for op in operators: 83 | # Use a function to default action to None 84 | expr, arity, association, action, extra = parse_operator(*op) 85 | 86 | # Check that the arity is valid 87 | if arity < 1 or arity > 2: 88 | raise Exception("Arity must be unary (1) or binary (2)") 89 | 90 | if association not in (opAssoc.LEFT, opAssoc.RIGHT): 91 | raise Exception("Association must be LEFT or RIGHT") 92 | 93 | # This will contain the expression 94 | this = Forward() 95 | 96 | # Create an expression based on the association and arity 97 | if association is opAssoc.LEFT: 98 | new_last = (last | extra) if extra else last 99 | if arity == 1: 100 | operator_expression = new_last + OneOrMore(expr) 101 | elif arity == 2: 102 | operator_expression = last + OneOrMore(expr + new_last) 103 | elif association is opAssoc.RIGHT: 104 | new_this = (this | extra) if extra else this 105 | if arity == 1: 106 | operator_expression = expr + new_this 107 | # Currently no operator uses this, so marking it nocover for now 108 | elif arity == 2: # nocover 109 | operator_expression = last + OneOrMore(new_this) # nocover 110 | 111 | # Set the parse action for the operator 112 | if action is not None: 113 | operator_expression.setParseAction(action) 114 | 115 | this <<= operator_expression | last 116 | last = this 117 | 118 | # Set the full expression and return it 119 | expression <<= last 120 | return expression 121 | 122 | 123 | # An integer value 124 | integer = Word(nums) 125 | integer.setParseAction(Integer.parse) 126 | integer.setName("integer") 127 | 128 | dice_separators = RandomElement.DICE_MAP.keys() 129 | dice_element = Or( 130 | wrap_string(CaselessLiteral, x, suppress=False) for x in dice_separators 131 | ) 132 | special = wrap_string(Literal, "%", suppress=False) | wrap_string( 133 | CaselessLiteral, "f", suppress=False 134 | ) 135 | 136 | # An expression in dice notation 137 | expression = ( 138 | StringStart() 139 | + operatorPrecedence( 140 | integer, 141 | [ 142 | (dice_element, 2, opAssoc.LEFT, RandomElement.parse, special), 143 | (dice_element, 1, opAssoc.RIGHT, RandomElement.parse_unary, special), 144 | (wrap_string(CaselessLiteral, "x"), 2, opAssoc.LEFT, Explode.parse), 145 | (wrap_string(CaselessLiteral, "x"), 1, opAssoc.LEFT, Explode.parse), 146 | (wrap_string(CaselessLiteral, "rr"), 2, opAssoc.LEFT, ForceReroll.parse), 147 | (wrap_string(CaselessLiteral, "rr"), 1, opAssoc.LEFT, ForceReroll.parse), 148 | (wrap_string(CaselessLiteral, "r"), 2, opAssoc.LEFT, Reroll.parse), 149 | (wrap_string(CaselessLiteral, "r"), 1, opAssoc.LEFT, Reroll.parse), 150 | (wrap_string(Word, "^hH", exact=1), 2, opAssoc.LEFT, Highest.parse), 151 | (wrap_string(Word, "^hH", exact=1), 1, opAssoc.LEFT, Highest.parse), 152 | (wrap_string(Word, "vlL", exact=1), 2, opAssoc.LEFT, Lowest.parse), 153 | (wrap_string(Word, "vlL", exact=1), 1, opAssoc.LEFT, Lowest.parse), 154 | (wrap_string(Word, "oOmM", exact=1), 2, opAssoc.LEFT, Middle.parse), 155 | (wrap_string(Word, "oOmM", exact=1), 1, opAssoc.LEFT, Middle.parse), 156 | (wrap_string(CaselessLiteral, "a"), 2, opAssoc.LEFT, Again.parse), 157 | (wrap_string(CaselessLiteral, "a"), 1, opAssoc.LEFT, Again.parse), 158 | (wrap_string(CaselessLiteral, "e"), 2, opAssoc.LEFT, Successes.parse), 159 | (wrap_string(CaselessLiteral, "f"), 2, opAssoc.LEFT, SuccessFail.parse), 160 | (wrap_string(CaselessLiteral, "t"), 1, opAssoc.LEFT, Total.parse), 161 | (wrap_string(CaselessLiteral, "s"), 1, opAssoc.LEFT, Sort.parse), 162 | (wrap_string(Literal, "+-"), 1, opAssoc.RIGHT, AddEvenSubOdd.parse), 163 | (wrap_string(Literal, "+"), 1, opAssoc.RIGHT, Identity.parse), 164 | (wrap_string(Literal, "-"), 1, opAssoc.RIGHT, Negate.parse), 165 | (wrap_string(Literal, ".+"), 2, opAssoc.LEFT, ArrayAdd.parse), 166 | (wrap_string(Literal, ".-"), 2, opAssoc.LEFT, ArraySub.parse), 167 | (wrap_string(Literal, "%"), 2, opAssoc.LEFT, Modulo.parse), 168 | (wrap_string(Literal, "/"), 2, opAssoc.LEFT, Div.parse), 169 | (wrap_string(Literal, "*"), 2, opAssoc.LEFT, Mul.parse), 170 | (wrap_string(Literal, "-"), 2, opAssoc.LEFT, Sub.parse), 171 | (wrap_string(Literal, "+"), 2, opAssoc.LEFT, Add.parse), 172 | (wrap_string(Literal, ","), 2, opAssoc.LEFT, Array.parse), 173 | (wrap_string(Literal, "|"), 2, opAssoc.LEFT, Extend.parse), 174 | ], 175 | ) 176 | + StringEnd() 177 | ) 178 | expression.setName("expression") 179 | -------------------------------------------------------------------------------- /dice/tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for the dice package""" 2 | -------------------------------------------------------------------------------- /dice/tests/test_command.py: -------------------------------------------------------------------------------- 1 | from dice import roll, roll_min, roll_max 2 | from dice.command import main 3 | from itertools import product 4 | from pytest import raises 5 | 6 | 7 | def test_roll(): 8 | for single, raw in product((True, False), (True, False)): 9 | assert roll("6d6", single=single, raw=raw) 10 | assert roll_min("6d6", single=single, raw=raw) 11 | assert roll_max("6d6", single=single, raw=raw) 12 | 13 | 14 | def test_main(): 15 | main(["2d6"]) 16 | 17 | 18 | def test_main_verbose(): 19 | main(["2d6", "--verbose"]) 20 | 21 | 22 | def test_main_min(): 23 | main(["2d6", "--min"]) 24 | 25 | 26 | def test_main_max(): 27 | main(["2d6", "--max"]) 28 | 29 | 30 | def test_main_max_dice(): 31 | main(["2d6", "--max-dice", "2"]) 32 | 33 | 34 | def test_main_max_dice_err(): 35 | with raises(SystemExit): 36 | main(["2d6", "--max-dice", "not_a_number"]) 37 | 38 | 39 | def test_main_error(): 40 | with raises(SystemExit): 41 | main(["d0"]) 42 | 43 | 44 | def test_main_error2(): 45 | """Test placing the error on the left""" 46 | with raises(SystemExit): 47 | main(["000000000000000000000000000000000000000001d6, d0"]) 48 | -------------------------------------------------------------------------------- /dice/tests/test_elements.py: -------------------------------------------------------------------------------- 1 | from dice.constants import DiceExtreme 2 | from dice.exceptions import DiceException, DiceFatalException 3 | import pickle 4 | from pytest import raises 5 | import random 6 | 7 | from dice.elements import ( 8 | Integer, 9 | Roll, 10 | WildRoll, 11 | Dice, 12 | FudgeDice, 13 | Total, 14 | MAX_ROLL_DICE, 15 | Element, 16 | RandomElement, 17 | WildDice, 18 | ) 19 | from dice import roll, roll_min, roll_max 20 | 21 | 22 | class TestElements: 23 | def test_integer(self): 24 | assert isinstance(Integer(1), int) 25 | 26 | def test_dice_construct(self): 27 | d = Dice(2, 6) 28 | assert d.amount == 2 and d.amount == 2 and d.sides == d.max_value == 6 29 | 30 | def test_dice_from_iterable(self): 31 | d = Dice.from_iterable((2, 6)) 32 | assert d.amount == 2 and d.sides == 6 33 | 34 | def test_dice_from_string(self): 35 | d = Dice.from_string("2d6") 36 | assert type(d) is Dice 37 | assert d.amount == 2 and d.sides == 6 38 | 39 | w = Dice.from_string("2w6") 40 | assert type(w) is WildDice 41 | assert w.amount == 2 and w.sides == 6 42 | 43 | u = Dice.from_string("2u6") 44 | assert type(u) is FudgeDice 45 | assert u.amount == 2 and u.sides == 6 46 | 47 | def test_dice_format(self): 48 | amount, sides = 6, 6 49 | for sep, cls in RandomElement.DICE_MAP.items(): 50 | d = cls(amount, sides) 51 | assert str(d) == "%i%s%i" % (amount, cls.SEPARATOR, sides) 52 | assert repr(d) == "%s(%i, %i)" % (cls.__name__, amount, sides) 53 | 54 | def test_roll(self): 55 | amount, sides = 6, 6 56 | assert len(Roll.roll(amount, 1, sides)) == amount 57 | 58 | rr = Roll.roll(amount, 1, sides) 59 | assert (1 * sides) <= sum(rr) <= (amount * sides) 60 | 61 | rr = roll("%id%i" % (amount, sides)) 62 | r_min, r_max = rr.random_element.min_value, rr.random_element.max_value 63 | assert r_min <= rr.do_roll_single() <= r_max 64 | 65 | def test_list(self): 66 | assert roll("1, 2, 3") == [1, 2, 3] 67 | 68 | def test_special_rhs(self): 69 | assert roll("d%", raw=True).max_value == 100 70 | fudge = roll("dF", raw=True) 71 | assert fudge.min_value == -1 and fudge.max_value == 1 72 | 73 | def test_extreme_roll(self): 74 | assert roll_min("3d6") == [1] * 3 75 | assert roll_max("3d6") == [6] * 3 76 | 77 | def test_fudge(self): 78 | amount, _range = 6, 6 79 | fdice = FudgeDice(amount, _range) 80 | assert fdice.min_value == -_range and fdice.max_value == _range 81 | froll = fdice.evaluate() 82 | assert len(froll) == amount 83 | assert -(amount * _range) <= sum(froll) <= (amount * _range) 84 | 85 | def test_wild(self): 86 | amount, sides = 6, 6 87 | assert len(WildRoll.roll(amount, 1, sides)) >= amount 88 | rr = WildRoll.roll(amount, 1, sides) 89 | assert 0 <= sum(rr) 90 | 91 | assert WildRoll.roll(0, 1, sides) == [] 92 | 93 | assert WildRoll.roll(1, 1, 1) == [1] 94 | 95 | def test_wild_success(self): 96 | while True: 97 | if len(WildRoll.roll(1, 1, 2)) > 1: 98 | break 99 | 100 | def test_wild_fail(self): 101 | while True: 102 | if WildRoll.roll(1, 1, 2) == [0]: 103 | break 104 | 105 | def test_wild_critfail(self): 106 | while True: 107 | if WildRoll.roll(3, 1, 2) == [0, 0, 0]: 108 | break 109 | 110 | 111 | class TestErrors: 112 | def test_toomanydice(self): 113 | with raises(DiceFatalException): 114 | roll("%id6" % (MAX_ROLL_DICE + 1)) 115 | 116 | with raises(DiceFatalException): 117 | roll("7d6", max_dice=6) 118 | 119 | with raises(ValueError): 120 | Roll.roll(6, 1, 6, max_dice=4) 121 | 122 | def test_negative_amount_extreme(self): 123 | with raises(ValueError): 124 | Roll.roll(-1, 1, 6, force_extreme=DiceExtreme.EXTREME_MAX) 125 | 126 | def test_roll_error(self): 127 | with raises(ValueError): 128 | Roll.roll(-1, 1, 6) 129 | 130 | with raises(ValueError): 131 | Roll.roll_single(4, 3) 132 | 133 | with raises(DiceFatalException): 134 | rolled = roll("6d6") 135 | rolled.do_roll_single(4, 3) 136 | 137 | def test_invalid_wrap(self): 138 | with raises(NotImplementedError): 139 | try: 140 | raise RuntimeError("blah") 141 | except Exception as e: 142 | raise DiceException.from_other(e) 143 | 144 | 145 | class TestEvaluate: 146 | def test_cache(self): 147 | """Test that evaluation returns the same result on successive runs""" 148 | roll("6d(6d6)t") 149 | ast = Total(Dice(6, Dice(6, 6))) 150 | evals = [ast.evaluate_cached() for i in range(100)] 151 | assert len(set(evals)) == 1 152 | assert ast.evaluate_cached() is ast.evaluate_cached() 153 | 154 | def test_nocache(self): 155 | """Test that evaluation returns different result on successive runs""" 156 | ast = Total(Dice(6, Dice(6, 6))) 157 | evals = [ast.evaluate() for i in range(100)] 158 | assert len(set(evals)) > 1 159 | 160 | def test_multiargs(self): 161 | """Test that binary operators function properly when repeated""" 162 | assert roll("1d1+1d1+1d1") == 3 163 | assert roll("1d1-1d1-1d1") == -1 164 | assert roll("1d1*1d1*1d1") == 1 165 | assert roll("1d1/1d1/1d1") == 1 166 | 167 | 168 | class TestRegisterDice: 169 | def test_reregister(self): 170 | class FooDice(RandomElement): 171 | SEPARATOR = "d" 172 | 173 | with raises(RuntimeError): 174 | RandomElement.register_dice(FooDice) 175 | 176 | def test_no_separator(self): 177 | class BarDice(RandomElement): 178 | pass 179 | 180 | with raises(TypeError): 181 | RandomElement.register_dice(BarDice) 182 | 183 | def test_not_random_element(self): 184 | class BazDice(Element): 185 | pass 186 | 187 | with raises(TypeError): 188 | RandomElement.register_dice(BazDice) 189 | 190 | 191 | class TestSystemRandom: 192 | sysrandom = random.SystemRandom() 193 | 194 | # lowest, middle and highest operators use shuffle() 195 | def test_sysrandom_op(self): 196 | roll("6d6h1", random=self.sysrandom) 197 | 198 | def test_sysrandom_roll(self): 199 | roll("6d6", random=self.sysrandom) 200 | 201 | 202 | class TestPickle: 203 | for expr in ["-d20", "4d6t", "+-(1,2,3)", "2d20h", "4d6h3s", "4dF - 2", "4*d%"]: 204 | value = roll(expr, raw=True, single=False) 205 | pickled = pickle.dumps(value) 206 | clone = pickle.loads(pickled) 207 | -------------------------------------------------------------------------------- /dice/tests/test_grammar.py: -------------------------------------------------------------------------------- 1 | from pyparsing import Word, opAssoc 2 | from dice.elements import Integer, Roll, WildRoll, ExplodedRoll 3 | from dice.exceptions import DiceException, DiceFatalException 4 | from dice import roll, roll_min, roll_max, grammar 5 | from pytest import raises 6 | 7 | 8 | class TestInteger: 9 | def test_value(self): 10 | assert roll("1337") == 1337 11 | 12 | def test_type(self): 13 | assert isinstance(roll("1"), int) 14 | assert isinstance(roll("1"), Integer) 15 | 16 | 17 | class TestDice: 18 | def test_dice_value(self): 19 | assert 0 < int(roll("d6")) <= 6 20 | assert 0 < int(roll("6d6")) <= 36 21 | 22 | assert 0 < int(roll("d%")) <= 100 23 | assert 0 < int(roll("6d%")) <= 600 24 | 25 | assert -1 <= int(roll("dF")) <= 1 26 | assert -6 <= int(roll("6dF")) <= 6 27 | assert -6 <= int(roll("u6")) <= 6 28 | assert -36 < int(roll("6u6")) <= 36 29 | 30 | assert 0 <= int(roll("w6")) 31 | assert 0 <= int(roll("6w6")) 32 | 33 | assert 0 < int(roll("d6x")) 34 | assert 0 < int(roll("6d6x")) 35 | 36 | def test_dice_type(self): 37 | assert isinstance(roll("d6"), Roll) 38 | assert isinstance(roll("6d6"), Roll) 39 | assert isinstance(roll("d%"), Roll) 40 | assert isinstance(roll("6d%"), Roll) 41 | 42 | assert isinstance(roll("dF"), Roll) 43 | assert isinstance(roll("6dF"), Roll) 44 | assert isinstance(roll("u6"), Roll) 45 | assert isinstance(roll("6u6"), Roll) 46 | 47 | assert isinstance(roll("w6"), WildRoll) 48 | assert isinstance(roll("6w6"), WildRoll) 49 | 50 | assert isinstance(roll("d6x"), ExplodedRoll) 51 | assert isinstance(roll("6d6x"), ExplodedRoll) 52 | 53 | def test_dice_values(self): 54 | for die in roll("6d6"): 55 | assert 0 < die <= 6 56 | 57 | def test_nested_dice(self): 58 | assert 1 <= roll("d(d6)t") <= 6 59 | assert -6 <= roll("u(d6)t") <= 6 60 | 61 | 62 | class TestOperators: 63 | def test_add(self): 64 | assert roll("2 + 2") == 4 65 | 66 | def test_sub(self): 67 | assert roll("2 - 2") == 0 68 | 69 | def test_mul(self): 70 | assert roll("2 * 2") == 4 71 | assert roll("1d6 * 2") % 2 == 0 72 | 73 | def test_div(self): 74 | assert roll("2 / 2") == 1 75 | 76 | def test_mod(self): 77 | assert roll("5 % 3") == 2 78 | assert roll("1 % 3") == 1 79 | assert 0 <= roll("d6 % 3") <= 2 80 | 81 | def test_identity(self): 82 | assert roll("+2") == 2 83 | assert roll("+(1, 2)") == [1, 2] 84 | 85 | def test_negate(self): 86 | assert roll("-2") == -2 87 | assert roll("-(1, 2)") == [-1, -2] 88 | 89 | def test_aeso(self): 90 | assert roll("+-1") == -1 91 | assert roll("+-2") == 2 92 | assert roll("+-(1, 2)") == [-1, 2] 93 | 94 | 95 | class TestVectorOperators: 96 | def test_total(self): 97 | assert (6 * 1) <= roll("6d6t") <= (6 * 6) 98 | 99 | def test_sort(self): 100 | value = roll("6d6s") 101 | assert value == sorted(value) 102 | assert isinstance(value, Roll) 103 | 104 | with raises(DiceFatalException): 105 | roll("6s") 106 | 107 | def test_drop(self): 108 | value = roll("6d6 v 3") 109 | assert len(value) == 3 110 | 111 | value = roll("(1, 2, 5, 9, 3) v 3") 112 | assert set(value) == set([1, 2, 3]) 113 | 114 | value = roll("6d6v") 115 | assert len(value) == 5 116 | 117 | value = roll("6d6v(-3)") 118 | assert len(value) == 3 119 | 120 | with raises(DiceFatalException): 121 | roll("6 v 3") 122 | 123 | def test_keep(self): 124 | value = roll("6d6 ^ 3") 125 | assert len(value) == 3 126 | 127 | value = roll("(1, 2, 5, 9, 3) ^ 3") 128 | assert set(value) == set([3, 5, 9]) 129 | 130 | value = roll("6d6^") 131 | assert len(value) == 5 132 | 133 | value = roll("6d6^(-3)") 134 | assert len(value) == 3 135 | 136 | with raises(DiceFatalException): 137 | roll("6 ^ 3") 138 | 139 | def test_middle(self): 140 | value = roll("6d6 o 3") 141 | assert len(value) == 3 142 | 143 | value = roll("(1, 2, 5, 9, 3) o 3") 144 | assert set(value) == set([2, 3, 5]) 145 | 146 | value = roll("6d6o") 147 | assert len(value) == 4 148 | 149 | with raises(DiceFatalException): 150 | roll("6 o 3") 151 | 152 | assert len(roll("6d6o(-4)")) == 2 153 | 154 | def test_successes(self): 155 | assert roll("(2, 4, 6, 8) e 5") == 2 156 | assert roll("6 e 5") == 1 157 | assert roll("100d20e1") == 100 158 | with raises(DiceFatalException): 159 | roll("d20 e 21") 160 | with raises(DiceFatalException): 161 | roll("d(d6) e 6") 162 | 163 | def test_successe_failures(self): 164 | assert roll("(1, 2, 4, 6, 8) f 5") == 1 165 | assert roll("6 f 5") == 1 166 | assert roll("100d20f1") == 100 167 | with raises(DiceFatalException): 168 | roll("d20 f 21") 169 | with raises(DiceFatalException): 170 | roll("d(d6) f 6") 171 | 172 | def test_array_add(self): 173 | assert roll("(2, 4, 6, 8) .+ 2") == [4, 6, 8, 10] 174 | 175 | def test_array_sub(self): 176 | assert roll("(2, 4, 6, 8) .- 2") == [0, 2, 4, 6] 177 | 178 | def test_array(self): 179 | rr = roll("2d6, 3d6, 4d6") 180 | assert len(rr) == 3 181 | 182 | def test_extend(self): 183 | rr = roll("2d6 | 3d6, 4d6") 184 | assert len(rr) == 4 185 | 186 | rr2 = roll("2d6 | 3d6 | 4d6") 187 | assert len(rr2) == 9 188 | 189 | rr3 = roll("2d6 | 3d6 | 10 | 4d6") 190 | assert len(rr3) == 10 191 | 192 | 193 | class TestDiceOperators: 194 | def test_reroll(self): 195 | r = roll("6d6r") 196 | assert len(r) == 6 197 | 198 | def test_force_reroll(self): 199 | r2 = roll("1000d6rr") 200 | assert 1 not in r2 201 | 202 | r3 = roll("100d6rr5") 203 | assert all(x > 5 for x in r3) 204 | 205 | def test_extreme_reroll(self): 206 | r = roll_min("2d6r") 207 | assert r == [1, 1] 208 | 209 | r = roll_max("2d6r6") 210 | assert r == [6, 6] 211 | 212 | def test_again_roll(self): 213 | while True: 214 | r = roll("6d6a") 215 | if 6 in r: 216 | break 217 | 218 | num_6 = r.count(6) 219 | assert 6 == len(r) - (num_6 // 2) 220 | 221 | def test_again_scalar(self): 222 | assert roll("6a6") == [6, 6] 223 | 224 | def test_again_vector(self): 225 | assert roll("(1,2,3)a2") == [1, 2, 2, 3] 226 | assert roll("(1|1|1)a1") == [1, 1, 1, 1, 1, 1] 227 | 228 | 229 | class TestErrors: 230 | @staticmethod 231 | def run_test(expr): 232 | with raises((DiceException, DiceFatalException)): 233 | roll(expr) 234 | 235 | def test_bad_operators(self): 236 | for expr in ("6d", "1+", "[1,2,3]", "f", "3f", "3x", "6.+6", "7.-7"): 237 | self.run_test(expr) 238 | 239 | def test_invalid_rolls(self): 240 | for expr in ("(0-1)d6", "6d(0-1)", "d0", "6d0"): 241 | self.run_test(expr) 242 | 243 | def test_unmatched_parenthesis(self): 244 | for expr in ("(6d6", "6d6)"): 245 | self.run_test(expr) 246 | 247 | def test_explode_min(self): 248 | self.run_test("6d6x1") 249 | self.run_test("6d6x(1-2)") 250 | 251 | def test_explode_onesided(self): 252 | self.run_test("6d1x") 253 | 254 | def test_invalid_reroll(self): 255 | for expr in ("6r", "(1,2)r", "6rr", "(1,2)rr", "d(d6)rr", "u(d6)r"): 256 | self.run_test(expr) 257 | 258 | def test_div_zero(self): 259 | self.run_test("1/0") 260 | self.run_test("1/(0*1)") 261 | 262 | def test_again_fail(self): 263 | self.run_test("(1,2,3)a") 264 | self.run_test("1a") 265 | 266 | 267 | class TestOperatorPrecedence: 268 | def test_operator_precedence_1(self): 269 | assert roll("16 / 8 * 4 + 2 - 1") == 9 270 | 271 | def test_operator_precedence_2(self): 272 | assert roll("16 - 8 + 4 * 2 / 1") == 16 273 | 274 | def test_operator_precedence_3(self): 275 | assert roll("10 - 3 + 2") == 9 276 | 277 | def test_operator_precedence_4(self): 278 | assert roll("1 + 2 * 3") == 7 279 | 280 | 281 | class TestExpression: 282 | def test_expression(self): 283 | assert isinstance(roll("2d6"), Roll) 284 | 285 | def test_sub_expression(self): 286 | assert isinstance(roll("(2d6)d(2d6)"), Roll) 287 | 288 | 289 | class TestBadPrecedence: 290 | def test_invalid_arity(self): 291 | with raises(Exception): 292 | grammar.operatorPrecedence( 293 | grammar.integer, [(Word("0"), 3, opAssoc.LEFT, Integer.parse)] 294 | ) 295 | 296 | def test_invalid_association(self): 297 | with raises(Exception): 298 | grammar.operatorPrecedence( 299 | grammar.integer, [(Word("0"), 2, None, Integer.parse)] 300 | ) 301 | -------------------------------------------------------------------------------- /dice/tests/test_utilities.py: -------------------------------------------------------------------------------- 1 | from pyparsing import Literal, ParseFatalException 2 | from pytest import raises 3 | import random 4 | import string 5 | 6 | from dice import roll, utilities 7 | from dice.utilities import verbose_print 8 | from dice.elements import RandomElement, FudgeDice 9 | 10 | 11 | def test_enable_pyparsing_packrat_parsing(): 12 | """Test that packrat parsing was enabled""" 13 | import pyparsing 14 | 15 | assert pyparsing.ParserElement._packratEnabled is True 16 | 17 | 18 | class TestVerbosePrint: 19 | def _get_vprint(self, expr): 20 | raw = roll(expr, raw=True) 21 | evaluated = raw.evaluate_cached() 22 | vprint = verbose_print(raw) 23 | return evaluated, vprint 24 | 25 | def test_dice_simple(self): 26 | v = self._get_vprint("6d6")[1] 27 | assert v.startswith("roll 6d6 -> ") 28 | 29 | def test_dice_complex(self): 30 | v = self._get_vprint("6d(6d6)t")[1] 31 | lines = v.split("\n") 32 | stripped = [l.strip() for l in lines] 33 | 34 | assert len(lines) == 6 35 | assert lines[0] == "Total(" 36 | assert stripped[1] == "Dice(" 37 | assert stripped[2] == "6," 38 | assert stripped[3].startswith("roll 6d6") 39 | assert stripped[4].startswith(") -> ") 40 | assert stripped[5].startswith(") -> ") 41 | 42 | def test_unevaluated(self): 43 | r = roll("d20", raw=True) 44 | v = verbose_print(r) 45 | assert v.startswith("roll 1d20 -> ") 46 | 47 | def test_single_line(self): 48 | v = self._get_vprint("d6x")[1] 49 | assert len(v.split("\n")) == 1 50 | 51 | 52 | class TestDiceSwitch: 53 | def test_separator_map(self): 54 | for sep, cls in RandomElement.DICE_MAP.items(): 55 | d = utilities.dice_switch(6, 6, sep) 56 | assert type(d) is cls 57 | 58 | def test_percentile(self): 59 | for sep, cls in RandomElement.DICE_MAP.items(): 60 | d = utilities.dice_switch(6, "%", sep) 61 | assert d.sides == 100 62 | 63 | def test_fudge(self): 64 | assert type(utilities.dice_switch(1, "f", "d")) == FudgeDice 65 | assert type(utilities.dice_switch(1, "f", "u")) == FudgeDice 66 | 67 | with raises(ValueError): 68 | utilities.dice_switch(1, "f", "w") 69 | 70 | def test_invalid(self): 71 | unused = set(string.ascii_lowercase) - set(RandomElement.DICE_MAP) 72 | 73 | bad_params = [(1, 0, "d"), (1, 6, "dd")] 74 | 75 | if unused: 76 | unused_char = random.choice(list(unused)) 77 | bad_params.append((1, 6, unused_char)) 78 | 79 | for params in bad_params: 80 | with raises(ValueError): 81 | utilities.dice_switch(*params) 82 | 83 | 84 | def test_binary_stack(): 85 | with raises(ParseFatalException): 86 | roll("6d6d6") 87 | 88 | 89 | def test_too_many_explosions(): 90 | with raises(ParseFatalException): 91 | while True: 92 | roll("1000d1000x2") 93 | -------------------------------------------------------------------------------- /dice/utilities.py: -------------------------------------------------------------------------------- 1 | import dice.elements 2 | from dice.constants import VERBOSE_INDENT 3 | 4 | 5 | def classname(obj): 6 | """Returns the name of an objects class""" 7 | return obj.__class__.__name__ 8 | 9 | 10 | def single(iterable): 11 | """Returns a single item if the iterable has only one item""" 12 | return iterable[0] if len(iterable) == 1 else iterable 13 | 14 | 15 | def wrap_string(cls, *args, **kwargs): 16 | suppress = kwargs.pop("suppress", True) 17 | e = cls(*args, **kwargs) 18 | e.setParseAction(dice.elements.String.parse) 19 | if suppress: 20 | return e.suppress() 21 | return e 22 | 23 | 24 | def add_even_sub_odd(operator, operand): 25 | """Add even numbers, subtract odd ones. See http://1w6.org/w6""" 26 | try: 27 | for i, x in enumerate(operand): 28 | if x % 2: 29 | operand[i] = -x 30 | return operand 31 | except TypeError: 32 | if operand % 2: 33 | return -operand 34 | return operand 35 | 36 | 37 | def dice_switch(amount, dice_type, kind="d"): 38 | kind = kind.lower() 39 | if len(kind) != 1: 40 | raise ValueError("Dice operator must be 1 letter", 1) 41 | 42 | if isinstance(dice_type, int) and int(dice_type) < 1: 43 | raise ValueError("Number of sides must be one or more", 2) 44 | elif isinstance(dice_type, str): 45 | dice_type = dice_type.lower() 46 | 47 | if dice_type == "f": 48 | if kind not in ("d", "u"): 49 | raise ValueError("can only use dF or uF", 2) 50 | return dice.elements.FudgeDice(amount, 1) 51 | elif kind not in dice.elements.RandomElement.DICE_MAP: 52 | raise ValueError("unknown dice kind: %s" % kind, 1) 53 | 54 | random_element = dice.elements.RandomElement.DICE_MAP[kind] 55 | 56 | if str(dice_type) == "%": 57 | dice_type = 100 58 | 59 | return random_element(amount, dice_type) 60 | 61 | 62 | def verbose_print_op(element, depth=0): 63 | lines = [[depth, classname(element) + "("]] 64 | num_ops = len(element.original_operands) 65 | 66 | for i, e in enumerate(element.original_operands): 67 | newlines = verbose_print_sub(e, depth + 1) 68 | 69 | if len(newlines) > 1 or num_ops > 1: 70 | if i + 1 < num_ops: 71 | newlines[-1].append(",") 72 | lines.extend(newlines) 73 | else: 74 | lines[-1].extend(newlines[0][1:]) 75 | 76 | closing = ") -> %s" % element.result 77 | 78 | if num_ops > 1 or len(lines) > 1 and lines[-1][0] < lines[-2][0]: 79 | lines.append([depth, closing]) 80 | else: 81 | lines[-1].append(closing) 82 | 83 | return lines 84 | 85 | 86 | def verbose_print_sub(element, indent=0, **kwargs): 87 | lines = [] 88 | if isinstance(element, dice.elements.Element) and not hasattr(element, "result"): 89 | element.evaluate_cached(**kwargs) 90 | 91 | if isinstance(element, dice.elements.Operator): 92 | return verbose_print_op(element, indent) 93 | 94 | elif isinstance(element, dice.elements.Dice): 95 | if any( 96 | not isinstance(op, (dice.elements.Integer, int)) 97 | for op in element.original_operands 98 | ): 99 | return verbose_print_op(element, indent) 100 | 101 | line = "roll %s -> %s" % (element, element.result) 102 | else: 103 | line = str(element) 104 | 105 | lines.append([indent, line]) 106 | return lines 107 | 108 | 109 | def verbose_print(element, **kwargs): 110 | lines = verbose_print_sub(element, **kwargs) 111 | lines = [(" " * (VERBOSE_INDENT * t[0]) + "".join(t[1:])) for t in lines] 112 | return "\n".join(lines) 113 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "dice" 7 | version = "4.0.0" 8 | authors = [ 9 | { name = "Sam Clements", email = "sam@borntyping.co.uk" }, 10 | { name = "Caleb Johnson", email = "me@calebj.io" }, 11 | ] 12 | maintainers = [ 13 | { name = "Sam Clements", email = "sam@borntyping.co.uk" }, 14 | ] 15 | description = "A library for parsing and evaluating dice notation" 16 | readme = "README.md" 17 | requires-python = ">=3.7" 18 | license = { text = "MIT" } 19 | classifiers = [ 20 | "Development Status :: 6 - Mature", 21 | "Environment :: Console", 22 | "Intended Audience :: Developers", 23 | "Intended Audience :: Other Audience", 24 | "License :: OSI Approved :: MIT License", 25 | "Operating System :: OS Independent", 26 | "Programming Language :: Python", 27 | "Programming Language :: Python :: 3", 28 | "Programming Language :: Python :: 3.8", 29 | "Programming Language :: Python :: 3.9", 30 | "Programming Language :: Python :: 3.10", 31 | "Programming Language :: Python :: 3.11", 32 | "Programming Language :: Python :: 3.12", 33 | "Topic :: Games/Entertainment", 34 | "Topic :: Games/Entertainment :: Board Games", 35 | "Topic :: Games/Entertainment :: Role-Playing", 36 | "Topic :: Games/Entertainment :: Multi-User Dungeons (MUD)", 37 | "Topic :: Games/Entertainment :: Turn Based Strategy", 38 | "Topic :: Utilities", 39 | ] 40 | keywords = ["dice"] 41 | dependencies = [ 42 | "pyparsing>=2.4.1", 43 | ] 44 | urls = { homepage = "https://github.com/borntyping/python-dice" } 45 | 46 | [project.scripts] 47 | dice = "dice.command:main" 48 | roll = "dice.command:main" 49 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | minversion=1.5.0 3 | envlist=py38,py39,py310,py311,py312 4 | skip_missing_interpreters=true 5 | 6 | [testenv] 7 | commands=pytest dice 8 | deps=pytest 9 | 10 | [testenv:black] 11 | commands=black --check --diff . 12 | deps=black 13 | 14 | [testenv:release] 15 | commands=python setup.py sdist bdist_wheel upload 16 | skip_sdist=true 17 | deps=wheel 18 | --------------------------------------------------------------------------------