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
## 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

View file

@ -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

View file

@ -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.
----

View file

@ -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.

View file

@ -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

View file

@ -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)