Add Cardinal player plugin + fix autopilot: force setup responses, suppress out-of-turn rolls, fix tax input, fix double-roll from -- Command handler

This commit is contained in:
Jarvis 2026-02-21 09:17:42 +00:00
parent 5aa96d2163
commit 2ffd7c3845
4 changed files with 409 additions and 12 deletions

View file

@ -0,0 +1 @@
# monop-player Cardinal plugin

View file

@ -0,0 +1,4 @@
{
"channel": "#monop",
"players": ["alice", "bob"]
}

View file

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

View file

@ -77,9 +77,14 @@ class PlayerBot:
self._send(f"USER {self.nick} 0 * :{self.nick}") self._send(f"USER {self.nick} 0 * :{self.nick}")
# Wait for registration # Wait for registration
self.sock.settimeout(10)
deadline = time.time() + 15 deadline = time.time() + 15
while time.time() < deadline: 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: if not data:
raise ConnectionError("Connection closed during registration") raise ConnectionError("Connection closed during registration")
self.buffer += data.decode("utf-8", errors="replace") self.buffer += data.decode("utf-8", errors="replace")
@ -88,14 +93,27 @@ class PlayerBot:
line, self.buffer = self.buffer.split("\r\n", 1) line, self.buffer = self.buffer.split("\r\n", 1)
if line.startswith("PING"): if line.startswith("PING"):
self._send("PONG" + line[4:]) 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: if " 001 " in self.buffer:
break break
self.sock.settimeout(None)
self._send(f"JOIN {self.channel}") self._send(f"JOIN {self.channel}")
time.sleep(0.3) time.sleep(0.5)
self.buffer = "" self.buffer = ""
self.log(f"Connected and joined {self.channel}") 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): def _send(self, line):
with self.lock: with self.lock:
self.sock.sendall((line + "\r\n").encode()) self.sock.sendall((line + "\r\n").encode())
@ -105,12 +123,15 @@ class PlayerBot:
self._send(f"PRIVMSG {self.channel} :{PREFIX}{msg}") self._send(f"PRIVMSG {self.channel} :{PREFIX}{msg}")
self.log(f" -> .{msg}") self.log(f" -> .{msg}")
def say_delayed(self, msg, delay=None): def say_delayed(self, msg, delay=None, force=False):
"""Send after a short delay (avoids flooding, more natural).""" """Send after a short delay. Rechecks is_my_turn() before sending."""
d = delay if delay is not None else RESPONSE_DELAY d = delay if delay is not None else RESPONSE_DELAY
def _do(): def _do():
time.sleep(d) 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() threading.Thread(target=_do, daemon=True).start()
def is_my_turn(self): def is_my_turn(self):
@ -373,7 +394,7 @@ class PlayerBot:
if msg.startswith("Do you wish to lose 10"): if msg.startswith("Do you wish to lose 10"):
if self.is_my_turn(): if self.is_my_turn():
# 10% is usually cheaper early game # 10% is usually cheaper early game
self.say_delayed("percentage") self.say_delayed("10%")
return return
# ============================================================ # ============================================================
@ -403,9 +424,7 @@ class PlayerBot:
elif not self.rolled_this_turn: elif not self.rolled_this_turn:
self.say_delayed("roll") self.say_delayed("roll")
self.rolled_this_turn = True self.rolled_this_turn = True
else: # else: already rolled, waiting for prompts to resolve
# Already rolled, just end turn (empty/done)
self.say_delayed("roll")
return return
# ============================================================ # ============================================================
@ -478,12 +497,12 @@ class PlayerBot:
if msg == "How many players?": if msg == "How many players?":
# Only the first player sends the number # Only the first player sends the number
if self.player_index == 0: if self.player_index == 0:
self.say_delayed(str(self.num_players)) self.say_delayed(str(self.num_players), force=True)
return True return True
if msg.startswith("Sorry. Number must range from"): if msg.startswith("Sorry. Number must range from"):
if self.player_index == 0: if self.player_index == 0:
self.say_delayed(str(self.num_players)) self.say_delayed(str(self.num_players), force=True)
return True return True
# "Player N, say ''me'' please." # "Player N, say ''me'' please."
@ -492,7 +511,7 @@ class PlayerBot:
player_num = int(m.group(1)) player_num = int(m.group(1))
# Player N is 1-indexed, our index is 0-indexed # Player N is 1-indexed, our index is 0-indexed
if player_num - 1 == self.player_index: 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 return True
# Duplicate name error # Duplicate name error