Refactor dice contrib

This commit is contained in:
Griatch 2023-06-17 18:53:48 +02:00
parent 093d0ebb07
commit b351deaadd
6 changed files with 271 additions and 71 deletions

View file

@ -1,5 +1,10 @@
# Changelog # 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 ## Evennia 2.0.1
June 17, 2023 June 17, 2023

View file

@ -1,5 +1,10 @@
# Changelog # 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 ## Evennia 2.0.1
June 17, 2023 June 17, 2023

View file

@ -22,7 +22,7 @@ from evennia.contrib.rpg import dice <---
class CharacterCmdSet(default_cmds.CharacterCmdSet): class CharacterCmdSet(default_cmds.CharacterCmdSet):
# ... # ...
def at_object_creation(self): def at_cmdset_creation(self):
# ... # ...
self.add(dice.CmdDice()) # <--- self.add(dice.CmdDice()) # <---
@ -53,17 +53,73 @@ was.
Is a hidden roll that does not inform the room it happened. 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 ```python
from evennia.contrib.rpg.dice import roll
from evennia.contrib.rpg import dice roll(dice, dicetype=6, modifier=None, conditional=None, return_tuple=False,
dice.roll(3, 10, ("+", 2)) # 3d10 + 2 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.
---- ----

View file

@ -22,7 +22,7 @@ from evennia.contrib.rpg import dice <---
class CharacterCmdSet(default_cmds.CharacterCmdSet): class CharacterCmdSet(default_cmds.CharacterCmdSet):
# ... # ...
def at_object_creation(self): def at_cmdset_creation(self):
# ... # ...
self.add(dice.CmdDice()) # <--- self.add(dice.CmdDice()) # <---
@ -53,13 +53,69 @@ was.
Is a hidden roll that does not inform the room it happened. 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 ```python
from evennia.contrib.rpg.dice import roll
from evennia.contrib.rpg import dice roll(dice, dicetype=6, modifier=None, conditional=None, return_tuple=False,
dice.roll(3, 10, ("+", 2)) # 3d10 + 2 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.

View file

@ -48,48 +48,62 @@ To roll dice in code, use the `roll` function from this module:
```python ```python
from evennia.contrib.rpg import dice 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 import re
from ast import literal_eval
from random import randint from random import randint
from evennia import CmdSet, default_cmds 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. This is a standard dice roller.
Args: Args:
dicenum (int): Number of dice to roll (the result to be added). dice (int or str): If an `int`, this is the number of dice to roll, and `dicetype` is used
dicetype (int): Number of sides of the dice to be rolled. to determine the type. If a `str`, it should be on the form `NdM` where `N` is the number
modifier (tuple): A tuple `(operator, value)`, where operator is 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 one of `"+"`, `"-"`, `"/"` or `"*"`. The result of the dice
roll(s) will be modified by this value. roll(s) will be modified by this value. Ignored if `dice` is a string.
conditional (tuple): A tuple `(conditional, value)`, where conditional (tuple, optional): A tuple `(conditional, value)`, where
conditional is one of `"=="`,`"<"`,`">"`,`">="`,`"<=`" or "`!=`". conditional is one of `"=="`,`"<"`,`">"`,`">="`,`"<=`" or "`!=`".
This allows the roller to directly return a result depending Ignored if `dice` is a string.
on if the conditional was passed or not.
return_tuple (bool): Return a tuple with all individual roll return_tuple (bool): Return a tuple with all individual roll
results or not. 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: Returns:
roll_result (int): The result of the roll + modifiers. This is the int, bool or tuple : By default, this is the result of the roll + modifiers. If
default return. `conditional` is given, or `dice` is a string defining a conditional, then a True/False
condition_result (bool): A True/False value returned if `conditional` value is returned. Finally, if `return_tuple` is set, this is a tuple
is set but not `return_tuple`. This effectively hides the result `(result, outcome, diff, rolls)`, where, `result` is the the normal result of the
of the roll. roll + modifiers, `outcome` and `diff` are the boolean absolute difference between the roll
full_result (tuple): If, return_tuple` is `True`, instead and the `conditional` input; both will be will be `None` if `conditional` is not set.
return a tuple `(result, outcome, diff, rolls)`. Here, The `rolls` a tuple holding all the individual rolls (one or more depending on how many
`result` is the normal result of the roll + modifiers. dice were rolled).
`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.
Raises: Raises:
TypeError if non-supported modifiers or conditionals are given. 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. All input numbers are converted to integers.
Examples: Examples:
print roll_dice(2, 6) # 2d6 ::
<<< 7 # explicit arguments
print roll_dice(1, 100, ('+', 5) # 1d100 + 5 print roll(2, 6) # 2d6
<<< 34 7
print roll_dice(1, 20, conditional=('<', 10) # let'say we roll 3 print roll(1, 100, ('+', 5) # 1d100 + 5
<<< True 4
print roll_dice(3, 10, return_tuple=True) print roll(1, 20, conditional=('<', 10) # let'say we roll 3
<<< (11, None, None, (2, 5, 4)) True
print roll_dice(2, 20, ('-', 2), conditional=('>=', 10), return_tuple=True) print roll(3, 10, return_tuple=True)
<<< (8, False, 2, (4, 6)) # roll was 4 + 6 - 2 = 8 (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 # 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) result = sum(rolls)
if modifier: if modifier_string:
# make sure to check types well before eval result = simple_eval(f"{result} {modifier_string}")
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))
outcome, diff = None, None outcome, diff = None, None
if conditional: if conditional_string and conditional_value:
# make sure to check types well before eval outcome = simple_eval(f"{result} {conditional_string}")
cond, condvalue = conditional diff = abs(result - conditional_value)
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 return_tuple: if return_tuple:
return result, outcome, diff, rolls return result, outcome, diff, rolls
elif conditional or (conditional_string and conditional_value):
return outcome # True|False
else: else:
if conditional: return result # integer
return outcome
else:
return result
# legacy alias # legacy alias
@ -235,7 +301,8 @@ class CmdDice(default_cmds.MuxCommand):
except ValueError: except ValueError:
self.caller.msg( self.caller.msg(
"You need to enter valid integer numbers, modifiers and operators." "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 return
# format output # format output

View file

@ -3,9 +3,8 @@ Testing of TestDice.
""" """
from mock import patch
from evennia.commands.default.tests import BaseEvenniaCommandTest from evennia.commands.default.tests import BaseEvenniaCommandTest
from mock import patch
from . import dice from . import dice
@ -13,9 +12,9 @@ from . import dice
@patch("evennia.contrib.rpg.dice.dice.randint", return_value=5) @patch("evennia.contrib.rpg.dice.dice.randint", return_value=5)
class TestDice(BaseEvenniaCommandTest): class TestDice(BaseEvenniaCommandTest):
def test_roll_dice(self, mocked_randint): def test_roll_dice(self, mocked_randint):
self.assertEqual(dice.roll_dice(6, 6, modifier=("+", 4)), mocked_randint() * 6 + 4) self.assertEqual(dice.roll(6, 6, modifier=("+", 4)), mocked_randint() * 6 + 4)
self.assertEqual(dice.roll_dice(6, 6, conditional=("<", 35)), True) self.assertEqual(dice.roll(6, 6, conditional=("<", 35)), True)
self.assertEqual(dice.roll_dice(6, 6, conditional=(">", 33)), False) self.assertEqual(dice.roll(6, 6, conditional=(">", 33)), False)
def test_cmddice(self, mocked_randint): def test_cmddice(self, mocked_randint):
self.call( self.call(
@ -23,3 +22,15 @@ class TestDice(BaseEvenniaCommandTest):
) )
self.call(dice.CmdDice(), "100000d1000", "The maximum roll allowed is 10000d10000.") 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).") 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)