""" Cardinal plugin: AI players for monop-irc. Watches monop bot output and responds as players to keep the game moving. Each player simply rolls on their turn, buys properties when they can afford them, answers yes/no prompts, and places minimum auction bids. This is completely independent of the monop state-tracking plugin. """ import re import logging import random from cardinal.decorators import event logger = logging.getLogger(__name__) # The monop bot nick BOT_NICK = "monop" # IRC command prefix PREFIX = "." class MonopPlayerPlugin: """Autopilot players for monop-irc games.""" def __init__(self, cardinal, config): self.cardinal = cardinal self.config = config or {} # Player names this plugin controls (set during game setup) self.controlled_players = set() # Which player's turn it is right now self.current_player = None # Channel the game is in self.channel = self.config.get("channel", "#monop") # Player names to register (config) self.player_names = self.config.get( "players", ["alice", "bob"] ) # Track game phase self.setup_phase = True self.players_registered = 0 self.num_players = len(self.player_names) # Track pending prompts self.pending_prompt = None # Auction state self.in_auction = False self.auction_current_bid = 0 # Track debt state self.in_debt = False # Track whose turn for "bad player" errors self.expected_player = None # Buffer for multi-line card text self.card_separator_count = 0 # Track buy house / sell house prompts self.in_house_prompt = False # Tax choice pending self.tax_choice_pending = False # Trade pending - who needs to confirm self.trade_pending_for = None logger.info( "MonopPlayerPlugin loaded: players=%s, channel=%s", self.player_names, self.channel ) def _say(self, msg): """Send a message to the game channel.""" self.cardinal.sendMsg(self.channel, f"{PREFIX}{msg}") def _player_say(self, player, msg): """Send a message as a specific player (via bot command).""" # In monop-irc bridge, messages are routed by IRC nick. # Since we're a single bot, we use the prefix format. # The bridge maps IRC nick -> player name. # We can't impersonate multiple nicks from one connection. # Instead, we'll just send the command and let the bridge # figure it out. For multi-player, we'd need multiple connections. self._say(msg) @event("irc.privmsg") def on_msg(self, cardinal, user, channel, message): """Process all messages in the game channel.""" if channel.lower() != self.channel.lower(): return sender = user.nick # Only react to monop bot messages if sender.lower() != BOT_NICK.lower(): return self._handle_bot_message(message) def _handle_bot_message(self, msg): """Route a monop bot message to the appropriate handler.""" msg = msg.strip() if not msg: return logger.debug("Bot says: %s", msg) # --- Setup Phase --- if self._handle_setup(msg): return # --- Turn Detection --- # Checkpoint line: "name (N) (cash $M) on square" m = re.match( r'^(.+?)\s+\((\d+)\)\s+\(cash\s+\$(-?\d+)\)\s+on\s+(.+)$', msg ) if m: name = m.group(1) self.current_player = name self.expected_player = name self.in_debt = False self.pending_prompt = None self.in_house_prompt = False self.tax_choice_pending = False # It's this player's turn - they need to roll if name.lower() in {n.lower() for n in self.controlled_players}: self._say("roll") return # --- Doubles: roll again --- m = re.match(r'^(.+?) rolled doubles\.\s+Goes again$', msg) if m: name = m.group(1) if name.lower() in {n.lower() for n in self.controlled_players}: self._say("roll") return # --- Buy Prompt --- if msg == "Do you want to buy?": self.pending_prompt = "buy" # Buy if it's our player if self.current_player and self.current_player.lower() in { n.lower() for n in self.controlled_players }: self._say("yes") return # --- Yes/No Prompts --- if msg in ( "Do you want to mortgage it?", "Do you want to unmortgage it?", "Is that ok?", ): if self.current_player and self.current_player.lower() in { n.lower() for n in self.controlled_players }: self._say("yes") return # --- Trade confirmation --- m = re.match(r'^(.+?), is the trade ok\?$', msg) if m: name = m.group(1) if name.lower() in {n.lower() for n in self.controlled_players}: self._say("yes") return # --- Debt / Forced Mortgage --- if msg == "How are you going to fix it up?": self.in_debt = True if self.current_player and self.current_player.lower() in { n.lower() for n in self.controlled_players }: # Try to mortgage something, or just resign self._say("mortgage") return if "that leaves you broke" in msg.lower(): # Player is bankrupt, nothing to do return if msg == "-- You are now Solvent ---": self.in_debt = False return # --- Mortgage prompts --- if msg.startswith("Which property do you want to mortgage?"): # The bot will list properties; we pick the first one # Actually monop lists them and waits for input via getinp # We'll handle this when we see the property list self.pending_prompt = "choose_mortgage" return if msg.startswith("Which property do you want to unmortgage?"): self.pending_prompt = "choose_unmortgage" return if msg == "You don't have any un-mortgaged property.": if self.in_debt: # Can't mortgage, try selling houses self._say("sell houses") return if msg == "You don't have any houses to sell!!": if self.in_debt: # Can't sell houses either, resign to bank self._say("resign") return # --- Resign confirmation --- if msg == "Do you really want to resign?": if self.current_player and self.current_player.lower() in { n.lower() for n in self.controlled_players }: self._say("yes") return if msg.startswith("Who do you wish to resign to?"): if self.current_player and self.current_player.lower() in { n.lower() for n in self.controlled_players }: self._say("bank") return # --- Auction --- m = re.match(r'^So it goes up for auction', msg) if m: self.in_auction = True self.auction_current_bid = 0 return # Auction bid prompt: "name:" if self.in_auction: m = re.match(r'^(.+?):$', msg) if m: name = m.group(1).strip() if name.lower() in {n.lower() for n in self.controlled_players}: # Bid slightly above current or drop out randomly if self.auction_current_bid == 0: self._say("1") self.auction_current_bid = 1 elif random.random() < 0.3: # Drop out self._say("0") else: bid = self.auction_current_bid + 10 self._say(str(bid)) self.auction_current_bid = bid return m = re.match( r'^You must bid higher than (\d+) to stay in$', msg ) if m: self.auction_current_bid = int(m.group(1)) return if msg.startswith("It goes to") or msg.startswith("Nobody seems"): self.in_auction = False return # --- Jail --- m = re.match(r'^\(This is your (\w+) turn in JAIL\)$', msg) if m: if self.current_player and self.current_player.lower() in { n.lower() for n in self.controlled_players }: # Try to roll doubles to get out self._say("roll") return if msg == "But you're not IN Jail": return # --- Tax choice --- m = re.match(r'^Do you wish to lose 10', msg) if m: self.tax_choice_pending = True if self.current_player and self.current_player.lower() in { n.lower() for n in self.controlled_players }: # Always pick percentage (tends to be cheaper early game) self._say("percentage") return # --- Command prompt (-- Command:) --- if msg == "-- Command:": # Bot is waiting for a command from current player if self.current_player and self.current_player.lower() in { n.lower() for n in self.controlled_players }: if self.in_debt: self._say("mortgage") else: self._say("roll") return # --- House buying/selling prompts --- if msg.startswith("How many houses do you wish to"): self.in_house_prompt = True return # --- Bad player error: someone tried to act out of turn --- m = re.match( r"^Illegal action: bad player \((.+?)'s turn, not (.+?)\)$", msg ) if m: correct = m.group(1) self.current_player = correct return # --- Illegal response: we sent something wrong --- if msg.startswith("Illegal response:"): logger.warning("Got illegal response: %s", msg) return # --- Valid inputs list (help) --- if msg.startswith("Valid inputs are:"): # Parse valid options, pick the first reasonable one options_str = msg[len("Valid inputs are:"):].strip() options = [o.strip().strip("'\"") for o in options_str.split(",")] if options and self.current_player and \ self.current_player.lower() in { n.lower() for n in self.controlled_players}: # Pick first non-quit option for opt in options: if opt.lower() not in ("quit", ""): self._say(opt) break return def _handle_setup(self, msg): """Handle game setup messages. Returns True if handled.""" if not self.setup_phase: return False if msg == "How many players?": self._say(str(self.num_players)) return True if msg.startswith("Sorry. Number must range from"): self._say(str(self.num_players)) return True m = re.match(r"^Player (\d+), say ''me'' please\.$", msg) if m: player_idx = int(m.group(1)) - 1 if player_idx < len(self.player_names): name = self.player_names[player_idx] self.controlled_players.add(name) self._say(name) return True # Player registered and rolling m = re.match(r'^(.+?)\s+\((\d+)\)\s+rolls\s+(\d+)$', msg) if m: return True # Who goes first m = re.match(r'^(.+?)\s+\((\d+)\)\s+goes first$', msg) if m: self.setup_phase = False self.current_player = m.group(1) return True # Re-roll for ties if "rolled the same thing" in msg: return True return False def close(self): logger.info("MonopPlayerPlugin unloaded") entrypoint = MonopPlayerPlugin