Browse Source

Include the 'drop-dice' and 'd%' functionalities

olinox 8 years ago
parent
commit
3e91e1ff0c
3 changed files with 147 additions and 42 deletions
  1. 12 6
      roll.py
  2. 21 2
      test.py
  3. 114 34
      xdice.py

+ 12 - 6
roll.py

@@ -4,6 +4,7 @@ Usage:
 
 Options:
     -s               Numeric score only
+    -v               Verbose result
 
     -h --help        Displays help message
     --version        Displays current xdice version
@@ -12,7 +13,7 @@ import sys
 
 import xdice
 
-def print_ex(string, exit_code=0):
+def _print_and_exit(string, exit_code=0):
     """ print and exit """
     print(string)
     sys.exit(exit_code)
@@ -21,18 +22,23 @@ def print_ex(string, exit_code=0):
 args = sys.argv[1:]
 
 if "-h" in args:
-    print_ex(__doc__)
+    _print_and_exit(__doc__)
 
-if "-v" in args:
-    print_ex("xdice {}".format(xdice.__VERSION__))
+if "--version" in args:
+    _print_and_exit("xdice {}".format(xdice.__VERSION__))
 
 score_only = False
 if "-s" in args:
     score_only = True
     args.remove("-s")
 
+verbose = False
+if "-v" in args:
+    verbose = True
+    args.remove("-v")
+
 if len(args) != 1:
-    print_ex("xdice CLI: invalid arguments\n" + __doc__, 1)
+    _print_and_exit("xdice CLI: invalid arguments\n" + __doc__, 1)
 
 pattern_string = args[0]
 
@@ -42,4 +48,4 @@ ps = xdice.roll(pattern_string)
 if score_only:
     print(ps)
 else:
-    print("{}\t({})".format(ps, ps.format()))
+    print("{}\t({})".format(ps, ps.format(verbose)))

+ 21 - 2
test.py

@@ -43,12 +43,26 @@ class Test(unittest.TestCase):
         xdice.roll("d")
         xdice.roll("2d")
         xdice.roll("d6")
+        xdice.roll("3d6l")
+        xdice.roll("3d6l2")
+        xdice.roll("3d6h")
+        xdice.roll("3d6h2")
+        xdice.roll("6d6lh")
+        xdice.roll("6d6lh2")
+        xdice.roll("6d6l2h")
+        xdice.roll("6d6l2h2")
+        xdice.roll("3dlh")
+        xdice.roll("1d%")
+        xdice.roll("d%")
 
         # test invalid expressions
         self.assertRaises(ValueError, xdice.roll, "")
         self.assertRaises(ValueError, xdice.roll, "1d0")
         self.assertRaises(TypeError, xdice.roll, "abc")
         self.assertRaises(TypeError, xdice.roll, "1d2,3")
+        self.assertRaises(ValueError, xdice.roll, "1d6l2")
+        self.assertRaises(ValueError, xdice.roll, "1d6h2")
+        self.assertRaises(ValueError, xdice.roll, "1d6lh")
 
     def test_dice_object(self):
 
@@ -67,6 +81,8 @@ class Test(unittest.TestCase):
         self.assertEqual(xdice.Dice(1, 6).roll(), 6)
 
         self.assertEqual(xdice.Dice.parse("6d1").roll(), 6)
+        self.assertRaises(ValueError, xdice.Dice.parse, "a1d6")
+        self.assertEqual(xdice.Dice.parse("6d1h1").roll().name, "6d1h1")
 
     def test_score_object(self):
 
@@ -77,14 +93,16 @@ class Test(unittest.TestCase):
         self.assertEqual(list(s), [1, 2, 3])
         self.assertEqual(s.detail, [1, 2, 3])
         self.assertTrue(1 in s)
-        self.assertEqual(s.__repr__(), "<Score; score=6; detail=[1, 2, 3]>")
+        self.assertEqual(s.__repr__(), "<Score; score=6; detail=[1, 2, 3]; dropped=[]; name=>")
 
+        s = xdice.Score([1, 2, 3], dropped=[1], name='foo')
+        self.assertEqual(s.__repr__(), "<Score; score=6; detail=[1, 2, 3]; dropped=[1]; name=foo>")
 
     def test_pattern_object(self):
 
         p = xdice.Pattern("6d1+6")
 
-        self.assertEqual(p._normalize("1 D 6"), "1d6")
+        self.assertEqual(xdice._normalize("1 D 6"), "1d6")
 
         p.compile()
         self.assertEqual(p.format_string, "{0}+6")
@@ -99,6 +117,7 @@ class Test(unittest.TestCase):
         self.assertEqual(ps.score(0), 6)
         self.assertEqual(ps.scores(), [6])
         self.assertEqual(ps.format(), "[1, 1, 1, 1, 1, 1]+6")
+        self.assertEqual(ps.format(verbose=True), " (scores:[1, 1, 1, 1, 1, 1]) +6")
 
     def test_compile(self):
         p1 = xdice.compile("6d1+6")

+ 114 - 34
xdice.py

@@ -10,9 +10,7 @@ import re
 
 __VERSION__ = 1.0
 
-# TODO: 'L', 'LX', 'H' and 'HX' notations: drop the x lowest or highest results => eg: 'AdXl3'
 # TODO: (?) 'Rx(...)' notation: roll x times the pattern in the parenthesis => eg: R3(1d4+3)
-# TODO: 'd%' notation: d% <=> d100
 # TODO: (?) Dice pools, 6-sided variations, 10-sided variations,
 # Open-ended variations (https://en.wikipedia.org/wiki/Dice_notation)
 
@@ -45,6 +43,22 @@ def _secured_eval(raw):
     by avoiding the use of any non-allowed function """
     return eval(raw, {"__builtins__":None}, _ALLOWED)
 
+def _assert_int_ge_to(value, threshold=0, msg=""):
+    """ assert value is an integer greater or equal to threshold """
+    try:
+        if int(value) < threshold:
+            raise ValueError()
+    except (TypeError, ValueError):
+        raise ValueError(msg)
+
+def _split_list(lst, left, right):
+    """ divides a list in 3 sections: [:left], [left:right], [right:]
+    return a tuple of lists"""
+    return lst[:left], lst[left:right], lst[right:]
+
+def _normalize(pattern):
+    return str(pattern).replace(" ", "").lower().replace("d%", "d100")
+
 class Dice():
     """
     Dice(sides, amount=1):
@@ -53,14 +67,20 @@ class Dice():
     Use roll() to get a Score() object.
     """
     DEFAULT_SIDES = 20
+    DICE_RE_STR = r"(?P<amount>\d*)d(?P<sides>\d*)(?:l(?P<lowest>\d*))?(?:h(?P<highest>\d*))?"
+    DICE_RE = re.compile(DICE_RE_STR)
 
-    def __init__(self, sides, amount=1):
-        """ Instanciate a Die object """
+    def __init__(self, sides, amount=1, drop_lowest=0, drop_highest=0):
+        """ Instantiate a Die object """
         self._sides = 1
         self._amount = 0
+        self._drop_lowest = 0
+        self._drop_highest = 0
 
         self.sides = sides
         self.amount = amount
+        self.drop_lowest = drop_lowest
+        self.drop_highest = drop_highest
 
     @property
     def sides(self):
@@ -70,11 +90,7 @@ class Dice():
     @sides.setter
     def sides(self, sides):
         """ Set the number of faces of the dice """
-        try:
-            if int(sides) < 1:
-                raise ValueError()
-        except (TypeError, ValueError):
-            raise ValueError("Invalid value for sides (given: '{}')".format(sides))
+        _assert_int_ge_to(sides, 1, "Invalid value for sides ('{}')".format(sides))
         self._sides = sides
 
     @property
@@ -85,13 +101,43 @@ class Dice():
     @amount.setter
     def amount(self, amount):
         """ Set the amount of dice """
-        try:
-            if int(amount) < 0:
-                raise ValueError()
-        except (TypeError, ValueError):
-            raise ValueError("Invalid value for amount (given: '{}')".format(amount))
+        _assert_int_ge_to(amount, 0, "Invalid value for amount ('{}')".format(amount))
         self._amount = amount
 
+    @property
+    def drop_lowest(self):
+        """ The N lowest dices to ignore """
+        return self._drop_lowest
+
+    @drop_lowest.setter
+    def drop_lowest(self, drop_lowest):
+        """ Set the number of lowest dices to ignore """
+        _assert_int_ge_to(drop_lowest, 0, "Invalid value for drop_lowest ('{}')".format(drop_lowest))
+        if self.drop_highest + drop_lowest > self.amount:
+            raise ValueError("You can not drop more dice than amount")
+        self._drop_lowest = drop_lowest
+
+    @property
+    def drop_highest(self):
+        """ The N highest dices to ignore """
+        return self._drop_highest
+
+    @drop_highest.setter
+    def drop_highest(self, drop_highest):
+        """ Set the number of highest dices to ignore """
+        _assert_int_ge_to(drop_highest, 0, "Invalid value for drop_highest ('{}')".format(drop_highest))
+        if self.drop_lowest + drop_highest > self.amount:
+            raise ValueError("You can not drop more dice than amount")
+        self._drop_highest = drop_highest
+
+    @property
+    def name(self):
+        """ build the name of the Dice """
+        return "{}d{}{}{}".format(self._amount,
+                                  self._sides,
+                                  "l{}".format(self._drop_lowest) if self._drop_lowest else "",
+                                  "h{}".format(self._drop_highest) if self._drop_highest else "")
+
     def __repr__(self):
         """ Return a string representation of the Dice """
         return "<Dice; sides={}; amount={}>".format(self.sides, self.amount)
@@ -105,20 +151,30 @@ class Dice():
 
     def roll(self):
         """ Role the dice and return a Score object """
-        return Score([random.randint(1, self._sides) for _ in range(self._amount)])
+        # Sort results
+        results = sorted([random.randint(1, self._sides) for _ in range(self._amount)])
+        # Drop the lowest / highest results
+        lowest, results, highest = _split_list(results, self._drop_lowest, len(results) - self._drop_highest)
+
+        return Score(results, lowest + highest, self.name)
 
     @classmethod
     def parse(cls, pattern):
         """ parse a pattern of the form 'xdx', where x are positive integers """
-        pattern = str(pattern).replace(" ", "").lower()
+        pattern = _normalize(pattern)
+
+        match = cls.DICE_RE.match(pattern)
+        if match is None:
+            raise ValueError("Invalid Dice pattern ('{}')".format(pattern))
 
-        amount, sides = pattern.split("d")
-        if not amount:
-            amount = 1
-        if not sides:
-            sides = cls.DEFAULT_SIDES
+        amount, sides, lowest, highest = match.groups()
 
-        return Dice(*map(int, [sides, amount]))
+        amount = amount or 1
+        sides = sides or cls.DEFAULT_SIDES
+        lowest = (lowest or 1) if lowest is not None else 0
+        highest = (highest or 1) if highest is not None else 0
+
+        return Dice(*map(int, [sides, amount, lowest, highest]))
 
 class Score(int):
     """ Score is a subclass of integer.
@@ -139,13 +195,15 @@ class Score(int):
         [1,2,3]
 
     """
-    def __new__(cls, detail):
+    def __new__(cls, detail, dropped=[], name=""):
         """
         detail should only contain integers
         Score value will be the sum of the list's values.
         """
         score = super(Score, cls).__new__(cls, sum(detail))
         score._detail = detail
+        score._dropped = dropped
+        score._name = name
         return score
 
     @property
@@ -157,7 +215,22 @@ class Score(int):
 
     def __repr__(self):
         """ Return a string representation of the Score """
-        return "<Score; score={}; detail={}>".format(int(self), self.detail)
+        return "<Score; score={}; detail={}; dropped={}; name={}>".format(int(self),
+                                                                          self.detail,
+                                                                          self.dropped,
+                                                                          self.name)
+
+    def format(self, verbose=False):
+        """
+        Return a formatted string detailing the score of the Dice roll.
+        > Eg: '3d6' => '[1,5,6]'
+        """
+        basestr = str(list(self.detail))
+        if not verbose:
+            return basestr
+        else:
+            droppedstr = ", dropped:{}".format(self.dropped) if verbose and self.dropped else ""
+            return " {}(scores:{}{}) ".format(self._name, basestr, droppedstr)
 
     def __contains__(self, value):
         """ Does score contains the given result """
@@ -167,21 +240,27 @@ class Score(int):
         """ Iterate over results """
         return self.detail.__iter__()
 
+    @property
+    def dropped(self):
+        """ list of dropped results """
+        return self._dropped
+
+    @property
+    def name(self):
+        """ descriptive name of the score """
+        return self._name
+
+
 class Pattern():
     """ A dice-notation pattern """
     def __init__(self, instr):
         """ Instantiate a Pattern object. """
         if not instr:
             raise ValueError("Invalid value for 'instr' ('{}')".format(instr))
-        self.instr = Pattern._normalize(instr)
+        self.instr = _normalize(instr)
         self.dices = []
         self.format_string = ""
 
-    @staticmethod
-    def _normalize(instr):
-        """ normalize the incoming string to a lower string without spaces"""
-        return str(instr).replace(" ", "").lower()
-
     def compile(self):
         """
         Parse the pattern. Two properties are updated at this time:
@@ -200,7 +279,7 @@ class Pattern():
             self.dices.append(dice)
             return "{{{}}}".format(index)
 
-        self.format_string = re.sub(r'\d*d\d*', _submatch, self.instr)
+        self.format_string = Dice.DICE_RE.sub(_submatch, self.instr)
 
     def roll(self):
         """
@@ -215,7 +294,8 @@ class Pattern():
 class PatternScore(int):
     """
     PatternScore is a subclass of integer, you can then manipulate it as you would do with an integer.
-    Moreover, you can get the list of the scores with the score(i) or scores() methods, and retrieve a formatted result with the format() method.
+    Moreover, you can get the list of the scores with the score(i)
+    or scores() methods, and retrieve a formatted result with the format() method.
     """
     def __new__(cls, eval_string, scores):
         ps = super(PatternScore, cls).__new__(cls, _secured_eval(eval_string.format(*scores)))
@@ -225,12 +305,12 @@ class PatternScore(int):
 
         return ps
 
-    def format(self):
+    def format(self, verbose=False):
         """
         Return a formatted string detailing the result of the roll.
         > Eg: '3d6+4' => '[1,5,6]+4'
         """
-        return self._eval_string.format(*[str(list(score)) for score in self._scores])
+        return self._eval_string.format(*[score.format(verbose) for score in self._scores])
 
     def score(self, i):
         """ Returns the Score object at index i. """