Awesome IRC bot
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

455 lines
16 KiB

.. module:: UnoPlay
:synopsis: Plays the Uno card game against the popular Eggdrop script "Color Uno"
.. moduleauthor:: Dave Pedu <>
from pyircbot.modulebase import ModuleBase, hook
from random import randint
import time
import re
from threading import Thread
from pprint import pprint
import logging
class UnoPlay(ModuleBase):
def __init__(self, bot, moduleName):
ModuleBase.__init__(self, bot, moduleName)
self.servicesModule ="Services")
self.current_card = None
self.shouldgo = False
self.has_drawn = False
self.has_joined = False
self.games_played = 0
self.strategies = {"play_high_value": self.play_high_value,
"play_by_chains": self.play_by_chains}
assert self.config["strategy"] in self.strategies = []
def trigger(self, msg, cmd):
if msg.trailing.startswith("["): # anti-znc buffer playback
if self.config["enable_trigger"] and "!jo" in msg.trailing:["unochannel"], "jo")
elif msg.trailing == "jo":
# Reset streak counter & join if another human joins
self.games_played = 0
def decklisten(self, msg, cmd):
Listen for messages sent via NOTICe to the bot, this is usually a list of cards in our hand. Parse them.
if msg.trailing.startswith("["): # anti-znc buffer playback
if not msg.prefix or self.config["unobot"] not in msg.prefix:
if "You don't have that card" in msg.trailing:
self.log.error("played invalid card!")
trailing = self.stripcolors(msg.trailing)
cards = []
for carddata in trailing.split(" "):
carddata = carddata.strip()
cards.sort(key=lambda tup: tup[1])
cards.reverse() = cards
if self.shouldgo:
self.shouldgo = False
def unoplay(self, msg, cmd):
if msg.trailing.startswith("["): # anti-znc buffer playback
trailing = self.stripcolors(msg.trailing)
if self.config["unobot"] not in msg.prefix.nick:
# Parse card from beginning message
# See if we play first
if "plays first..." in trailing:
message = trailing.split("The top card is")[1]"we play first!")
self.current_card = self.parsecard(message)
self.log.debug("top card: %s" % str(self.current_card))
if in trailing:
self.shouldgo = True
# We need to choose a color
if "hoose a color %s" % in trailing:
if "hoose a color" in trailing and not in trailing:
# Waiting for other guy to choose a color
# See if someone passed to us
if "passes" in trailing and in trailing:
self.shouldgo = True
# Play after someone was droppped
if "continuing with" in trailing and in trailing:
self.shouldgo = True
# After color change by bot
if "Current player " in trailing and "and chooses" in trailing:
color = trailing.split(" and chooses ")[1].split(" Current ")[0].strip()
self.current_card[2]['color'] = {'Blue': 'b', 'Red': 'r', 'Yellow': 'y', 'Green': 'g'}[color]
self.current_card[2]['number'] = -1
self.current_card[2]['type'] = None"Color changed to %s " % self.current_card[2]['color'])
if "urrent player %s" % in trailing:
self.shouldgo = True
# After color change
if "play continues with " in trailing:
color = trailing.split(" chose ")[1].split(" , ")[0].strip()
self.current_card[2]['color'] = {'Blue': 'b', 'Red': 'r', 'Yellow': 'y', 'Green': 'g'}[color]
self.current_card[2]['number'] = -1
self.current_card[2]['type'] = None"Color changed to %s " % self.current_card[2]['color'])
if in trailing:
self.shouldgo = True
# Parse misc played cards
# bug
if " plays " in trailing and "four cards" not in trailing:
message = trailing.split(" plays ")[1].split(" ")[0]
self.current_card = self.parsecard(message)"current card: %s" % str(self.current_card))
# After someone plays to us
if "to %s" % in trailing:
self.shouldgo = True
# After color change to us
if "play continues with %s" % in trailing:
self.shouldgo = True
# Reset
if " by Marky" in trailing or "cards played in" in trailing:"System reset")
self.current_card = None
self.shouldgo = False
self.has_drawn = False
self.has_joined = False = []
if self.config["enable_autojoin"]:
if "to join uno" in trailing:
if self.games_played >= self.config["streak_max"]:
self.games_played = 0
def play_high_value(self):
""" is sorted by card value by default. This strategy searches for a move by finding the mostly highly
valued card that is a valid move.
# Play anything thats not a wild
for card in
# Skip wilds for now
if card[0] in ["wd4", "w"]:
if self.validate(card, self.current_card):
return card
# Play anything
for card in
if self.validate(card, self.current_card):
return card
# Give up
return None
def play_by_chains(self):
Find all legal permutations starting with the card in play based on our hand. The first card of the chain with
the highest point sum will be selected for play.
start = time.time()
def chain_next(cards, chain):
Given some cards, (Cards == list of card-like data structures)
And given a chain, (chain == list of cards where the last entry is the card we play on top of)
Return a list of all chains formed by appending valid moves to the chain
# Find cards we can legally append to the chain - all wilds and valid moves are accepted
valid_nexts = [card for card in cards if chain[-1][0].startswith("w") or
self.validate(card, chain[-1], cards)]
if valid_nexts:
# If we can make a move, permutate subchains per valid move
subchains = []
for card in valid_nexts:
child_chain = chain[:]
child_cards = cards[:]
child_cards.sort(reverse=True, key=lambda x: x[1]) # Explore high value cards first (this helps?)
assert len(child_cards) == len(cards) - 1
for subchain in chain_next(child_cards, child_chain):
return subchains
# If we can't play, we found the end of a chain. Return the chain
return [chain]
# Get chains with at least one card added by us
chains = [[i, 0] for i in chain_next(, [self.current_card]) if len(i) > 1]
for chain in chains:
for card in chain[0][1:]:
chain[1] += card[1]
chains.sort(key=lambda x: x[1], reverse=True)
# We now have a list of sets like:
# (chain, chain_value)
# Where chain is a list of card structs and chain_value is the point value of that chain.
# The list is sorted by chain_value
# pprint(chains)
end = time.time()"Cards in hand: {}. Considered {} possible outcomes in {}ms..."
.format(len(, len(chains), round((end - start) * 1000, 2)))
if not chains:
return None # No valid moves :(
selected_chain, value = chains[0]
return selected_chain[1]
def join_game(self):
if not self.has_joined:
self.has_joined = True["unochannel"], "jo")
def send_later(self, channel, msg, area):
Thread(target=self._send_later, args=(, (channel, msg, ), area)).start()
def _send_later(self, method, args, kwargs, area):
method(*args, **kwargs)
def sleep(self, area):
if self.config["enable_delays"]:
sleep_min, sleep_max = self.config["delay_{}".format(area)]
sleep_time = randint(sleep_min * 10, sleep_max * 10) / 10
self.log.debug("Sleeping {}s for {}".format(sleep_time, area))
def pickcolor(self):
mycolors = {"r": 0, "g": 0, "b": 0, "y": 0}
for card in
if card[2]["color"] in mycolors.keys():
mycolors[card[2]["color"]] += 1
mycolors = sorted(mycolors.items(), key=lambda x: x[1])
mycolors.reverse()"Color pick weights: %s" % str(mycolors))
self.sleep("beforepickcolor")["unochannel"], "co %s" % mycolors[0][0])
def taketurn(self):
self.shouldgo = False
move = self.getbestmove()
if move is None:
if self.has_drawn:
self.has_drawn = False
self.shouldgo = False
if self.config["enable_delays"]:
time.sleep(self.config["randomhuman_sleep"])["unochannel"], "pa")
self.has_drawn = True
self.shouldgo = True
self.sleep("beforedraw")["unochannel"], "dr")
self.has_drawn = False
if self.config["enable_randomhuman"]:
if randint(1, self.config["randomhuman_chance"]) == 1:["unochannel"], "ct")
if self.config["enable_delays"]:
self.log.debug("playing %s" % move[0])
def getbestmove(self):
Depend inon the set strategy, determine the best card to play
return self.strategies[self.config["strategy"]]()
def validate(self, newcard, basecard, other_cards=None, colors_only=False):
Determine if it is a legal move to play newcard on top of basecard
:param newcard: the card you want to try to play
:param basecard: the card you play on top of
:param other_cards: if WD4 rule enforcement is on, consider these other cards in-hand when selecting a WD4
nc = newcard[2]
bc = basecard[2]
if nc["type"] in ["w"]:
return True
# Wilds can always be played
if nc["type"] in ["wd4"]:
# WD4 can only be played if there are no non-wd4 moves playable
if self.config["enforce_wd4"] and other_cards:
other_valid = any([self.validate(i, basecard) for i in other_cards if i[0] != "wd4"])
if not other_valid:
return True
return True
# Color matches can always be played
if nc["color"] == bc["color"]:
return True
# Matching numbers are ok
if nc["type"] == "num" and bc["type"] == "num" and nc["number"] == bc["number"]:
return True
# type matches can always be played unless its a number
if nc["type"] == bc["type"] and not bc["type"] == "num":
return True
self.log.debug("invalid: %s on %s)" % (nc, bc))
return False
def playcard(self, card):["unochannel"], "pl %s" % card)
def parsecard(self, input):
return self._parsecard(input)
logging.error("Failed to parse card: {}".format(repr(input)))
def _parsecard(self, input):
Given a card PMed to our bot, parse it into a card data structure, e.g.:
('r1',1, {'type': 'num', 'number': 1, 'color': 'r'}),
self.log.debug("Parse %s" % input)
# Colors
colors = {
'r': 'Red',
'y': 'Yellow',
'b': 'Blue',
'g': 'Green'
# cards that don't have a color
uncolored_cards = ['wd4', 'w']
# types of cards
card_types = [
('wd4', 'Draw Four'),
('w', 'WI LD'),
('d2', 'Two'),
('r', 'Reverse'),
('s', 'Skip')
weights = {
'wd4': 30,
'w': 20,
'd2': 15,
'r': 14,
's': 14
card = ""
weight = 0
cardinfo = {"type": None, "color": None, "number": None}
for duo in card_types:
key = duo[0]
value = duo[1]
if value in input:
if key in uncolored_cards:
cardinfo["type"] = key
return (key, weights[key], cardinfo)
card = key
if key in weights:
weight = weights[key]
cardinfo["type"] = card
if card == "":
# If we're here, the card has to be a number
# ghetto parse it
cardnumstr = ""
for i in input:
cardnumstr += str(int(i))
card = cardnumstr
weight = int(cardnumstr)
cardinfo["type"] = "num"
cardinfo["number"] = weight
for color in colors:
if colors[color] in input:
cardinfo["color"] = color
return (color + card, weight, cardinfo)
def stripcolors(self, input):
Strip color codes from an IRC messages
return re.sub(r'\\x0([23])(([0-9]{1,2}((,[0-9]{1,2})?))?)', '', repr(input))[1:-1]
class TestStrategy(UnoPlay):
def __init__(self):
self.current_card = ('y1', 1, {'type': 'num', 'color': 'y', 'number': 1}) = [('wd4', 30, {'type': 'wd4', 'color': None, 'number': None}),
('br', 14, {'type': 'r', 'color': 'b', 'number': None}),
('br', 14, {'type': 'r', 'color': 'b', 'number': None}),
('g1', 1, {'type': 'num', 'color': 'g', 'number': 1}),
('b2', 2, {'type': 'num', 'color': 'b', 'number': 2}),
('r1', 1, {'type': 'num', 'color': 'r', 'number': 1})]
self.log = logging.getLogger('TestLog')
if __name__ == '__main__':
t = TestStrategy()