# -*- test-case-name: omnipresence.plugins.dice.test_dice
"""Event plugins for storing banks of die rolls used in role-playing
games."""
from collections import defaultdict, Counter
from random import Random
from ...humanize import andify
from ...message import collapse
from ...plugin import SubcommandEventPlugin, UserVisibleError
#: The maximum number of dice that can be rolled at once.
MAX_DIE_GROUP_SIZE = 42
def format_rolls(rolls):
"""Return a string representing the given *rolls* and their sum, in
the form `"1 2 3 4 = 10"`."""
rolls = list(rolls)
if not rolls:
return 'no rolls'
return '\x02{}\x02 = {}'.format(
' '.join(str(r) for r in sorted(rolls)), sum(rolls))
[docs]class Default(SubcommandEventPlugin):
"""Manage dice pools.
The ``new``, ``add``, ``use``, and ``clear`` subcommands affect a
per-user bank of die rolls, while ``roll`` is used for one-off rolls
that should not be added to the bank.
:alice: dice new 4d6
:bot: Rolled **1 4 5 6** = 16.
Bank now has **1 4 5 6** = 16.
:brian: dice new 4d6
:bot: Rolled **2 3 3 4** = 12.
Bank now has **2 3 3 4** = 12.
:alice: dice
:bot: Bank has **1 4 5 6** = 16.
:alice: dice show brian
:bot: Bank has **2 3 3 4** = 12.
:brian: dice roll 2d20
:bot: Rolled **7 15** = 22.
:brian: dice use 3 3
:bot: Used **3 3** = 6. Bank now has **2 4** = 12.
:alice: dice clear
:bot: Bank cleared.
"""
def __init__(self):
#: User die banks, keyed by a (channel, nick) tuple.
self.banks = defaultdict(Counter)
#: The instance of `random.Random` used for die rolls. This is
#: overridden for deterministic testing.
self.random = Random()
[docs] def roll_dice(self, dice):
"""Return random rolls for the *dice* given as an iterable
containing some combination of individual die groups as strings,
such as `"2d6"`. Integers are accepted as dice; they "roll" to
themselves."""
rolls = []
for die_group in dice:
number, d, size = die_group.rpartition('d')
constant = not d
# Set the number of dice to 1 if this is a "constant" integer
# die, or there is no number specified (so "d8" == "1d8").
if constant or not number:
number = 1
try:
number = int(number)
size = int(size)
except ValueError:
raise ValueError('Invalid die group specification {}.'
.format(die_group))
if number < 1:
raise ValueError('Invalid number of dice {}.'.format(number))
if size < 1:
raise ValueError('Invalid die size {}.'.format(size))
if len(rolls) + number > MAX_DIE_GROUP_SIZE:
raise ValueError('Cannot roll more than {} dice at once.'
.format(MAX_DIE_GROUP_SIZE))
for _ in xrange(number):
rolls.append(size if constant
else self.random.randint(1, size))
return rolls
def reply_for_roll(self, msg, args, update=False, clear=False):
nick = msg.connection._lower(msg.actor.nick)
if not args:
raise UserVisibleError('Please specify dice to roll.')
try:
rolls = self.roll_dice(args.split())
except ValueError as e:
raise UserVisibleError(str(e))
message = 'Rolled {}.'.format(format_rolls(rolls))
if update:
if clear:
self.banks.pop((msg.venue, nick), None)
bank = self.banks[(msg.venue, nick)]
bank.update(rolls)
message += ' Bank now has {}.'.format(
format_rolls(bank.elements()))
return message
def on_empty_subcommand(self, msg):
return self.on_subcommand_show(msg, '')
def on_empty_subcmdhelp(self, msg):
return ('[\x02add\x02 \x1Fdice\x1F | '
'\x02clear\x02 | '
'\x02new\x02 \x1Fdice\x1F | '
'\x02roll\x02 \x1Fdice\x1F | '
'\x02show\x02 [\x1Fnick\x1F] | '
'\x02use\x02 \x1Frolls\x1F] - '
'Manage your die bank. '
'For more details on a specific subcommand, see help '
'for \x02{0}\x02 \x1Fsubcommand\x1F. '
'For information on dice notation, see help for '
'\x02{0} notation\x02.').format(msg.subaction)
def on_subcommand_add(self, msg, args):
return self.reply_for_roll(msg, args, update=True)
def on_subcmdhelp_add(self, msg):
return ('\x1Fdice\x1F - Roll the given dice and add the '
'resulting rolls to your die bank.')
def on_subcommand_clear(self, msg, args):
nick = msg.connection._lower(msg.actor.nick)
self.banks.pop((msg.venue, nick), None)
return 'Bank cleared.'
def on_subcmdhelp_clear(self, msg):
return '- Remove all rolls from your die bank.'
def on_subcommand_new(self, msg, args):
return self.reply_for_roll(msg, args, update=True, clear=True)
def on_subcmdhelp_new(self, msg):
return ('\x1Fdice\x1F - Remove all rolls from your die bank, '
'then roll the given dice and add the resulting rolls '
'to your die bank.')
def on_subcmdhelp_notation(self, msg):
return ('- Indicate dice using the standard '
'\x1FA\x1F\x02d\x02\x1FX\x1F notation, where \x1FA\x1F '
'is the number of dice to roll and \x1FX\x1F is the '
'die size. '
'Separate multiple sets of dice with spaces. '
'Positive integers may also be used as dice; they '
'"roll" to themselves.')
def on_subcommand_roll(self, msg, args):
return self.reply_for_roll(msg, args)
def on_subcmdhelp_roll(self, msg):
return ('\x1Fdice\x1F - Roll the given dice without adding the '
' resulting rolls to your die bank.')
def on_subcommand_show(self, msg, args):
nick = msg.connection._lower(args or msg.actor.nick)
rolls = self.banks[(msg.venue, nick)].elements()
return 'Bank has {}.'.format(format_rolls(rolls))
def on_subcmdhelp_show(self, msg):
return ('[\x1Fnick\x1F] - Show the rolls in the die bank '
'belonging to the user with the given nick, or your '
'own if no nick is provided.')
def on_subcommand_use(self, msg, args):
nick = msg.connection._lower(msg.actor.nick)
if not args:
raise UserVisibleError('Please specify rolls to use.')
rolls = []
for roll in args.split():
try:
rolls.append(int(roll))
except ValueError:
raise UserVisibleError('{} is not a valid roll.'.format(roll))
# Figure out if the specified rolls actually exist by
# duplicating the bank, subtracting the rolls from it,
# and bailing if any of the counts are negative.
new_bank = Counter(self.banks[(msg.venue, nick)])
new_bank.subtract(rolls)
negatives = sorted([
roll for roll, count in new_bank.iteritems() if count < 0])
if negatives:
raise UserVisibleError(
'You do not have enough {} in your die bank to use '
'those rolls.'.format(
andify(['{}s'.format(n) for n in negatives])))
self.banks[(msg.venue, nick)] = new_bank
return 'Used {}. Bank now has {}.'.format(
format_rolls(rolls),
format_rolls(new_bank.elements()))
def on_subcmdhelp_use(self, msg):
return '\x1Frolls\x1F - Remove the given rolls from your die bank.'
def on_nick(self, msg):
venues = [venue for venue, nick in self.banks
if msg.connection.case_mapping.equates(msg.actor.nick, nick)]
old_nick = msg.connection._lower(msg.actor.nick)
new_nick = msg.connection._lower(msg.content)
for venue in venues:
self.banks[(venue, new_nick)] = self.banks[(venue, old_nick)]
del self.banks[(venue, old_nick)]