diff --git a/Dicebot/__init__.py b/Dicebot/__init__.py new file mode 100644 index 0000000..2791915 --- /dev/null +++ b/Dicebot/__init__.py @@ -0,0 +1,69 @@ +### +# Copyright (c) 2007-2010, Andrey Rahmatullin +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +""" +Dice bot +""" + +import supybot +import supybot.world as world + +# Use this for the version of this plugin. You may wish to put a CVS keyword +# in here if you're keeping the plugin in CVS or some similar system. +__version__ = "" + +__author__ = supybot.Author('Andrey Rahmatullin', 'wRAR', 'wrar@wrar.name') +__maintainer__ = getattr(supybot.authors, 'oddluck', + supybot.Author('oddluck', 'oddluck', 'oddluck@riseup.net')) + +# This is a dictionary mapping supybot.Author instances to lists of +# contributions. +__contributors__ = {} + +# This is a url where the most recent plugin package can be downloaded. +__url__ = 'https://github.com/oddluck/limnoria-plugins/' + +from . import config +from . import plugin +from . import deck +from imp import reload +# In case we're being reloaded. +reload(deck) +reload(plugin) +# Add more reloads here if you add third-party modules and want them to be +# reloaded when this plugin is reloaded. Don't forget to import them as well! + +if world.testing: + from . import test + +Class = plugin.Class +configure = config.configure + + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: diff --git a/Dicebot/config.py b/Dicebot/config.py new file mode 100644 index 0000000..a296790 --- /dev/null +++ b/Dicebot/config.py @@ -0,0 +1,49 @@ +### +# Copyright (c) 2007-2008, Andrey Rahmatullin +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +import supybot.conf as conf +import supybot.registry as registry + +def configure(advanced): + # This will be called by supybot to configure this module. advanced is + # a bool that specifies whether the user identified himself as an advanced + # user or not. You should effect your configuration by manipulating the + # registry as appropriate. + conf.registerPlugin('Dicebot', True) + + +Dicebot = conf.registerPlugin('Dicebot') +conf.registerChannelValue(Dicebot, 'autoRoll', + registry.Boolean(False, """Determines whether the bot will automatically + roll the dice it sees in the channel.""")) +conf.registerGlobalValue(Dicebot, 'autoRollInPrivate', + registry.Boolean(False, """Determines whether the bot will automatically + roll the dice it sees in private messages.""")) + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78 diff --git a/Dicebot/deck.py b/Dicebot/deck.py new file mode 100644 index 0000000..7e28cf3 --- /dev/null +++ b/Dicebot/deck.py @@ -0,0 +1,73 @@ +### +# Copyright (c) 2008, Anatoly Popov +# Copyright (c) 2008, Andrey Rahmatullin +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +import random + +class Deck: + """ + 54-card deck simulator. + + This class represents a standard 54-card deck (with 2 different Jokers) + and supports shuffling and drawing. + """ + titles = ['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A'] + suits = ['♣', '♦', '♥', '♠'] + + def __init__(self): + """ + Initialize a new deck and shuffle it. + """ + self.deck = [] + self.base_deck = ['Black Joker', 'Red Joker'] + [t + s + for t in self.titles + for s in self.suits] + self.shuffle() + + def shuffle(self): + """ + Restore and shuffle the deck. + + All cards are returned to the deck and then shuffled randomly. + """ + new_deck = self.base_deck[:] + random.shuffle(new_deck) + self.deck = new_deck + + def __next__(self): + """ + Draw the top card from the deck and return it. + + Drawn card is removed from the deck. If it was the last card, deck is + shuffled. + """ + card = self.deck.pop() + if not self.deck: + self.shuffle() + return card diff --git a/Dicebot/docs/7th Sea.txt b/Dicebot/docs/7th Sea.txt new file mode 100644 index 0000000..817d4e1 --- /dev/null +++ b/Dicebot/docs/7th Sea.txt @@ -0,0 +1,11 @@ +7th Sea RnK support +~~~~~~~~~~~~~~~~~~~ +There is a special support for RnK mechanics. The base form is '5k2', it shows +all kept dice and their sum. Exploded dice are shown as result numbers (23 for +10+10+3, for example). A static modifier may be applied to the total sum using +'5k2+2' or '5k2-2'. '-' prefix disables explosion of 10's, '+' prefix enables +printing of unkept dice. If you want to disable explosion and to print unkept +dice, you can use '-5kk2' ('5kk2' is identical to '+5k2'). Attempts to roll +and/or keep more than 10 dice are resolved according to Player's Guide, +wrapping excess dice from unkept to kept and from kept to the static modifier. +You can roll the same combination several times using, for example, '3#5k2'. diff --git a/Dicebot/docs/DH.txt b/Dicebot/docs/DH.txt new file mode 100644 index 0000000..a5bd654 --- /dev/null +++ b/Dicebot/docs/DH.txt @@ -0,0 +1,9 @@ +Dark Heresy/Rogue Trader/Deathwatch support +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +DH/RT/DW systems use a d100 die, the result is compared to a target number +with some modifiers. You can roll a die using 'vs(40)' or 'vs(40+20-10)' +syntax. Unlimited number of modifiers is supported. The result is displayed as +the threshold value minus the roll value, so a positive number means success +and tens digit equals the number of degrees of success/failure. +You can roll several identical tests using syntax '3vs(30)'. diff --git a/Dicebot/docs/NEWS.txt b/Dicebot/docs/NEWS.txt new file mode 100644 index 0000000..85f93ed --- /dev/null +++ b/Dicebot/docs/NEWS.txt @@ -0,0 +1,30 @@ +1.0 +~~~ +- Port to Python 3. + +0.6 +~~~ +- Extended tests for Shadowrun. +- Support any number of dice and modifiers in the standard roll. + +0.5 +~~~ +- Dark Heresy support added. +- New WoD support added (undocumented and untested, though). +- Multiroll support for 7th Sea. + +0.4 +~~~ +- 7th Sea support added. +- Basic card deck simulation added. +- Tests added. + +0.3 +~~~ +- Shadowrun 4 support added (see Shadowrun.txt). +- Multiple expressions per message are now supported. + +0.2 +~~~ +- First public release. + diff --git a/Dicebot/docs/README.txt b/Dicebot/docs/README.txt new file mode 100644 index 0000000..45ab7f1 --- /dev/null +++ b/Dicebot/docs/README.txt @@ -0,0 +1,62 @@ +Description +~~~~~~~~~~~ +Dicebot plugin contains the commands which simulate rolling of dice. +Though core supybot plugin Games contain 'dice' command, it is very simple and +is not sufficient for advanced uses, such as online playing of tabletop +role-playing games using IRC. +The most basic feature of any dicebot is, of course, rolling of one or several +identical dice and showing the results. That is what core 'dice' command can +do. It takes an expression such as 3d6 and returns a series of numbers - +results of rolling each (of 3) die (6-sided): '2, 4, and 4'. This may be +sufficient for some games, but usually you need more. Here is what this plugin +can do for you. + +Features +~~~~~~~~ +1. Sum of dice rolled. Expression form is just '2d6', plugin returns the sum +of dice values as one number. +2. Sum of several different dice and some fixed numbers. Expression: +'2d6+3d8-2+10'. After summing up dice results the specified number is added (or +subtracted) to the sum. +3. Separate results of several identical rolls which use previously described +form. This is written as '3#1d20+7' and yields 3 numbers, each meaning the +result of rolling 1d20+7 as described above. +4. Possibility to omit leading 1 as dice count and use just 'd6' or '3#d20'. +5. Two (three?) distinct modes of operation: roll command and autorolling (can +be enabled per-channel and for private messages, see configuration below). +roll command accepts just one expression and shows its result (if the +expression is valid). Autorolling means that bot automatically rolls and +displays all recognized expressions it sees (be it on the channel or in the +query). Autorolling is much more convenient during online play, but may be +confusing during normal talk, so it can be enabled only when it is needed. +6. To distinguish between different rolls, all results are prefixed with +requesting user's nick and requested expression. +7. If you use several expressions in one message, bot will roll all of them and +return all the results in one reply, separated with semicolon. +8. Shadowrun 4ed support, see included Shadowrun.txt; 7th Sea RnK support, see +7th Sea.txt; Dark Heresy/Rogue Trader/Deathwatch support, see DH.txt. +9. Concerning extensibility, you just need to add a regex for your expression +and a function which parses that expression and returns a string which will be +displayed. +10. Also includes basic card deck simulator, see below. + +Configuration +~~~~~~~~~~~~~ +autoRoll (per-channel): whether to roll all expressions seen on the channel +autoRollInPrivate (global): whether to roll expressions in the queries +Both settings are off by default, so that bot replies only to explicit !roll. + +Deck +~~~~ +Bot has a 54-card deck which it can shuffle (!shuffle command) and from which +you can draw (!draw or !deal command, with optional number argument if you want +to draw several cards). Drawn card is removed from the deck, but shuffle +restores full deck. If the last card is drawn, the deck is automatically +shuffled before drawing next card. + +Thanks +~~~~~~ +Ur-DnD roleplaying community (#dnd @ RusNet) for games, talking and fun, and +personally Zuzuzu for describing basic dicebot requirements, which led to +writing the first version of this plugin in August 2007. + diff --git a/Dicebot/docs/Shadowrun.txt b/Dicebot/docs/Shadowrun.txt new file mode 100644 index 0000000..0734ec8 --- /dev/null +++ b/Dicebot/docs/Shadowrun.txt @@ -0,0 +1,23 @@ +Shadowrun 4th ed. support +~~~~~~~~~~~~~~~~~~~~~~~~~ + +In SR4 you roll a number of d6 (this number is called a dice pool) and count +how many dice show 5 or 6, the more the better (SR4 p.54-55). You also need to +count 1's, because if half or more of the dice show 1, you have a *glitch* +(some bad game effect). Because you don't need to sum roll results, roll dice +other than d6 or add some fixed modifiers, SR4 dicer can (and should) be +simpler and easier to use. So you just say 10#sd (where 10 is your dice pool) +and bot will show the total hits number and/or a glitch message. +Also you can declare the use of Edge before a roll (SR4 p.67) to use the Rule +of Six (SR4, p.56), which means rerolling all 6's, potentially increasing the +total hits number. This mode is used by saying 10#sdx (x stands for 'eXploding +dice'). + +You can make Extended tests by saying i.e. 10,8#sde. Here 10 is the pool size +and 8 is the threshold. The output will include the number of passes, resulting +hit number and, in case of glitches, the pass number of the first glitch. + +The current version of Shadowrun code will log all the rolled dice values +(with the DEBUG level), to check the algorithm and for curious players. This +may be removed in future versions. + diff --git a/Dicebot/plugin.py b/Dicebot/plugin.py new file mode 100644 index 0000000..f70b1f0 --- /dev/null +++ b/Dicebot/plugin.py @@ -0,0 +1,511 @@ +### +# Copyright (c) 2007-2010, Andrey Rahmatullin +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +from .deck import Deck +from .sevenSea2EdRaiseRoller import SevenSea2EdRaiseRoller + +from operator import itemgetter +import re +import random + +from supybot.commands import additional, wrap +from supybot.utils.str import format, ordinal +import supybot.ircmsgs as ircmsgs +import supybot.callbacks as callbacks + +class Dicebot(callbacks.Plugin): + """This plugin supports rolling the dice using !roll 4d20+3 as well as + automatically rolling such combinations it sees in the channel (if + autoRoll option is enabled for that channel) or query (if + autoRollInPrivate option is enabled). + """ + + rollReStandard = re.compile(r'((?P\d+)#)?(?P[+-]?(\d*d\d+|\d+)([+-](\d*d\d+|\d+))*)$') + rollReSR = re.compile(r'(?P\d+)#sd$') + rollReSRX = re.compile(r'(?P\d+)#sdx$') + rollReSRE = re.compile(r'(?P\d+),(?P\d+)#sde$') + rollRe7Sea = re.compile(r'((?P\d+)#)?(?P[-+])?(?P\d+)(?Pk{1,2})(?P\d+)(?P[+-]\d+)?$') + rollRe7Sea2ed = re.compile(r'(?P([-+]|\d)+)s(?P\d)(?P-)?(l(?P\d+))?(?Pex)?(?Pr15)?$') + rollReWoD = re.compile(r'(?P\d+)w(?P\d|-)?$') + rollReDH = re.compile(r'(?P\d*)vs\((?P([-+]|\d)+)\)$') + rollReWG = re.compile(r'(?P\d+)#wg$') + + validationDH = re.compile(r'^[+\-]?\d{1,4}([+\-]\d{1,4})*$') + validation7sea2ed = re.compile(r'^[+\-]?\d{1,2}([+\-]\d{1,2})*$') + + MAX_DICE = 1000 + MIN_SIDES = 2 + MAX_SIDES = 100 + MAX_ROLLS = 30 + + def __init__(self, irc): + super(Dicebot, self).__init__(irc) + self.deck = Deck() + + def _roll(self, dice, sides, mod=0): + """ + Roll a die several times, return sum of the results plus the static modifier. + + Arguments: + dice -- number of dice rolled; + sides -- number of sides each die has; + mod -- number added to the total result (optional); + """ + res = int(mod) + for _ in range(dice): + res += random.randrange(1, sides+1) + return res + + def _rollMultiple(self, dice, sides, rolls=1, mod=0): + """ + Roll several dice several times, return a list of results. + + Specified number of dice with specified sides is rolled several times. + Each time the sum of results is calculated, with optional modifier + added. The list of these sums is returned. + + Arguments: + dice -- number of dice rolled each time; + sides -- number of sides each die has; + rolls -- number of times dice are rolled; + mod -- number added to the each total result (optional); + """ + return [self._roll(dice, sides, mod) for i in range(rolls)] + + @staticmethod + def _formatMod(mod): + """ + Format a numeric modifier for printing expressions such as 1d20+3. + + Nonzero numbers are formatted with a sign, zero is formatted as an + empty string. + """ + return ('%+d' % mod) if mod != 0 else '' + + def _process(self, irc, text): + """ + Process a message and reply with roll results, if any. + + The message is split to the words and each word is checked against all + known expression forms (first applicable form is used). All results + are printed together in the IRC reply. + """ + checklist = [ + (self.rollReStandard, self._parseStandardRoll), + (self.rollReSR, self._parseShadowrunRoll), + (self.rollReSRX, self._parseShadowrunXRoll), + (self.rollReSRE, self._parseShadowrunExtRoll), + (self.rollRe7Sea, self._parse7SeaRoll), + (self.rollRe7Sea2ed, self._parse7Sea2edRoll), + (self.rollReWoD, self._parseWoDRoll), + (self.rollReDH, self._parseDHRoll), + (self.rollReWG, self._parseWGRoll), + ] + results = [] + for word in text.split(): + for expr, parser in checklist: + m = expr.match(word) + if m: + r = parser(m) + if r: + results.append(r) + break + if results: + irc.reply('; '.join(results)) + + def _parseStandardRoll(self, m): + """ + Parse rolls such as 3#2d6+1d4+2. + + This is a roll (or several rolls) of several dice with optional + static modifiers. It yields one number (the sum of results and + modifiers) for each roll series. + """ + rolls = int(m.group('rolls') or 1) + spec = m.group('spec') + if not spec[0] in '+-': + spec = '+' + spec + r = re.compile(r'(?P[+-])((?P\d*)d(?P\d+)|(?P\d+))') + + totalMod = 0 + totalDice = {} + for m in r.finditer(spec): + if not m.group('mod') is None: + totalMod += int(m.group('sign') + m.group('mod')) + continue + dice = int(m.group('dice') or 1) + sides = int(m.group('sides')) + if dice > self.MAX_DICE or sides > self.MAX_SIDES or sides < self.MIN_SIDES: + return + if m.group('sign') == '-': + sides *= -1 + totalDice[sides] = totalDice.get(sides, 0) + dice + + if len(totalDice) == 0: + return + + results = [] + for _ in range(rolls): + result = totalMod + for sides, dice in totalDice.items(): + if sides > 0: + result += self._roll(dice, sides) + else: + result -= self._roll(dice, -sides) + results.append(result) + + specFormatted = '' + self.log.debug(repr(totalDice)) + for sides, dice in sorted(list(totalDice.items()), key=itemgetter(0), reverse=True): + if sides > 0: + if len(specFormatted) > 0: + specFormatted += '+' + specFormatted += '%dd%d' % (dice, sides) + else: + specFormatted += '-%dd%d' % (dice, -sides) + specFormatted += self._formatMod(totalMod) + + return '[%s] %s' % (specFormatted, ', '.join([str(i) for i in results])) + + def _parseShadowrunRoll(self, m): + """ + Parse Shadowrun-specific roll such as 3#sd. + """ + rolls = int(m.group('rolls')) + if rolls < 1 or rolls > self.MAX_DICE: + return + L = self._rollMultiple(1, 6, rolls) + self.log.debug(format("%L", [str(i) for i in L])) + return self._processSRResults(L, rolls) + + def _parseShadowrunXRoll(self, m): + """ + Parse Shadowrun-specific 'exploding' roll such as 3#sdx. + """ + rolls = int(m.group('rolls')) + if rolls < 1 or rolls > self.MAX_DICE: + return + L = self._rollMultiple(1, 6, rolls) + self.log.debug(format("%L", [str(i) for i in L])) + reroll = L.count(6) + while reroll: + rerolled = self._rollMultiple(1, 6, reroll) + self.log.debug(format("%L", [str(i) for i in rerolled])) + L.extend([r for r in rerolled if r >= 5]) + reroll = rerolled.count(6) + return self._processSRResults(L, rolls, True) + + @staticmethod + def _processSRResults(results, pool, isExploding=False): + hits = results.count(6) + results.count(5) + ones = results.count(1) + isHit = hits > 0 + isGlitch = ones >= (pool + 1) / 2 + explStr = ', exploding' if isExploding else '' + if isHit: + hitsStr = format('%n', (hits, 'hit')) + glitchStr = ', glitch' if isGlitch else '' + return '(pool %d%s) %s%s' % (pool, explStr, hitsStr, glitchStr) + if isGlitch: + return '(pool %d%s) critical glitch!' % (pool, explStr) + return '(pool %d%s) 0 hits' % (pool, explStr) + + def _parseShadowrunExtRoll(self, m): + """ + Parse Shadowrun-specific Extended test roll such as 14,3#sde. + """ + pool = int(m.group('pool')) + if pool < 1 or pool > self.MAX_DICE: + return + threshold = int(m.group('thr')) + if threshold < 1 or threshold > self.MAX_DICE: + return + result = 0 + passes = 0 + glitches = [] + critGlitch = None + while result < threshold: + L = self._rollMultiple(1, 6, pool) + self.log.debug(format('%L', [str(i) for i in L])) + hits = L.count(6) + L.count(5) + result += hits + passes += 1 + isHit = hits > 0 + isGlitch = L.count(1) >= (pool + 1) / 2 + if isGlitch: + if not isHit: + critGlitch = passes + break + glitches.append(ordinal(passes)) + + glitchStr = format(', glitch at %L', glitches) if len(glitches) > 0 else '' + if critGlitch is None: + return format('(pool %i, threshold %i) %n, %n%s', + pool, threshold, (passes, 'pass'), (result, 'hit'), glitchStr) + else: + return format('(pool %i, threshold %i) critical glitch at %s pass%s, %n so far', + pool, threshold, ordinal(critGlitch), glitchStr, (result, 'hit')) + + def _parse7Sea2edRoll(self, m): + """ + Parse 7th Sea 2ed roll (4s2 is its simplest form). Full spec: https://redd.it/80l7jm + """ + rolls = m.group('rolls') + if rolls is None: + return + # additional validation + if not re.match(self.validation7sea2ed, rolls): + return + + roll_count = eval(rolls) + if roll_count < 1 or roll_count > self.MAX_ROLLS: + return + skill = int(m.group('skill')) + vivre = m.group('vivre') == '-' + explode = m.group('explode') == 'ex' + lashes = 0 if m.group('lashes') is None else int(m.group('lashes')) + cursed = m.group('cursed') is not None + self.log.debug(format('7sea2ed: %i (%s) dices at %i skill. lashes = %i. explode is %s. vivre is %s', + roll_count, + str(rolls), + skill, + lashes, + "enabled" if explode else "disabled", + "enabled" if vivre else "disabled" + )) + roller = SevenSea2EdRaiseRoller( + lambda x: self._rollMultiple(1, 10, x), + skill_rank=skill, + explode=explode, + lash_count=lashes, + joie_de_vivre=vivre, + raise_target=15 if cursed else 10) + + return '[%s]: %s' % (m.group(0), str(roller.roll_and_count(roll_count))) + + + def _parse7SeaRoll(self, m): + """ + Parse 7th Sea-specific roll (4k2 is its simplest form). + """ + rolls = int(m.group('rolls')) + if rolls < 1 or rolls > self.MAX_ROLLS: + return + count = int(m.group('count') or 1) + keep = int(m.group('keep')) + mod = int(m.group('mod') or 0) + prefix = m.group('prefix') + k = m.group('k') + explode = prefix != '-' + if keep < 1 or keep > self.MAX_ROLLS: + return + if keep > rolls: + keep = rolls + if rolls > 10: + keep += rolls - 10 + rolls = 10 + if keep > 10: + mod += (keep - 10) * 10 + keep = 10 + unkept = (prefix == '+' or k == 'kk') and keep < rolls + explodeStr = ', not exploding' if not explode else '' + results = [] + for _ in range(count): + L = self._rollMultiple(1, 10, rolls) + if explode: + for i in range(len(L)): + if L[i] == 10: + while True: + rerolled = self._roll(1, 10) + L[i] += rerolled + if rerolled < 10: + break + self.log.debug(format("%L", [str(i) for i in L])) + L.sort(reverse=True) + keptDice, unkeptDice = L[:keep], L[keep:] + unkeptStr = ' | %s' % ', '.join([str(i) for i in unkeptDice]) if unkept else '' + keptStr = ', '.join([str(i) for i in keptDice]) + results.append('(%d) %s%s' % (sum(keptDice) + mod, keptStr, unkeptStr)) + + return '[%dk%d%s%s] %s' % (rolls, keep, self._formatMod(mod), explodeStr, + '; '.join(results)) + + def _parseWoDRoll(self, m): + """ + Parse New World of Darkness roll (5w) + """ + rolls = int(m.group('rolls')) + if rolls < 1 or rolls > self.MAX_ROLLS: + return + if m.group('explode') == '-': + explode = 0 + elif m.group('explode') is not None and m.group('explode').isdigit(): + explode = int(m.group('explode')) + if explode < 8 or explode > 10: + explode = 10 + else: + explode = 10 + L = self._rollMultiple(1, 10, rolls) + self.log.debug(format("%L", [str(i) for i in L])) + successes = len([x for x in L if x >= 8]) + if explode: + for i in range(len(L)): + if L[i] >= explode: + while True: + rerolled = self._roll(1, 10) + self.log.debug(str(rerolled)) + if rerolled >= 8: + successes += 1 + if rerolled < explode: + break + + if explode == 0: + explStr = ', not exploding' + elif explode != 10: + explStr = ', %d-again' % explode + else: + explStr = '' + + result = format('%n', (successes, 'success')) if successes > 0 else 'FAIL' + return '(%d%s) %s' % (rolls, explStr, result) + + def _parseDHRoll(self, m): + """ + Parse Dark Heresy roll (3vs(20+30-10)) + """ + rolls = int(m.group('rolls') or 1) + if rolls < 1 or rolls > self.MAX_ROLLS: + return + + thresholdExpr = m.group('thr') + # additional validation + if not re.match(self.validationDH, thresholdExpr): + return + + threshold = eval(thresholdExpr) + rollResults = self._rollMultiple(1, 100, rolls) + results = [threshold - roll for roll in rollResults] + return '%s (%s vs %d)' % (', '.join([str(i) for i in results]), + ', '.join([str(i) for i in rollResults]), + threshold) + + def _parseWGRoll(self, m): + """ + Parse WH40K: Wrath & Glory roll (10#wg) + """ + rolls = int(m.group('rolls') or 1) + if rolls < 1 or rolls > self.MAX_ROLLS: + return + + L = self._rollMultiple(1, 6, rolls) + self.log.debug(format("%L", [str(i) for i in L])) + return self._processWGResults(L, rolls) + + @staticmethod + def _processWGResults(results, pool): + wrathstrings=["❶","❷","❸","❹","❺","❻"] + strTag="" + + wrathDie=results.pop(0) + n6 = results.count(6) + n5 = results.count(5) + n4 = results.count(4) + icons = 2 * n6 + n5 + n4 + + Glory = wrathDie == 6 + Complication = wrathDie == 1 + + iconssymb = wrathstrings[wrathDie-1] + " " + if Glory: + strTag += "| Glory" + icons += 2 + elif wrathDie > 3: + icons += 1 + elif Complication: + strTag += "| Complication" + iconssymb += n6 * "➅ " + n5 * "5 " + n4 * "4 " + isNonZero = icons > 0 + if isNonZero: + iconsStr = str(icons) + " icon(s): " + iconssymb + strTag + return '[pool %d] %s' % (pool, iconsStr) + + def _autoRollEnabled(self, irc, channel): + """ + Check if automatic rolling is enabled for this context. + """ + return ((irc.isChannel(channel) and + self.registryValue('autoRoll', channel)) or + (not irc.isChannel(channel) and + self.registryValue('autoRollInPrivate'))) + + def roll(self, irc, msg, args, text): + """d[] + + Rolls a die with number of sides times, summarizes the + results and adds optional modifier + For example, 2d6 will roll 2 six-sided dice; 10d10-3 will roll 10 + ten-sided dice and subtract 3 from the total result. + """ + if self._autoRollEnabled(irc, msg.args[0]): + return + self._process(irc, text) + roll = wrap(roll, ['somethingWithoutSpaces']) + + def shuffle(self, irc, msg, args): + """takes no arguments + + Restores and shuffles the deck. + """ + self.deck.shuffle() + irc.reply('shuffled') + shuffle = wrap(shuffle) + + def draw(self, irc, msg, args, count): + """[] + + Draws cards (1 if omitted) from the deck and shows them. + """ + cards = [next(self.deck) for i in range(count)] + irc.reply(', '.join(cards)) + draw = wrap(draw, [additional('positiveInt', 1)]) + deal = draw + + def doPrivmsg(self, irc, msg): + if not self._autoRollEnabled(irc, msg.args[0]): + return + if ircmsgs.isAction(msg): + text = ircmsgs.unAction(msg) + else: + text = msg.args[1] + self._process(irc, text) + +Class = Dicebot + + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: diff --git a/Dicebot/readme.md b/Dicebot/readme.md new file mode 100644 index 0000000..8aa6f90 --- /dev/null +++ b/Dicebot/readme.md @@ -0,0 +1,6 @@ +# Testing + +1. Use python3+, python2 is not supported +2. I recommend to use virtualenv: `virtualenv -p python3 .venv && source .venv/bin/activate` +3. Install dependencies: `pip3 install -r requirements.txt` +4. Run tests: `supybot-test Dicebot` diff --git a/Dicebot/requirements.txt b/Dicebot/requirements.txt new file mode 100644 index 0000000..7a8e451 --- /dev/null +++ b/Dicebot/requirements.txt @@ -0,0 +1,3 @@ +limnoria +pylint +pytest diff --git a/Dicebot/sevenSea2EdRaiseRoller.py b/Dicebot/sevenSea2EdRaiseRoller.py new file mode 100644 index 0000000..bce0563 --- /dev/null +++ b/Dicebot/sevenSea2EdRaiseRoller.py @@ -0,0 +1,260 @@ +### +# Copyright (c) 2018, Anatoly Popov +# Copyright (c) 2018, Andrey Rahmatullin +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +import random +import pytest +from collections import defaultdict + +class RollResult: + def __init__(self, result, lash_count=0, joie_de_vivre_target=0, suffix=''): + self.result = result + self.suffix = suffix + if result < lash_count: + self.value = 0 + elif result <= joie_de_vivre_target: + self.value = 10 + else: + self.value = result + + def __str__(self): + if self.result == self.value: + return "%d%s" % (self.result, self.suffix) + else: + return "%d%s [%d]" % (self.value, self.suffix, self.result) + +class Raise: + def __init__(self, raise_count=0, rolls=[]): + self.rolls = list(map(lambda x: x if isinstance(x, RollResult) else RollResult(x), rolls)) + self.raise_count = raise_count + + @property + def Sum(self): + return sum(x.value for x in self.rolls) + + def __str__(self): + if self.raise_count == 0: + return "(%s)" % (" + ".join(map(str, self.rolls))) + else: + return "%s(%s)" % ("*" * self.raise_count, " + ".join(map(str, self.rolls))) + +class RaiseRollResult: + def __init__(self, raises=[], unused=[], discarded=None): + self.raises = raises + self.unused = unused + self.discarded = discarded + + def __str__(self): + total_raises = sum(x.raise_count for x in self.raises) + result = "0 raises" if total_raises == 0 else "%d %s: %s" % ( + total_raises, + "raises" if total_raises != 1 else "raise", + ", ".join(map(str, self.raises)) + ) + + if self.unused: + result = "%s, unused: %s" % ( + result, + ", ".join(map(str, self.unused)) + ) + + if self.discarded: + result = "%s, discarded: %s" % ( + result, + ", ".join(map(str, self.discarded)) + ) + + return result + +class RaiseAggregator: + def __init__(self, raise_target, raises_per_target, rolls): + self.raise_target = raise_target + self.raises_per_target = raises_per_target + self.ten_is_still_raise = self.raise_target == 10 or self.raises_per_target != 1 + self.exhausted = False + + self.rolled_dices = defaultdict(list) + self.rolled_dice_count = 0 + self.dices = defaultdict(list) + self.dice_count = 0 + self.max_roll = 0 + for x in rolls: + self.rolled_dices[x.value].append(x) + self.rolled_dice_count += 1 + if x.value > self.max_roll: + self.max_roll = x.value + + def get_dice(self, numbers_to_check): + for x in numbers_to_check: + if len(self.dices[x]) > 0: + self.dice_count -= 1 + return self.dices[x].pop() + + return None + + def tostr(self): + r = '{' + for x in self.dices: + r += '%d: [' % x + for y in self.dices[x]: + r += '%s, ' % str(y) + r += '], ' + return r + '}' + + def get_lower_dice(self, target): + return self.get_dice(range(target, 0, -1)) + + def get_higher_dice(self, target): + return self.get_dice(range(target + 1, self.max_roll + 1)) + + def get_raise_candidate(self, first_dice, down): + raise_candidate = [first_dice] + while True: + raise_sum = sum(x.value for x in raise_candidate) + if raise_sum >= self.raise_target: + return Raise(self.raises_per_target, raise_candidate) + + target = self.raise_target - raise_sum + next_dice = self.get_lower_dice(target) if down else self.get_higher_dice(target) + if next_dice is not None: + raise_candidate.append(next_dice) + elif self.dice_count > 0 and down: + # we are going down. Let's grab one dice above and continue + raise_candidate.append(self.get_higher_dice(0)) + continue + elif self.ten_is_still_raise and raise_sum >= 10: + return Raise(1, raise_candidate) + else: + return Raise(0, raise_candidate) + + def return_dice_to_pool(self, dice): + self.dice_count += 1 + self.dices[dice.value].append(dice) + + def return_raise_to_pool(self, first_dice, raise_candidate): + for x in raise_candidate.rolls: + if x != first_dice: + self.return_dice_to_pool(x) + + def __iter__(self): + self.dices = defaultdict(list) + self.dice_count = self.rolled_dice_count + for value in self.rolled_dices: + for roll in self.rolled_dices[value]: + self.dices[value].append(roll) + self.exhausted = False + return self + + def __next__(self): + if self.exhausted: + raise StopIteration + + first_dice = self.get_lower_dice(self.max_roll) + if first_dice is None: + self.exhausted = True + raise StopIteration + + lower = self.get_raise_candidate(first_dice, True) + if lower.Sum == self.raise_target: + return lower + + higher = self.get_raise_candidate(first_dice, False) + if higher.raise_count == 0 and lower.raise_count == 0: + self.exhausted = True + self.return_raise_to_pool(first_dice, higher) + self.return_raise_to_pool(first_dice, lower) + self.return_dice_to_pool(first_dice) + raise StopIteration + + if higher.raise_count == lower.raise_count: + if higher.Sum >= lower.Sum: + self.return_raise_to_pool(first_dice, higher) + return lower + else: + self.return_raise_to_pool(first_dice, lower) + return higher + elif higher.raise_count > lower.raise_count: + self.return_raise_to_pool(first_dice, lower) + return higher + else: + self.return_raise_to_pool(first_dice, higher) + return lower + + +class SevenSea2EdRaiseRoller: + """ + Raise roller for 7sea, 2ed. Spec: https://redd.it/80l7jm + """ + + def __init__(self, roller, raise_target=10, raises_per_target=1, explode=False, lash_count=0, skill_rank=0, joie_de_vivre=False): + self.roller = roller + self.explode = skill_rank >= 5 or explode + self.lash_count = lash_count + self.joie_de_vivre_target = skill_rank if joie_de_vivre else 0 + self.reroll_one_dice = skill_rank >= 3 + default_roll = raise_target == 10 and raises_per_target == 1 + self.aggregator_template = lambda x: RaiseAggregator( + 15 if skill_rank >= 4 and default_roll else raise_target, + 2 if skill_rank >= 4 and default_roll else raises_per_target, + x + ) + + def roll_and_count(self, dice_count): + """ + Assemble raises, according to spec + """ + rolls = self.roll(dice_count) + if not self.reroll_one_dice: + discarded_dice = None + else: + reroll = self.roll(1, 'r') + min_value_dice = min(rolls, key=lambda x: x.value) + if min_value_dice.value < sum(x.value for x in reroll): + rolls.remove(min_value_dice) + rolls += reroll + discarded_dice = [min_value_dice] + else: + discarded_dice = reroll + + aggregator = self.aggregator_template(rolls) + raises = list(aggregator) + unused = [] + for value in aggregator.dices: + for dice in aggregator.dices[value]: + unused.append(dice) + + return RaiseRollResult(raises, sorted(unused, key=lambda x: x.value, reverse=True), discarded_dice) + + def roll(self, dice_count, suffix=''): + if dice_count == 0: + return [] + + rolls = [RollResult(x, self.lash_count, self.joie_de_vivre_target, suffix) for x in self.roller(dice_count)] + + return rolls + self.roll(len([x for x in rolls if x.result == 10]), suffix + 'x') if self.explode else rolls diff --git a/Dicebot/test.py b/Dicebot/test.py new file mode 100644 index 0000000..b9ba1dd --- /dev/null +++ b/Dicebot/test.py @@ -0,0 +1,115 @@ +### +# Copyright (c) 2007-2010, Andrey Rahmatullin +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +from supybot.test import PluginTestCase + +class DicebotTestCase(PluginTestCase): + plugins = ('Dicebot',) + def testPlugin(self): + self.assertHelp('dicebot roll') + self.assertNotError('dicebot roll 1d2') + self.assertNoResponse('dicebot roll dummy') + + def testRollStd(self): + self.assertRegexp('dicebot roll 1d20', r'\[1d20\] \d+') + self.assertRegexp('dicebot roll d20', r'\[1d20\] \d+') + self.assertRegexp('dicebot roll 1d20+5', r'\[1d20\+5\] \d+') + self.assertRegexp('dicebot roll d20+5', r'\[1d20\+5\] \d+') + self.assertRegexp('dicebot roll 1d20-30', r'\[1d20-30\] -\d+') + self.assertRegexp('dicebot roll d20-30', r'\[1d20-30\] -\d+') + self.assertRegexp('dicebot roll 2d20-1', r'\[2d20-1\] \d+') + self.assertRegexp('dicebot roll d20-1d6+3', r'\[1d20-1d6\+3\] -?\d+') + self.assertRegexp('dicebot roll 1d20+d20+3', r'\[2d20\+3\] \d+') + self.assertRegexp('dicebot roll 1d20+4+d6-3', r'\[1d20\+1d6\+1\] \d+') + self.assertNoResponse('dicebot roll 1d1') + + def testRollMult(self): + self.assertRegexp('dicebot roll 2#1d20', r'\[1d20\] \d+, \d+') + self.assertRegexp('dicebot roll 2#d20', r'\[1d20\] \d+, \d+') + self.assertRegexp('dicebot roll 2#1d20+5', r'\[1d20\+5\] \d+, \d+') + self.assertRegexp('dicebot roll 2#d20+5', r'\[1d20\+5\] \d+, \d+') + self.assertRegexp('dicebot roll 2#1d20-30', r'\[1d20-30\] -\d+, -\d+') + self.assertRegexp('dicebot roll 2#d20-30', r'\[1d20-30\] -\d+, -\d+') + self.assertRegexp('dicebot roll 2#2d20-1', r'\[2d20-1\] \d+, \d+') + self.assertNoResponse('dicebot roll 2#1d1') + + def testRollSR(self): + self.assertRegexp('dicebot roll 2#sd', r'\(pool 2\) (\d hits?|critical glitch!)') + self.assertRegexp('dicebot roll 4#sd', r'\(pool 4\) (\d hits?(, glitch)?|critical glitch!)') + self.assertNoResponse('dicebot roll 0#sd') + + def testRollSRX(self): + self.assertRegexp('dicebot roll 2#sdx', r'\(pool 2, exploding\) (\d hits?|critical glitch!)') + self.assertRegexp('dicebot roll 4#sdx', r'\(pool 4, exploding\) (\d hits?(, glitch)?|critical glitch!)') + self.assertNoResponse('dicebot roll 0#sdx') + + def testRoll7S(self): + self.assertRegexp('dicebot roll 3k2', r'\[3k2\] \(\d+\) \d+, \d+') + self.assertRegexp('dicebot roll 2k3', r'\[2k2\] \(\d+\) \d+, \d+') + self.assertRegexp('dicebot roll 3kk2', r'\[3k2\] \(\d+\) \d+, \d+ \| \d+') + self.assertRegexp('dicebot roll +3k2', r'\[3k2\] \(\d+\) \d+, \d+ \| \d+') + self.assertRegexp('dicebot roll -3k2', r'\[3k2, not exploding\] \(\d+\) \d+, \d+') + self.assertRegexp('dicebot roll +3kk2', r'\[3k2\] \(\d+\) \d+, \d+ \| \d+') + self.assertRegexp('dicebot roll -3kk2', r'\[3k2, not exploding\] \(\d+\) \d+, \d+ \| \d+') + self.assertRegexp('dicebot roll 3k2+1', r'\[3k2\+1\] \(\d+\) \d+, \d+') + self.assertRegexp('dicebot roll 3k2-1', r'\[3k2-1\] \(\d+\) \d+, \d+') + self.assertRegexp('dicebot roll -3k2-1', r'\[3k2-1, not exploding\] \(\d+\) \d+, \d+') + self.assertRegexp('dicebot roll 10k10', r'\[10k10\] \(\d+\) (\d+, ){9}\d+') + self.assertRegexp('dicebot roll 12k10', r'\[10k10\+20\] \(\d+\) (\d+, ){9}\d+') + self.assertRegexp('dicebot roll 12k9', r'\[10k10\+10\] \(\d+\) (\d+, ){9}\d+') + self.assertRegexp('dicebot roll 12k8', r'\[10k10\] \(\d+\) (\d+, ){9}\d+') + self.assertRegexp('dicebot roll 12k9+5', r'\[10k10\+15\] \(\d+\) (\d+, ){9}\d+') + self.assertRegexp('dicebot roll 12kk9', r'\[10k10\+10\] \(\d+\) (\d+, ){9}\d+') + self.assertRegexp('dicebot roll 12kk7', r'\[10k9\] \(\d+\) (\d+, ){8}\d+ \| \d+') + self.assertRegexp('dicebot roll 3#3k2', r'\[3k2\] \(\d+\) \d+, \d+(; \(\d+\) \d+, \d+){2}') + + def testDeck(self): + validator = r'(2|3|4|5|6|7|8|9|10|J|Q|K|A)(♣|♦|♥|♠)|(Black|Red) Joker' + self.assertRegexp('dicebot draw', validator) + self.assertResponse('dicebot shuffle', 'shuffled') + for i in range(0, 54): + self.assertRegexp('dicebot draw', validator) + + def testWoD(self): + self.assertRegexp('dicebot roll 3w', r'\(3\) (\d success(es)?|FAIL)') + self.assertRegexp('dicebot roll 3w-', r'\(3, not exploding\) (\d success(es)?|FAIL)') + self.assertRegexp('dicebot roll 3w8', r'\(3, 8-again\) (\d success(es)?|FAIL)') + self.assertNoResponse('dicebot roll 0w') + + def testDH(self): + self.assertRegexp('dicebot roll vs(10)', r'-?\d+ \(\d+ vs 10\)') + self.assertRegexp('dicebot roll vs(10+20)', r'-?\d+ \(\d+ vs 30\)') + self.assertRegexp('dicebot roll vs(10+20-5)', r'-?\d+ \(\d+ vs 25\)') + self.assertRegexp('dicebot roll 3vs(10+20)', r'-?\d+, -?\d+, -?\d+ \(\d+, \d+, \d+ vs 30\)') + + def testWG(self): + self.assertRegexp('dicebot roll 10#wg', r'\[pool 10\] \d+ icon\(s\): [❶❷❸❹❺❻] ([1-5➅] )*(\| Glory|\| Complication)?') + + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: diff --git a/Dicebot/test_Raise.py b/Dicebot/test_Raise.py new file mode 100644 index 0000000..001736f --- /dev/null +++ b/Dicebot/test_Raise.py @@ -0,0 +1,50 @@ +### +# Copyright (c) 2018, Anatoly Popov +# Copyright (c) 2018, Andrey Rahmatullin +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +import random +import pytest +from .sevenSea2EdRaiseRoller import Raise, RollResult + +class TestRaise: + def test_str_no_raises(self): + x = Raise(rolls=[10]) + assert str(x) == "(10)" + + def test_str_some_raises(self): + x = Raise(2, rolls=[10, 5]) + assert str(x) == "**(10 + 5)" + + def test_str_no_raises_complex_roll(self): + x = Raise(rolls=[RollResult(1, lash_count=5)]) + assert str(x) == "(0 [1])" + + def test_str_some_raises_complex_roll(self): + x = Raise(2, rolls=[10, RollResult(1, joie_de_vivre_target=5)]) + assert str(x) == "**(10 + 10 [1])" diff --git a/Dicebot/test_RollResult.py b/Dicebot/test_RollResult.py new file mode 100644 index 0000000..788278a --- /dev/null +++ b/Dicebot/test_RollResult.py @@ -0,0 +1,82 @@ +### +# Copyright (c) 2018, Anatoly Popov +# Copyright (c) 2018, Andrey Rahmatullin +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +import random +import pytest +from .sevenSea2EdRaiseRoller import RollResult + +class TestRollResult: + def test_default(self): + x = RollResult(1, 0, 0) + assert x.value == 1 + assert x.result == 1 + + def test_joie_de_vivre(self): + x = RollResult(1, joie_de_vivre_target=1) + assert x.value == 10 + assert x.result == 1 + + x = RollResult(2, joie_de_vivre_target=1) + assert x.value == 2 + assert x.result == 2 + + def test_lashes(self): + x = RollResult(1, lash_count=2) + assert x.value == 0 + assert x.result == 1 + + x = RollResult(2, lash_count=2) + assert x.value == 2 + assert x.result == 2 + + def test_lashes_precede_joie_de_vivre(self): + x = RollResult(1, lash_count=2, joie_de_vivre_target=1) + assert x.value == 0 + assert x.result == 1 + + def test_joie_de_vivre_works_greater_lashes(self): + x = RollResult(3, lash_count=2, joie_de_vivre_target=5) + assert x.value == 10 + assert x.result == 3 + + x = RollResult(1, lash_count=2, joie_de_vivre_target=5) + assert x.value == 0 + assert x.result == 1 + + def test_output_no_changes(self): + x = RollResult(3) + assert str(x) == '3' + + def test_output_any_change(self): + x = RollResult(3, lash_count=5) + assert str(x) == '0 [3]' + + x = RollResult(3, joie_de_vivre_target=5) + assert str(x) == '10 [3]' diff --git a/Dicebot/test_Roller.py b/Dicebot/test_Roller.py new file mode 100644 index 0000000..f4b9fac --- /dev/null +++ b/Dicebot/test_Roller.py @@ -0,0 +1,145 @@ +### +# Copyright (c) 2018, Anatoly Popov +# Copyright (c) 2018, Andrey Rahmatullin +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +import random +import pytest +from .sevenSea2EdRaiseRoller import Raise, RollResult, SevenSea2EdRaiseRoller + +class TestRoller: + def test_zero_dice(self): + x = SevenSea2EdRaiseRoller(lambda x: range(1, x+1)).roll_and_count(0) + assert len(x.raises) == 0 + assert len(x.unused) == 0 + assert str(x) == "0 raises" + + def test_zero_raises_one_dice(self): + x = SevenSea2EdRaiseRoller(lambda x: range(1, x+1)).roll_and_count(1) + assert len(x.raises) == 0 + assert len(x.unused) == 1 + assert str(x) == "0 raises, unused: 1" + + def test_green(self): + x = SevenSea2EdRaiseRoller(lambda x: range(1, x+1)).roll_and_count(4) + assert len(x.raises) == 1 + assert len(x.unused) == 0 + assert str(x) == "1 raise: *(4 + 3 + 2 + 1)" + + def test_explode(self): + rolls = SevenSea2EdRaiseRoller(ExplodingRoller().roll).roll(1) + assert ', '.join(map(str, rolls)) == "10" + + rolls = SevenSea2EdRaiseRoller(ExplodingRoller().roll, explode=True).roll(1) + assert ', '.join(map(str, rolls)) == "10, 5x" + + rolls = SevenSea2EdRaiseRoller(ExplodingRoller(3).roll, explode=True).roll(1) + assert ', '.join(map(str, rolls)) == "10, 10x, 10xx, 5xxx" + + rolls = SevenSea2EdRaiseRoller(ExplodingRoller(3).roll, explode=True).roll(3) + assert ', '.join(map(str, rolls)) == "10, 10, 10, 5x, 10x, 10x, 10xx, 5xx, 10xxx, 10xxxx, 10xxxxx, 5xxxxxx" + + def test_big_skill(self): + rolls = SevenSea2EdRaiseRoller( + RerollRoller([8, 6, 1, 8, 5, 2, 4]).roll, + skill_rank=7 + ).roll_and_count(7) + assert str(rolls) == "4 raises: **(8 + 6 + 1), **(8 + 5 + 2), unused: 4, discarded: 1r" + + def test_nines_without_ones(self): + rolls = SevenSea2EdRaiseRoller( + RerollRoller([10, 9, 9, 9, 8, 7, 6, 2]).roll, + skill_rank=3 + ).roll_and_count(8) + assert str(rolls) == "4 raises: *(10), *(9 + 2), *(9 + 6), *(9 + 7), unused: 8, discarded: 1r" + + def test_discard_one_of_the_initial(self): + rolls = SevenSea2EdRaiseRoller( + RerollRoller([10, 9, 9, 9, 8, 7, 6, 2], [10, 5]).roll, + skill_rank=3 + ).roll_and_count(8) + assert str(rolls) == "5 raises: *(10r), *(10), *(9 + 6), *(9 + 7), *(9 + 8), discarded: 2" + + def test_discard_one_of_the_initial_explode(self): + rolls = SevenSea2EdRaiseRoller( + RerollRoller([10, 9, 9, 9, 8, 7, 6, 2], [10, 5]).roll, + skill_rank=3, + explode=True + ).roll_and_count(7) + assert str(rolls) == "5 raises: *(10r), *(10), *(9 + 5rx), *(9 + 6), *(9 + 7), unused: 8, discarded: 2x" + + def test_optimal_solution_is_one_step_up(self): + rolls = SevenSea2EdRaiseRoller( + RerollRoller([10, 10, 10, 10, 5, 5, 5, 4, 4, 7, 6]).roll, + skill_rank=5 + ).roll_and_count(7) + assert str(rolls) == "10 raises: **(10 + 5), **(10 + 5), **(10 + 5), **(10 + 6x), **(7x + 4x + 4x), discarded: 1r" + + def test_optimal_solution_is_one_step_up2(self): + rolls = SevenSea2EdRaiseRoller( + RerollRoller([10, 5, 10, 5, 6, 4, 3, 4, 3], [2]).roll, + skill_rank=5 + ).roll_and_count(7) + assert str(rolls) == "6 raises: **(10 + 5), **(10 + 5), **(6 + 4x + 4 + 3x), unused: 3, discarded: 2r" + + # will wait boosting trees + # def test_optimal_solution_is_one_step_up3(self): + # rolls = SevenSea2EdRaiseRoller( + # RerollRoller([7, 6, 3, 1, 6, 4, 4]).roll, + # skill_rank=5 + # ).roll_and_count(7) + # assert str(rolls) == "4 raises: **(7 + 6 + 3), **(6 + 4 + 4 + 1), discarded: 1" + +class Roller: + def roll(self, count): + return [next(self) for _ in range(count)] + +class RerollRoller(Roller): + def __init__(self, result, reroll_result=[1]): + self.result = result + reroll_result + self.index = 0 + + def __next__(self): + self.index %= len(self.result) + value = self.result[self.index] + self.index += 1 + return value + +class ExplodingRoller(Roller): + def __init__(self, ten_count=1, default_value=5): + self.ten_count = ten_count + self.current_ten_count = ten_count + self.default_value = default_value + + def __next__(self): + if self.current_ten_count == 0: + self.current_ten_count = self.ten_count + return self.default_value + else: + self.current_ten_count -= 1 + return 10 diff --git a/Unicode/requirements.txt b/Unicode/requirements.txt deleted file mode 100644 index 322630e..0000000 --- a/Unicode/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -simplejson