""" .. 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 = self.bot.getmodulebyname("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 self.cards = [] @hook("PRIVMSG") def trigger(self, msg, cmd): if msg.trailing.startswith("["): # anti-znc buffer playback return if self.config["enable_trigger"] and "!jo" in msg.trailing: self.bot.act_PRIVMSG(self.config["unochannel"], "jo") elif msg.trailing == "jo": # Reset streak counter & join if another human joins self.games_played = 0 self.join_game() @hook("NOTICE") 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 return if not msg.prefix or self.config["unobot"] not in msg.prefix: return if "You don't have that card" in msg.trailing: self.log.error("played invalid card!") return trailing = self.stripcolors(msg.trailing) cards = [] for carddata in trailing.split(" "): carddata = carddata.strip() cards.append(self.parsecard(carddata)) cards.sort(key=lambda tup: tup[1]) cards.reverse() self.cards = cards self.log.debug(cards) if self.shouldgo: self.shouldgo = False self.taketurn() @hook("PRIVMSG") def unoplay(self, msg, cmd): if msg.trailing.startswith("["): # anti-znc buffer playback return trailing = self.stripcolors(msg.trailing) if self.config["unobot"] not in msg.prefix.nick: return # Parse card from beginning message # See if we play first if "plays first..." in trailing: message = trailing.split("The top card is")[1] self.log.info("we play first!") self.current_card = self.parsecard(message) self.log.debug("top card: %s" % str(self.current_card)) if self.bot.get_nick() in trailing: self.shouldgo = True # We need to choose a color if "hoose a color %s" % self.bot.get_nick() in trailing: self.pickcolor() return if "hoose a color" in trailing and self.bot.get_nick() not in trailing: # Waiting for other guy to choose a color return # See if someone passed to us if "passes" in trailing and self.bot.get_nick() in trailing: self.shouldgo = True # Play after someone was droppped if "continuing with" in trailing and self.bot.get_nick() 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 self.log.info("Color changed to %s " % self.current_card[2]['color']) if "urrent player %s" % self.bot.get_nick() in trailing: self.shouldgo = True return # 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 self.log.info("Color changed to %s " % self.current_card[2]['color']) if self.bot.get_nick() in trailing: self.shouldgo = True return # 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) self.log.info("current card: %s" % str(self.current_card)) # After someone plays to us if "to %s" % self.bot.get_nick() in trailing: self.shouldgo = True # After color change to us if "play continues with %s" % self.bot.get_nick() in trailing: self.shouldgo = True # Reset if " by Marky" in trailing or "cards played in" in trailing: self.log.info("System reset") self.current_card = None self.shouldgo = False self.has_drawn = False self.has_joined = False self.cards = [] if self.config["enable_autojoin"]: if "to join uno" in trailing: if self.games_played >= self.config["streak_max"]: self.games_played = 0 return else: self.join_game() def play_high_value(self): """ Self.cards 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 self.cards: # Skip wilds for now if card[0] in ["wd4", "w"]: continue if self.validate(card, self.current_card): return card # Play anything for card in self.cards: 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_chain.append(card) child_cards = cards[:] child_cards.remove(card) 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): subchains.append(subchain) return subchains else: # 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.cards, [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() self.log.info("Cards in hand: {}. Considered {} possible outcomes in {}ms..." .format(len(self.cards), 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.sleep("joingame") self.has_joined = True self.bot.act_PRIVMSG(self.config["unochannel"], "jo") def send_later(self, channel, msg, area): Thread(target=self._send_later, args=(self.bot.act_PRIVMSG, (channel, msg, ), area)).start() def _send_later(self, method, args, kwargs, area): self.sleep(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)) time.sleep(sleep_time) def pickcolor(self): mycolors = {"r": 0, "g": 0, "b": 0, "y": 0} for card in self.cards: if card[2]["color"] in mycolors.keys(): mycolors[card[2]["color"]] += 1 mycolors = sorted(mycolors.items(), key=lambda x: x[1]) mycolors.reverse() self.log.info("Color pick weights: %s" % str(mycolors)) self.sleep("beforepickcolor") self.bot.act_PRIVMSG(self.config["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"]) self.bot.act_PRIVMSG(self.config["unochannel"], "pa") else: self.has_drawn = True self.shouldgo = True self.sleep("beforedraw") self.bot.act_PRIVMSG(self.config["unochannel"], "dr") return self.has_drawn = False if self.config["enable_randomhuman"]: if randint(1, self.config["randomhuman_chance"]) == 1: self.bot.act_PRIVMSG(self.config["unochannel"], "ct") if self.config["enable_delays"]: time.sleep(self.config["randomhuman_sleep"]) self.sleep("beforemove") self.log.debug("playing %s" % move[0]) self.playcard(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 else: 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): self.bot.act_PRIVMSG(self.config["unochannel"], "pl %s" % card) def parsecard(self, input): try: return self._parsecard(input) except: logging.error("Failed to parse card: {}".format(repr(input))) raise 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) else: card = key if key in weights: weight = weights[key] break cardinfo["type"] = card if card == "": # If we're here, the card has to be a number # ghetto parse it cardnumstr = "" for i in input: try: cardnumstr += str(int(i)) except: pass 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}) self.cards = [('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() pprint(t.play_by_chains())