mirror of
https://github.com/oddluck/limnoria-plugins.git
synced 2025-04-25 12:31:07 -05:00
550 lines
19 KiB
Python
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:
|