From b351deaaddb992f08557536bab5173cb484ef28a Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 17 Jun 2023 18:53:48 +0200 Subject: [PATCH] Refactor dice contrib --- CHANGELOG.md | 5 + docs/source/Coding/Changelog.md | 5 + docs/source/Contribs/Contrib-Dice.md | 66 +++++++++- evennia/contrib/rpg/dice/README.md | 66 +++++++++- evennia/contrib/rpg/dice/dice.py | 179 ++++++++++++++++++--------- evennia/contrib/rpg/dice/tests.py | 21 +++- 6 files changed, 271 insertions(+), 71 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06e0ec6dff..bd862e33a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Main + +- Contrib: Refactored `dice.roll` contrib function to use `safe_eval`. Can now + optionally be used as `dice.roll("2d10 + 4 > 10")`. Old way works too. + ## Evennia 2.0.1 June 17, 2023 diff --git a/docs/source/Coding/Changelog.md b/docs/source/Coding/Changelog.md index 06e0ec6dff..98dbc6313c 100644 --- a/docs/source/Coding/Changelog.md +++ b/docs/source/Coding/Changelog.md @@ -1,5 +1,10 @@ # Changelog +## Main + +- Contrib update: Made `dice.roll` contrib function optionally accept dice + definition string e.g. `dice.roll("2d10 + 4 > 10")`. Old way works too. + ## Evennia 2.0.1 June 17, 2023 diff --git a/docs/source/Contribs/Contrib-Dice.md b/docs/source/Contribs/Contrib-Dice.md index 9e07b53283..fd48c3e448 100644 --- a/docs/source/Contribs/Contrib-Dice.md +++ b/docs/source/Contribs/Contrib-Dice.md @@ -22,7 +22,7 @@ from evennia.contrib.rpg import dice <--- class CharacterCmdSet(default_cmds.CharacterCmdSet): # ... - def at_object_creation(self): + def at_cmdset_creation(self): # ... self.add(dice.CmdDice()) # <--- @@ -53,17 +53,73 @@ was. Is a hidden roll that does not inform the room it happened. -### Rolling dice from code +## Rolling dice from code -To roll dice in code, use the `roll` function from this module: +To roll dice in code, use the `roll` function from this module. It has two +main ways to define the expected roll: ```python +from evennia.contrib.rpg.dice import roll -from evennia.contrib.rpg import dice -dice.roll(3, 10, ("+", 2)) # 3d10 + 2 +roll(dice, dicetype=6, modifier=None, conditional=None, return_tuple=False, + max_dicenum=10, max_dicetype=1000) ``` +You can only roll one set of dice. If your RPG requires you to roll multiple +sets of dice and combine them in more advanced ways, you can do so with multiple +`roll()` calls. + +### Roll dice based on a string + +You can specify the first argument as a string on standard RPG d-syntax (NdM, +where N is the number of dice to roll, and M is the number sides per dice): + +```python +roll("3d10 + 2") +``` + +You can also give a conditional (you'll then get a `True`/`False` back): + +```python +roll("2d6 - 1 >= 10") +``` + +### Explicit arguments + +If you specify the first argument as an integer, it's interpret as the number of +dice to roll and you can then build the roll more explicitly. This can be +useful if you are using the roller together with some other system and want to +construct the roll from components. + + +Here's how to roll `3d10 + 2` with explicit syntax: + +```python +roll(3, 10, modifier=("+", 2)) +``` + +Here's how to roll `2d6 - 1 >= 10` (you'll get back `True`/`False` back): + +```python +roll(2, 6, modifier=("-", 1), conditional=(">=", 10)) +``` + +### Get all roll details + +If you need the individual rolls (e.g. for a dice pool), set the `return_tuple` kwarg: + +```python +roll("3d10 > 10", return_tuple=True) +(13, True, 3, (3, 4, 6)) # (result, outcome, diff, rolls) +``` + +The return is a tuple `(result, outcome, diff, rolls)`, where `result` is the +result of the roll, `outcome` is `True/False` if a conditional was +given (`None` otherwise), `diff` is the absolute difference between the +conditional and the result (`None` otherwise) and `rolls` is a tuple containing +the individual roll results. + ---- diff --git a/evennia/contrib/rpg/dice/README.md b/evennia/contrib/rpg/dice/README.md index db20407341..f9d64264e2 100644 --- a/evennia/contrib/rpg/dice/README.md +++ b/evennia/contrib/rpg/dice/README.md @@ -22,7 +22,7 @@ from evennia.contrib.rpg import dice <--- class CharacterCmdSet(default_cmds.CharacterCmdSet): # ... - def at_object_creation(self): + def at_cmdset_creation(self): # ... self.add(dice.CmdDice()) # <--- @@ -53,13 +53,69 @@ was. Is a hidden roll that does not inform the room it happened. -### Rolling dice from code +## Rolling dice from code -To roll dice in code, use the `roll` function from this module: +To roll dice in code, use the `roll` function from this module. It has two +main ways to define the expected roll: ```python +from evennia.contrib.rpg.dice import roll -from evennia.contrib.rpg import dice -dice.roll(3, 10, ("+", 2)) # 3d10 + 2 +roll(dice, dicetype=6, modifier=None, conditional=None, return_tuple=False, + max_dicenum=10, max_dicetype=1000) ``` + +You can only roll one set of dice. If your RPG requires you to roll multiple +sets of dice and combine them in more advanced ways, you can do so with multiple +`roll()` calls. + +### Roll dice based on a string + +You can specify the first argument as a string on standard RPG d-syntax (NdM, +where N is the number of dice to roll, and M is the number sides per dice): + +```python +roll("3d10 + 2") +``` + +You can also give a conditional (you'll then get a `True`/`False` back): + +```python +roll("2d6 - 1 >= 10") +``` + +### Explicit arguments + +If you specify the first argument as an integer, it's interpret as the number of +dice to roll and you can then build the roll more explicitly. This can be +useful if you are using the roller together with some other system and want to +construct the roll from components. + + +Here's how to roll `3d10 + 2` with explicit syntax: + +```python +roll(3, 10, modifier=("+", 2)) +``` + +Here's how to roll `2d6 - 1 >= 10` (you'll get back `True`/`False` back): + +```python +roll(2, 6, modifier=("-", 1), conditional=(">=", 10)) +``` + +### Get all roll details + +If you need the individual rolls (e.g. for a dice pool), set the `return_tuple` kwarg: + +```python +roll("3d10 > 10", return_tuple=True) +(13, True, 3, (3, 4, 6)) # (result, outcome, diff, rolls) +``` + +The return is a tuple `(result, outcome, diff, rolls)`, where `result` is the +result of the roll, `outcome` is `True/False` if a conditional was +given (`None` otherwise), `diff` is the absolute difference between the +conditional and the result (`None` otherwise) and `rolls` is a tuple containing +the individual roll results. diff --git a/evennia/contrib/rpg/dice/dice.py b/evennia/contrib/rpg/dice/dice.py index 0862eb066a..4c9f1f7fb9 100644 --- a/evennia/contrib/rpg/dice/dice.py +++ b/evennia/contrib/rpg/dice/dice.py @@ -48,48 +48,62 @@ To roll dice in code, use the `roll` function from this module: ```python from evennia.contrib.rpg import dice -dice.roll_dice(3, 10, ("+", 2)) # 3d10 + 2 - +dice.roll(3, 10, ("+", 2)) # 3d10 + 2 ``` +or use the string syntax: + +dice.roll("3d10 + 2") + """ import re +from ast import literal_eval from random import randint from evennia import CmdSet, default_cmds +from evennia.utils.utils import simple_eval -def roll(dicenum, dicetype, modifier=None, conditional=None, return_tuple=False): +def roll( + dice, + dicetype=6, + modifier=None, + conditional=None, + return_tuple=False, + max_dicenum=10, + max_dicetype=1000, +): """ This is a standard dice roller. Args: - dicenum (int): Number of dice to roll (the result to be added). - dicetype (int): Number of sides of the dice to be rolled. - modifier (tuple): A tuple `(operator, value)`, where operator is + dice (int or str): If an `int`, this is the number of dice to roll, and `dicetype` is used + to determine the type. If a `str`, it should be on the form `NdM` where `N` is the number + of dice and `M` is the number of sides on each die. Also + `NdM [modifier] [number] [conditional]` is understood, e.g. `1d6 + 3` + or `2d10 / 2 > 10`. + dicetype (int, optional): Number of sides of the dice to be rolled. Ignored if + `dice` is a string. + modifier (tuple, optional): A tuple `(operator, value)`, where operator is one of `"+"`, `"-"`, `"/"` or `"*"`. The result of the dice - roll(s) will be modified by this value. - conditional (tuple): A tuple `(conditional, value)`, where + roll(s) will be modified by this value. Ignored if `dice` is a string. + conditional (tuple, optional): A tuple `(conditional, value)`, where conditional is one of `"=="`,`"<"`,`">"`,`">="`,`"<=`" or "`!=`". - This allows the roller to directly return a result depending - on if the conditional was passed or not. + Ignored if `dice` is a string. return_tuple (bool): Return a tuple with all individual roll results or not. + max_dicenum (int): The max number of dice to allow to be rolled. + max_dicetype (int): The max number of sides on the dice to roll. Returns: - roll_result (int): The result of the roll + modifiers. This is the - default return. - condition_result (bool): A True/False value returned if `conditional` - is set but not `return_tuple`. This effectively hides the result - of the roll. - full_result (tuple): If, return_tuple` is `True`, instead - return a tuple `(result, outcome, diff, rolls)`. Here, - `result` is the normal result of the roll + modifiers. - `outcome` and `diff` are the boolean result of the roll and - absolute difference to the `conditional` input; they will - be will be `None` if `conditional` is not set. `rolls` is - itself a tuple holding all the individual rolls in the case of - multiple die-rolls. + int, bool or tuple : By default, this is the result of the roll + modifiers. If + `conditional` is given, or `dice` is a string defining a conditional, then a True/False + value is returned. Finally, if `return_tuple` is set, this is a tuple + `(result, outcome, diff, rolls)`, where, `result` is the the normal result of the + roll + modifiers, `outcome` and `diff` are the boolean absolute difference between the roll + and the `conditional` input; both will be will be `None` if `conditional` is not set. + The `rolls` a tuple holding all the individual rolls (one or more depending on how many + dice were rolled). Raises: TypeError if non-supported modifiers or conditionals are given. @@ -98,48 +112,100 @@ def roll(dicenum, dicetype, modifier=None, conditional=None, return_tuple=False) All input numbers are converted to integers. Examples: - print roll_dice(2, 6) # 2d6 - <<< 7 - print roll_dice(1, 100, ('+', 5) # 1d100 + 5 - <<< 34 - print roll_dice(1, 20, conditional=('<', 10) # let'say we roll 3 - <<< True - print roll_dice(3, 10, return_tuple=True) - <<< (11, None, None, (2, 5, 4)) - print roll_dice(2, 20, ('-', 2), conditional=('>=', 10), return_tuple=True) - <<< (8, False, 2, (4, 6)) # roll was 4 + 6 - 2 = 8 + :: + # explicit arguments + print roll(2, 6) # 2d6 + 7 + print roll(1, 100, ('+', 5) # 1d100 + 5 + 4 + print roll(1, 20, conditional=('<', 10) # let'say we roll 3 + True + print roll(3, 10, return_tuple=True) + (11, None, None, (2, 5, 4)) + print roll(2, 20, ('-', 2), conditional=('>=', 10), return_tuple=True) + (8, False, 2, (4, 6)) # roll was 4 + 6 - 2 = 8 + + # string form + print roll("3d6 + 2") + 10 + print roll("2d10 + 2 > 10") + True + print roll("2d20 - 2 >= 10") + (8, False, 2, (4, 6)) # roll was 4 + 6 - 2 = 8 """ - dicenum = int(dicenum) - dicetype = int(dicetype) + + modifier_string = "" + conditional_string = "" + conditional_value = None + if isinstance(dice, str) and "d" in dice.lower(): + # A string is given, parse it as NdM dice notation + roll_string = dice.lower() + + # split to get the NdM syntax + dicenum, rest = roll_string.split("d", 1) + + # parse packwards right-to-left + if any(True for cond in ("==", "<", ">", "!=", "<=", ">=") if cond in rest): + # split out any conditionals, like '< 12' + rest, *conditionals = re.split(r"(==|<=|>=|<|>|!=)", rest, maxsplit=1) + try: + conditional_value = int(conditionals[1]) + except ValueError: + raise TypeError( + f"Conditional '{conditionals[-1]}' was not recognized. Must be a number." + ) + conditional_string = "".join(conditionals) + + if any(True for op in ("+", "-", "*", "/") if op in rest): + # split out any modifiers, like '+ 2' + rest, *modifiers = re.split(r"(\+|-|/|\*)", rest, maxsplit=1) + modifier_string = "".join(modifiers) + + # whatever is left is the dice type + dicetype = rest + + else: + # an integer is given - explicit modifiers and conditionals as part of kwargs + dicenum = int(dice) + dicetype = int(dicetype) + if modifier: + modifier_string = "".join(str(part) for part in modifier) + if conditional: + conditional_value = int(conditional[1]) + conditional_string = "".join(str(part) for part in conditional) + + try: + dicenum = int(dicenum) + dicetype = int(dicetype) + except Exception: + raise TypeError( + f"The number of dice and dice-size must both be numerical. Got '{dicenum}' " + f"and '{dicetype}'." + ) + if 0 < dicenum > max_dicenum: + raise TypeError(f"Invalid number of dice rolled (must be between 1 and {max_dicenum}).") + if 0 < dicetype > max_dicetype: + raise TypeError(f"Invalid die-size used (must be between 1 and {max_dicetype} sides).") # roll all dice, remembering each roll - rolls = tuple([randint(1, dicetype) for roll in range(dicenum)]) + rolls = tuple([randint(1, dicetype) for _ in range(dicenum)]) result = sum(rolls) - if modifier: - # make sure to check types well before eval - mod, modvalue = modifier - if mod not in ("+", "-", "*", "/"): - raise TypeError("Non-supported dice modifier: %s" % mod) - modvalue = int(modvalue) # for safety - result = eval("%s %s %s" % (result, mod, modvalue)) + if modifier_string: + result = simple_eval(f"{result} {modifier_string}") + outcome, diff = None, None - if conditional: - # make sure to check types well before eval - cond, condvalue = conditional - if cond not in (">", "<", ">=", "<=", "!=", "=="): - raise TypeError("Non-supported dice result conditional: %s" % conditional) - condvalue = int(condvalue) # for safety - outcome = eval("%s %s %s" % (result, cond, condvalue)) # True/False - diff = abs(result - condvalue) + if conditional_string and conditional_value: + outcome = simple_eval(f"{result} {conditional_string}") + diff = abs(result - conditional_value) + if return_tuple: return result, outcome, diff, rolls + elif conditional or (conditional_string and conditional_value): + return outcome # True|False else: - if conditional: - return outcome - else: - return result + return result # integer # legacy alias @@ -235,7 +301,8 @@ class CmdDice(default_cmds.MuxCommand): except ValueError: self.caller.msg( "You need to enter valid integer numbers, modifiers and operators." - " |w%s|n was not understood." % self.args + " |w%s|n was not understood." + % self.args ) return # format output diff --git a/evennia/contrib/rpg/dice/tests.py b/evennia/contrib/rpg/dice/tests.py index 3f5c37cfc6..9283941514 100644 --- a/evennia/contrib/rpg/dice/tests.py +++ b/evennia/contrib/rpg/dice/tests.py @@ -3,9 +3,8 @@ Testing of TestDice. """ -from mock import patch - from evennia.commands.default.tests import BaseEvenniaCommandTest +from mock import patch from . import dice @@ -13,9 +12,9 @@ from . import dice @patch("evennia.contrib.rpg.dice.dice.randint", return_value=5) class TestDice(BaseEvenniaCommandTest): def test_roll_dice(self, mocked_randint): - self.assertEqual(dice.roll_dice(6, 6, modifier=("+", 4)), mocked_randint() * 6 + 4) - self.assertEqual(dice.roll_dice(6, 6, conditional=("<", 35)), True) - self.assertEqual(dice.roll_dice(6, 6, conditional=(">", 33)), False) + self.assertEqual(dice.roll(6, 6, modifier=("+", 4)), mocked_randint() * 6 + 4) + self.assertEqual(dice.roll(6, 6, conditional=("<", 35)), True) + self.assertEqual(dice.roll(6, 6, conditional=(">", 33)), False) def test_cmddice(self, mocked_randint): self.call( @@ -23,3 +22,15 @@ class TestDice(BaseEvenniaCommandTest): ) self.call(dice.CmdDice(), "100000d1000", "The maximum roll allowed is 10000d10000.") self.call(dice.CmdDice(), "/secret 3d6 + 4", "You roll 3d6 + 4 (secret, not echoed).") + + def test_string_form(self, mocked_randint): + self.assertEqual(dice.roll("6d6 + 4"), mocked_randint() * 6 + 4) + self.assertEqual(dice.roll("6d6 < 35"), True) + self.assertEqual(dice.roll("6d6 > 35"), False) + self.assertEqual(dice.roll("2d10 + 5 >= 14"), True) + + def test_maxvals(self, mocked_randint): + with self.assertRaises(TypeError): + dice.roll(11, 1001, max_dicenum=10, max_dicetype=1000) + with self.assertRaises(TypeError): + dice.roll(10, 1001, max_dicenum=10, max_dicetype=1000)