monop-state/plugins/monop-player/plugin.py

374 lines
12 KiB
Python
Raw Permalink Normal View History

"""
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