Просмотр исходного кода

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

olinox 8 лет назад
Родитель
Сommit
3e91e1ff0c
3 измененных файлов с 147 добавлено и 42 удалено
  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:
 Options:
     -s               Numeric score only
     -s               Numeric score only
+    -v               Verbose result
 
 
     -h --help        Displays help message
     -h --help        Displays help message
     --version        Displays current xdice version
     --version        Displays current xdice version
@@ -12,7 +13,7 @@ import sys
 
 
 import xdice
 import xdice
 
 
-def print_ex(string, exit_code=0):
+def _print_and_exit(string, exit_code=0):
     """ print and exit """
     """ print and exit """
     print(string)
     print(string)
     sys.exit(exit_code)
     sys.exit(exit_code)
@@ -21,18 +22,23 @@ def print_ex(string, exit_code=0):
 args = sys.argv[1:]
 args = sys.argv[1:]
 
 
 if "-h" in args:
 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
 score_only = False
 if "-s" in args:
 if "-s" in args:
     score_only = True
     score_only = True
     args.remove("-s")
     args.remove("-s")
 
 
+verbose = False
+if "-v" in args:
+    verbose = True
+    args.remove("-v")
+
 if len(args) != 1:
 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]
 pattern_string = args[0]
 
 
@@ -42,4 +48,4 @@ ps = xdice.roll(pattern_string)
 if score_only:
 if score_only:
     print(ps)
     print(ps)
 else:
 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("d")
         xdice.roll("2d")
         xdice.roll("2d")
         xdice.roll("d6")
         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
         # test invalid expressions
         self.assertRaises(ValueError, xdice.roll, "")
         self.assertRaises(ValueError, xdice.roll, "")
         self.assertRaises(ValueError, xdice.roll, "1d0")
         self.assertRaises(ValueError, xdice.roll, "1d0")
         self.assertRaises(TypeError, xdice.roll, "abc")
         self.assertRaises(TypeError, xdice.roll, "abc")
         self.assertRaises(TypeError, xdice.roll, "1d2,3")
         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):
     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(1, 6).roll(), 6)
 
 
         self.assertEqual(xdice.Dice.parse("6d1").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):
     def test_score_object(self):
 
 
@@ -77,14 +93,16 @@ class Test(unittest.TestCase):
         self.assertEqual(list(s), [1, 2, 3])
         self.assertEqual(list(s), [1, 2, 3])
         self.assertEqual(s.detail, [1, 2, 3])
         self.assertEqual(s.detail, [1, 2, 3])
         self.assertTrue(1 in s)
         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):
     def test_pattern_object(self):
 
 
         p = xdice.Pattern("6d1+6")
         p = xdice.Pattern("6d1+6")
 
 
-        self.assertEqual(p._normalize("1 D 6"), "1d6")
+        self.assertEqual(xdice._normalize("1 D 6"), "1d6")
 
 
         p.compile()
         p.compile()
         self.assertEqual(p.format_string, "{0}+6")
         self.assertEqual(p.format_string, "{0}+6")
@@ -99,6 +117,7 @@ class Test(unittest.TestCase):
         self.assertEqual(ps.score(0), 6)
         self.assertEqual(ps.score(0), 6)
         self.assertEqual(ps.scores(), [6])
         self.assertEqual(ps.scores(), [6])
         self.assertEqual(ps.format(), "[1, 1, 1, 1, 1, 1]+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):
     def test_compile(self):
         p1 = xdice.compile("6d1+6")
         p1 = xdice.compile("6d1+6")

+ 114 - 34
xdice.py

@@ -10,9 +10,7 @@ import re
 
 
 __VERSION__ = 1.0
 __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: (?) '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,
 # TODO: (?) Dice pools, 6-sided variations, 10-sided variations,
 # Open-ended variations (https://en.wikipedia.org/wiki/Dice_notation)
 # 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 """
     by avoiding the use of any non-allowed function """
     return eval(raw, {"__builtins__":None}, _ALLOWED)
     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():
 class Dice():
     """
     """
     Dice(sides, amount=1):
     Dice(sides, amount=1):
@@ -53,14 +67,20 @@ class Dice():
     Use roll() to get a Score() object.
     Use roll() to get a Score() object.
     """
     """
     DEFAULT_SIDES = 20
     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._sides = 1
         self._amount = 0
         self._amount = 0
+        self._drop_lowest = 0
+        self._drop_highest = 0
 
 
         self.sides = sides
         self.sides = sides
         self.amount = amount
         self.amount = amount
+        self.drop_lowest = drop_lowest
+        self.drop_highest = drop_highest
 
 
     @property
     @property
     def sides(self):
     def sides(self):
@@ -70,11 +90,7 @@ class Dice():
     @sides.setter
     @sides.setter
     def sides(self, sides):
     def sides(self, sides):
         """ Set the number of faces of the dice """
         """ 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
         self._sides = sides
 
 
     @property
     @property
@@ -85,13 +101,43 @@ class Dice():
     @amount.setter
     @amount.setter
     def amount(self, amount):
     def amount(self, amount):
         """ Set the amount of dice """
         """ 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
         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):
     def __repr__(self):
         """ Return a string representation of the Dice """
         """ Return a string representation of the Dice """
         return "<Dice; sides={}; amount={}>".format(self.sides, self.amount)
         return "<Dice; sides={}; amount={}>".format(self.sides, self.amount)
@@ -105,20 +151,30 @@ class Dice():
 
 
     def roll(self):
     def roll(self):
         """ Role the dice and return a Score object """
         """ 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
     @classmethod
     def parse(cls, pattern):
     def parse(cls, pattern):
         """ parse a pattern of the form 'xdx', where x are positive integers """
         """ 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):
 class Score(int):
     """ Score is a subclass of integer.
     """ Score is a subclass of integer.
@@ -139,13 +195,15 @@ class Score(int):
         [1,2,3]
         [1,2,3]
 
 
     """
     """
-    def __new__(cls, detail):
+    def __new__(cls, detail, dropped=[], name=""):
         """
         """
         detail should only contain integers
         detail should only contain integers
         Score value will be the sum of the list's values.
         Score value will be the sum of the list's values.
         """
         """
         score = super(Score, cls).__new__(cls, sum(detail))
         score = super(Score, cls).__new__(cls, sum(detail))
         score._detail = detail
         score._detail = detail
+        score._dropped = dropped
+        score._name = name
         return score
         return score
 
 
     @property
     @property
@@ -157,7 +215,22 @@ class Score(int):
 
 
     def __repr__(self):
     def __repr__(self):
         """ Return a string representation of the Score """
         """ 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):
     def __contains__(self, value):
         """ Does score contains the given result """
         """ Does score contains the given result """
@@ -167,21 +240,27 @@ class Score(int):
         """ Iterate over results """
         """ Iterate over results """
         return self.detail.__iter__()
         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():
 class Pattern():
     """ A dice-notation pattern """
     """ A dice-notation pattern """
     def __init__(self, instr):
     def __init__(self, instr):
         """ Instantiate a Pattern object. """
         """ Instantiate a Pattern object. """
         if not instr:
         if not instr:
             raise ValueError("Invalid value for 'instr' ('{}')".format(instr))
             raise ValueError("Invalid value for 'instr' ('{}')".format(instr))
-        self.instr = Pattern._normalize(instr)
+        self.instr = _normalize(instr)
         self.dices = []
         self.dices = []
         self.format_string = ""
         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):
     def compile(self):
         """
         """
         Parse the pattern. Two properties are updated at this time:
         Parse the pattern. Two properties are updated at this time:
@@ -200,7 +279,7 @@ class Pattern():
             self.dices.append(dice)
             self.dices.append(dice)
             return "{{{}}}".format(index)
             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):
     def roll(self):
         """
         """
@@ -215,7 +294,8 @@ class Pattern():
 class PatternScore(int):
 class PatternScore(int):
     """
     """
     PatternScore is a subclass of integer, you can then manipulate it as you would do with an integer.
     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):
     def __new__(cls, eval_string, scores):
         ps = super(PatternScore, cls).__new__(cls, _secured_eval(eval_string.format(*scores)))
         ps = super(PatternScore, cls).__new__(cls, _secured_eval(eval_string.format(*scores)))
@@ -225,12 +305,12 @@ class PatternScore(int):
 
 
         return ps
         return ps
 
 
-    def format(self):
+    def format(self, verbose=False):
         """
         """
         Return a formatted string detailing the result of the roll.
         Return a formatted string detailing the result of the roll.
         > Eg: '3d6+4' => '[1,5,6]+4'
         > 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):
     def score(self, i):
         """ Returns the Score object at index i. """
         """ Returns the Score object at index i. """