Skip to content

Commit d9b6a74

Browse files
authored
Merge pull request #2532 from sopel-irc/more-dice-tests
dice: bugfixes, type hints, and additional tests
2 parents 0371d0b + 6cbb89f commit d9b6a74

File tree

1 file changed

+158
-82
lines changed

1 file changed

+158
-82
lines changed

sopel/builtins/dice.py

Lines changed: 158 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -11,30 +11,35 @@
1111
import operator
1212
import random
1313
import re
14+
from typing import TYPE_CHECKING
1415

1516
from sopel import plugin
1617
from 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

1927
class 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

Comments
 (0)