diff --git a/cardinal-plugin/monop-player/__init__.py b/cardinal-plugin/monop-player/__init__.py new file mode 100644 index 0000000..c560ce7 --- /dev/null +++ b/cardinal-plugin/monop-player/__init__.py @@ -0,0 +1 @@ +# monop-player Cardinal plugin diff --git a/cardinal-plugin/monop-player/config.example.json b/cardinal-plugin/monop-player/config.example.json new file mode 100644 index 0000000..5622c21 --- /dev/null +++ b/cardinal-plugin/monop-player/config.example.json @@ -0,0 +1,4 @@ +{ + "channel": "#monop", + "players": ["alice", "bob"] +} diff --git a/cardinal-plugin/monop-player/plugin.py b/cardinal-plugin/monop-player/plugin.py new file mode 100644 index 0000000..ea964a2 --- /dev/null +++ b/cardinal-plugin/monop-player/plugin.py @@ -0,0 +1,373 @@ +""" +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 diff --git a/monop_players.py b/monop_players.py index 31032b9..8cc5a20 100644 --- a/monop_players.py +++ b/monop_players.py @@ -77,9 +77,14 @@ class PlayerBot: self._send(f"USER {self.nick} 0 * :{self.nick}") # Wait for registration + self.sock.settimeout(10) deadline = time.time() + 15 while time.time() < deadline: - data = self.sock.recv(4096) + try: + data = self.sock.recv(4096) + except socket.timeout: + self.log("Timeout waiting for registration") + break if not data: raise ConnectionError("Connection closed during registration") self.buffer += data.decode("utf-8", errors="replace") @@ -88,14 +93,27 @@ class PlayerBot: line, self.buffer = self.buffer.split("\r\n", 1) if line.startswith("PING"): self._send("PONG" + line[4:]) + if " 433 " in line: + # Nick in use — try with underscore + self.nick = self.nick + "_" + self.log(f"Nick in use, trying {self.nick}") + self._send(f"NICK {self.nick}") if " 001 " in self.buffer: break + self.sock.settimeout(None) self._send(f"JOIN {self.channel}") - time.sleep(0.3) + time.sleep(0.5) self.buffer = "" self.log(f"Connected and joined {self.channel}") + # If we're the first player, send the player count immediately + # in case "How many players?" was sent before we joined + if self.player_index == 0: + time.sleep(1.0) + self.log("Sending player count (in case we missed the prompt)") + self.say(str(self.num_players)) + def _send(self, line): with self.lock: self.sock.sendall((line + "\r\n").encode()) @@ -105,12 +123,15 @@ class PlayerBot: self._send(f"PRIVMSG {self.channel} :{PREFIX}{msg}") self.log(f" -> .{msg}") - def say_delayed(self, msg, delay=None): - """Send after a short delay (avoids flooding, more natural).""" + def say_delayed(self, msg, delay=None, force=False): + """Send after a short delay. Rechecks is_my_turn() before sending.""" d = delay if delay is not None else RESPONSE_DELAY def _do(): time.sleep(d) - self.say(msg) + if force or self.is_my_turn(): + self.say(msg) + else: + self.log(f" (suppressed .{msg} — not my turn)") threading.Thread(target=_do, daemon=True).start() def is_my_turn(self): @@ -373,7 +394,7 @@ class PlayerBot: if msg.startswith("Do you wish to lose 10"): if self.is_my_turn(): # 10% is usually cheaper early game - self.say_delayed("percentage") + self.say_delayed("10%") return # ============================================================ @@ -403,9 +424,7 @@ class PlayerBot: elif not self.rolled_this_turn: self.say_delayed("roll") self.rolled_this_turn = True - else: - # Already rolled, just end turn (empty/done) - self.say_delayed("roll") + # else: already rolled, waiting for prompts to resolve return # ============================================================ @@ -478,12 +497,12 @@ class PlayerBot: if msg == "How many players?": # Only the first player sends the number if self.player_index == 0: - self.say_delayed(str(self.num_players)) + self.say_delayed(str(self.num_players), force=True) return True if msg.startswith("Sorry. Number must range from"): if self.player_index == 0: - self.say_delayed(str(self.num_players)) + self.say_delayed(str(self.num_players), force=True) return True # "Player N, say ''me'' please." @@ -492,7 +511,7 @@ class PlayerBot: player_num = int(m.group(1)) # Player N is 1-indexed, our index is 0-indexed if player_num - 1 == self.player_index: - self.say_delayed(self.nick, delay=1.0) + self.say_delayed(self.nick, delay=1.0, force=True) return True # Duplicate name error