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:
parent
5aa96d2163
commit
2ffd7c3845
4 changed files with 409 additions and 12 deletions
1
cardinal-plugin/monop-player/__init__.py
Normal file
1
cardinal-plugin/monop-player/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
# monop-player Cardinal plugin
|
||||||
4
cardinal-plugin/monop-player/config.example.json
Normal file
4
cardinal-plugin/monop-player/config.example.json
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"channel": "#monop",
|
||||||
|
"players": ["alice", "bob"]
|
||||||
|
}
|
||||||
373
cardinal-plugin/monop-player/plugin.py
Normal file
373
cardinal-plugin/monop-player/plugin.py
Normal 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
|
||||||
|
|
@ -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:
|
||||||
|
try:
|
||||||
data = self.sock.recv(4096)
|
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)
|
||||||
|
if force or self.is_my_turn():
|
||||||
self.say(msg)
|
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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue