374 lines
12 KiB
Python
374 lines
12 KiB
Python
|
|
"""
|
||
|
|
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
|