2020-06-30 22:03:05 +00:00

550 lines
19 KiB
Python

###
# Copyright (c) 2008, Andrey Rahmatullin
# Copyright (c) 2020, oddluck <oddluck@riseup.net>
# 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 Dice(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<rolls>\d+)#)?(?P<spec>[+-]?(\d*d\d+|\d+)([+-](\d*d\d+|\d+))*)$"
)
rollReSR = re.compile(r"(?P<rolls>\d+)#sd$")
rollReSRX = re.compile(r"(?P<rolls>\d+)#sdx$")
rollReSRE = re.compile(r"(?P<pool>\d+),(?P<thr>\d+)#sde$")
rollRe7Sea = re.compile(
r"((?P<count>\d+)#)?(?P<prefix>[-+])?(?P<rolls>\d+)(?P<k>k{1,2})(?P<keep>\d+)(?P<mod>[+-]\d+)?$"
)
rollRe7Sea2ed = re.compile(
r"(?P<rolls>([-+]|\d)+)s(?P<skill>\d)(?P<vivre>-)?(l(?P<lashes>\d+))?(?P<explode>ex)?(?P<cursed>r15)?$"
)
rollReWoD = re.compile(r"(?P<rolls>\d+)w(?P<explode>\d|-)?$")
rollReDH = re.compile(r"(?P<rolls>\d*)vs\((?P<thr>([-+]|\d)+)\)$")
rollReWG = re.compile(r"(?P<rolls>\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(Dice, 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<sign>[+-])((?P<dice>\d*)d(?P<sides>\d+)|(?P<mod>\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):
"""<dice>d<sides>[<modifier>]
Rolls a die with <sides> number of sides <dice> times, summarizes the
results and adds optional modifier <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):
"""[<count>]
Draws <count> 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 = Dice
# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: