1111import operator
1212import random
1313import re
14+ from typing import TYPE_CHECKING
1415
1516from sopel import plugin
1617from sopel .tools .calculation import eval_equation
1718
19+ if TYPE_CHECKING :
20+ from sopel .bot import SopelWrapper
21+ from sopel .trigger import Trigger
22+
23+
24+ MAX_DICE = 1000
25+
1826
1927class DicePouch :
20- def __init__ (self , num_of_die , type_of_die , addition ) :
28+ def __init__ (self , dice_count : int , dice_type : int ) -> None :
2129 """Initialize dice pouch and roll the dice.
2230
23- Args:
24- num_of_die: number of dice in the pouch.
25- type_of_die: how many faces the dice have.
26- addition: how much is added to the result of the dice.
31+ :param dice_count: the number of dice in the pouch
32+ :param dice_type: how many faces each die has
2733 """
28- self .num = num_of_die
29- self .type = type_of_die
30- self .addition = addition
34+ self .num : int = dice_count
35+ self .type : int = dice_type
3136
32- self .dice = {}
33- self .dropped = {}
37+ self .dice : dict [ int , int ] = {}
38+ self .dropped : dict [ int , int ] = {}
3439
3540 self .roll_dice ()
3641
37- def roll_dice (self ):
42+ def roll_dice (self ) -> None :
3843 """Roll all the dice in the pouch."""
3944 self .dice = {}
4045 self .dropped = {}
@@ -43,11 +48,10 @@ def roll_dice(self):
4348 count = self .dice .setdefault (number , 0 )
4449 self .dice [number ] = count + 1
4550
46- def drop_lowest (self , n ) :
47- """Drop n lowest dice from the result.
51+ def drop_lowest (self , n : int ) -> None :
52+ """Drop ``n`` lowest dice from the result.
4853
49- Args:
50- n: the number of dice to drop.
54+ :param n: the number of dice to drop
5155 """
5256
5357 sorted_x = sorted (self .dice .items (), key = operator .itemgetter (0 ))
@@ -69,8 +73,8 @@ def drop_lowest(self, n):
6973 if self .dice [i ] == 0 :
7074 del self .dice [i ]
7175
72- def get_simple_string (self ):
73- """Return the values of the dice like (2+2+2[+1+1])+1 ."""
76+ def get_simple_string (self ) -> str :
77+ """Return the values of the dice like (2+2+2[+1+1])."""
7478 dice = self .dice .items ()
7579 faces = ("+" .join ([str (face )] * times ) for face , times in dice )
7680 dice_str = "+" .join (faces )
@@ -81,14 +85,10 @@ def get_simple_string(self):
8185 dfaces = ("+" .join ([str (face )] * times ) for face , times in dropped )
8286 dropped_str = "[+%s]" % ("+" .join (dfaces ),)
8387
84- plus_str = ""
85- if self .addition :
86- plus_str = "{:+d}" .format (self .addition )
87-
88- return "(%s%s)%s" % (dice_str , dropped_str , plus_str )
88+ return "(%s%s)" % (dice_str , dropped_str )
8989
90- def get_compressed_string (self ):
91- """Return the values of the dice like (3x2[+2x1])+1 ."""
90+ def get_compressed_string (self ) -> str :
91+ """Return the values of the dice like (3x2[+2x1])."""
9292 dice = self .dice .items ()
9393 faces = ("%dx%d" % (times , face ) for face , times in dice )
9494 dice_str = "+" .join (faces )
@@ -99,89 +99,152 @@ def get_compressed_string(self):
9999 dfaces = ("%dx%d" % (times , face ) for face , times in dropped )
100100 dropped_str = "[+%s]" % ("+" .join (dfaces ),)
101101
102- plus_str = ""
103- if self .addition :
104- plus_str = "{:+d}" .format (self .addition )
102+ return "(%s%s)" % (dice_str , dropped_str )
105103
106- return "(%s%s)%s" % (dice_str , dropped_str , plus_str )
107-
108- def get_sum (self ):
109- """Get the sum of non-dropped dice and the addition."""
110- result = self .addition
104+ def get_sum (self ) -> int :
105+ """Get the sum of non-dropped dice."""
106+ result = 0
111107 for face , times in self .dice .items ():
112108 result += face * times
113109 return result
114110
115- def get_number_of_faces (self ):
116- """Returns sum of different faces for dropped and not dropped dice
111+ def get_number_of_faces (self ) -> int :
112+ """Returns sum of different faces for dropped and not dropped dice.
117113
118- This can be used to estimate, whether the result can be shown in
119- compressed form in a reasonable amount of space.
114+ This can be used to estimate whether the result can be shown ( in
115+ compressed form) in a reasonable amount of space.
120116 """
121117 return len (self .dice ) + len (self .dropped )
122118
123119
124- def _roll_dice (bot , dice_expression ):
125- result = re .search (
126- r"""
127- (?P<dice_num>-?\d*)
128- d
129- (?P<dice_type>-?\d+)
130- (v(?P<drop_lowest>-?\d+))?
131- $""" ,
132- dice_expression ,
133- re .IGNORECASE | re .VERBOSE )
120+ class DiceError (Exception ):
121+ """Custom base exception type."""
122+
123+
124+ class InvalidDiceFacesError (DiceError ):
125+ """Custom exception type for invalid number of die faces."""
126+ def __init__ (self , faces : int ):
127+ super ().__init__ (faces )
128+
129+ @property
130+ def faces (self ) -> int :
131+ return self .args [0 ]
132+
133+
134+ class NegativeDiceCountError (DiceError ):
135+ """Custom exception type for invalid numbers of dice."""
136+ def __init__ (self , count : int ):
137+ super ().__init__ (count )
138+
139+ @property
140+ def count (self ) -> int :
141+ return self .args [0 ]
142+
143+
144+ class TooManyDiceError (DiceError ):
145+ """Custom exception type for excessive numbers of dice."""
146+ def __init__ (self , requested : int , available : int ):
147+ super ().__init__ (requested , available )
148+
149+ @property
150+ def available (self ) -> int :
151+ return self .args [1 ]
152+
153+ @property
154+ def requested (self ) -> int :
155+ return self .args [0 ]
134156
135- if result is None :
136- raise ValueError ("Invalid dice expression: %r" % dice_expression )
137157
138- dice_num = int (result .group ('dice_num' ) or 1 )
139- dice_type = int (result .group ('dice_type' ))
158+ class UnableToDropDiceError (DiceError ):
159+ """Custom exception type for failing to drop lowest N dice."""
160+ def __init__ (self , dropped : int , total : int ):
161+ super ().__init__ (dropped , total )
162+
163+ @property
164+ def dropped (self ) -> int :
165+ return self .args [0 ]
166+
167+ @property
168+ def total (self ) -> int :
169+ return self .args [1 ]
170+
171+
172+ def _get_error_message (exc : DiceError ) -> str :
173+ if isinstance (exc , InvalidDiceFacesError ):
174+ return "I don't have any dice with {} sides." .format (exc .faces )
175+ if isinstance (exc , NegativeDiceCountError ):
176+ return "I can't roll {} dice." .format (exc .count )
177+ if isinstance (exc , TooManyDiceError ):
178+ return "I only have {}/{} dice." .format (exc .available , exc .requested )
179+ if isinstance (exc , UnableToDropDiceError ):
180+ return "I can't drop the lowest {} of {} dice." .format (
181+ exc .dropped , exc .total )
182+
183+ return "Unknown error rolling dice: %r" % exc
184+
185+
186+ def _roll_dice (dice_match : re .Match [str ]) -> DicePouch :
187+ dice_num = int (dice_match .group ('dice_num' ) or 1 )
188+ dice_type = int (dice_match .group ('dice_type' ))
140189
141190 # Dice can't have zero or a negative number of sides.
142191 if dice_type <= 0 :
143- bot .reply ("I don't have any dice with %d sides. =(" % dice_type )
144- return None # Signal there was a problem
192+ raise InvalidDiceFacesError (dice_type )
145193
146194 # Can't roll a negative number of dice.
147195 if dice_num < 0 :
148- bot .reply ("I'd rather not roll a negative amount of dice. =(" )
149- return None # Signal there was a problem
196+ raise NegativeDiceCountError (dice_num )
150197
151198 # Upper limit for dice should be at most a million. Creating a dict with
152199 # more than a million elements already takes a noticeable amount of time
153200 # on a fast computer and ~55kB of memory.
154- if dice_num > 1000 :
155- bot .reply ('I only have 1000 dice. =(' )
156- return None # Signal there was a problem
201+ if dice_num > MAX_DICE :
202+ raise TooManyDiceError (dice_num , MAX_DICE )
157203
158- dice = DicePouch (dice_num , dice_type , 0 )
204+ dice = DicePouch (dice_num , dice_type )
159205
160- if result .group ('drop_lowest' ):
161- drop = int (result .group ('drop_lowest' ))
206+ if dice_match .group ('drop_lowest' ):
207+ drop = int (dice_match .group ('drop_lowest' ))
162208 if drop >= 0 :
163209 dice .drop_lowest (drop )
164210 else :
165- bot . reply ( "I can't drop the lowest %d dice. =(" % drop )
211+ raise UnableToDropDiceError ( drop , dice_num )
166212
167213 return dice
168214
169215
170216@plugin .command ('roll' , 'dice' , 'd' )
171217@plugin .priority ("medium" )
218+ @plugin .example (".roll" , "No dice to roll." )
219+ @plugin .example (".roll 2d6+4^2&" ,
220+ "I don't know how to process that. "
221+ "Are the dice as well as the algorithms correct?" )
222+ @plugin .example (".roll 65(2)" , "I couldn't find any valid dice expressions." )
223+ @plugin .example (".roll 2d-2" , "I don't have any dice with -2 sides." )
224+ @plugin .example (".roll 1d0" , "I don't have any dice with 0 sides." )
225+ @plugin .example (".roll -1d6" , "I can't roll -1 dice." )
226+ @plugin .example (".roll 3d6v-1" , "I can't drop the lowest -1 of 3 dice." )
227+ @plugin .example (".roll 2d6v0" , r'2d6v0: \(\d\+\d\) = \d+' , re = True )
228+ @plugin .example (".roll 2d6v4" , r'2d6v4: \(\[\+\d\+\d\]\) = 0' , re = True )
229+ @plugin .example (".roll 2d6v1+8" , r'2d6v1\+8: \(\d\[\+\d\]\)\+8 = \d+' , re = True )
230+ @plugin .example (".roll 11d1v1" , "11d1v1: (10x1[+1x1]) = 10" )
172231@plugin .example (".roll 3d1+1" , '3d1+1: (1+1+1)+1 = 4' )
173232@plugin .example (".roll 3d1v2+1" , '3d1v2+1: (1[+1+1])+1 = 2' )
174233@plugin .example (".roll 2d4" , r'2d4: \(\d\+\d\) = \d' , re = True )
175234@plugin .example (".roll 100d1" , r'[^:]*: \(100x1\) = 100' , re = True )
176- @plugin .example (".roll 1001d1" , 'I only have 1000 dice. =(' )
235+ @plugin .example (".roll 100d100" , r'100d100: \(\.{3}\) = \d+' , re = True )
236+ @plugin .example (".roll 1000d999^1000d999" , 'You roll 1000d999^1000d999: (...)^(...) = very big' )
237+ @plugin .example (".roll 1000d999^1000d99" , "I can't display a number that big. =(" )
238+ @plugin .example (
239+ ".roll {}d1" .format (MAX_DICE + 1 ), 'I only have {}/{} dice.' .format (MAX_DICE , MAX_DICE + 1 ))
177240@plugin .example (".roll 1d1 + 1d1" , '1d1 + 1d1: (1) + (1) = 2' )
178241@plugin .example (".roll 1d1+1d1" , '1d1+1d1: (1)+(1) = 2' )
179- @plugin .example (".roll 1d6 # initiative" , r'1d6 : \(\d\) = \d' , re = True )
242+ @plugin .example (".roll d6 # initiative" , r'd6 : \(\d\) = \d' , re = True )
180243@plugin .example (".roll 2d20v1+2 # roll with advantage" , user_help = True )
181244@plugin .example (".roll 2d10+3" , user_help = True )
182245@plugin .example (".roll 1d6" , user_help = True )
183246@plugin .output_prefix ('[dice] ' )
184- def roll (bot , trigger ):
247+ def roll (bot : SopelWrapper , trigger : Trigger ):
185248 """Rolls dice and reports the result.
186249
187250 The dice roll follows this format: XdY[vZ][+N][#COMMENT]
@@ -190,49 +253,56 @@ def roll(bot, trigger):
190253 number of lowest dice to be dropped from the result. N is the constant to
191254 be applied to the end result. Comment is for easily noting the purpose.
192255 """
193- # This regexp is only allowed to have one capture group, because having
194- # more would alter the output of re.findall.
195- dice_regexp = r"-?\d*[dD]-?\d+(?:[vV]-?\d+)?"
256+ dice_regexp = r"""
257+ (?P<dice_num>-?\d*)
258+ d
259+ (?P<dice_type>-?\d+)
260+ (v(?P<drop_lowest>-?\d+))?
261+ """
196262
197263 # Get a list of all dice expressions, evaluate them and then replace the
198264 # expressions in the original string with the results. Replacing is done
199265 # using string formatting, so %-characters must be escaped.
200266 if not trigger .group (2 ):
201267 bot .reply ("No dice to roll." )
202268 return
269+
203270 arg_str_raw = trigger .group (2 ).split ("#" , 1 )[0 ].strip ()
204- dice_expressions = re .findall (dice_regexp , arg_str_raw )
205271 arg_str = arg_str_raw .replace ("%" , "%%" )
206- arg_str = re .sub (dice_regexp , "%s" , arg_str )
272+ arg_str = re .sub (dice_regexp , "%s" , arg_str , 0 , re . IGNORECASE | re . VERBOSE )
207273
208- dice = [_roll_dice (bot , dice_expr ) for dice_expr in dice_expressions ]
274+ dice_expressions = [
275+ match for match in
276+ re .finditer (dice_regexp , arg_str_raw , re .IGNORECASE | re .VERBOSE )
277+ ]
209278
210- if None in dice :
279+ if not dice_expressions :
280+ bot .reply ("I couldn't find any valid dice expressions." )
281+ return
282+
283+ try :
284+ dice = [_roll_dice (dice_expr ) for dice_expr in dice_expressions ]
285+ except DiceError as err :
211286 # Stop computing roll if there was a problem rolling dice.
287+ bot .reply (_get_error_message (err ))
212288 return
213289
214- def _get_eval_str (dice ) :
290+ def _get_eval_str (dice : DicePouch ) -> str :
215291 return "(%d)" % (dice .get_sum (),)
216292
217- def _get_pretty_str (dice ) :
293+ def _get_pretty_str (dice : DicePouch ) -> str :
218294 if dice .num <= 10 :
219295 return dice .get_simple_string ()
220296 elif dice .get_number_of_faces () <= 10 :
221297 return dice .get_compressed_string ()
222298 else :
223299 return "(...)"
224300
225- eval_str = arg_str % (tuple (map (_get_eval_str , dice )))
226- pretty_str = arg_str % (tuple (map (_get_pretty_str , dice )))
301+ eval_str : str = arg_str % (tuple (map (_get_eval_str , dice )))
302+ pretty_str : str = arg_str % (tuple (map (_get_pretty_str , dice )))
227303
228304 try :
229305 result = eval_equation (eval_str )
230- except TypeError :
231- bot .reply (
232- "The type of this equation is, apparently, not a string. "
233- "How did you do that, anyway?"
234- )
235- return
236306 except ValueError :
237307 # As it seems that ValueError is raised if the resulting equation would
238308 # be too big, give a semi-serious answer to reflect on this.
@@ -246,4 +316,10 @@ def _get_pretty_str(dice):
246316 )
247317 return
248318
249- bot .say ("%s: %s = %d" % (arg_str_raw , pretty_str , result ))
319+ try :
320+ bot .say ("%s: %s = %d" % (arg_str_raw , pretty_str , result ))
321+ except ValueError :
322+ # Converting the result to a string can also raise ValueError if it has
323+ # more than int_max_str_digits digits (4300 by default on CPython)
324+ # See https://docs.python.org/3.12/library/stdtypes.html#int-max-str-digits
325+ bot .reply ("I can't display a number that big. =(" )
0 commit comments