monop-state/monop_players.py

600 lines
21 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""
Autopilot players for monop-irc.
Spawns one IRC connection per player. Each player watches monop bot output
and responds appropriately to keep the game moving. Players are not
sophisticated they just roll, buy when affordable, and handle prompts.
Usage:
python3 monop_players.py [--host HOST] [--port PORT] [--channel CHAN] [--players alice,bob]
"""
import socket
import threading
import time
import re
import random
import argparse
import sys
HOST = "127.0.0.1"
PORT = 6667
CHANNEL = "#monop"
PREFIX = "."
BOT_NICK = "monop"
# How long to wait before responding (avoids flooding)
RESPONSE_DELAY = 0.8
class PlayerBot:
"""A single IRC-connected monop player."""
def __init__(self, nick, channel, host, port, player_names, player_index):
self.nick = nick
self.channel = channel
self.host = host
self.port = port
self.player_names = player_names
self.player_index = player_index # which player number we are (0-based)
self.num_players = len(player_names)
self.sock = None
self.buffer = ""
self.lock = threading.Lock()
# Game state tracking (minimal — just enough to respond correctly)
self.setup_phase = True
self.setup_registrations_seen = 0
self.current_player = None # whose turn it is
self.my_money = 1500
self.in_jail = False
self.jail_turns = 0
self.in_debt = False
self.in_auction = False
self.auction_bid = 0
self.awaiting_prompt = None # what kind of prompt we're waiting to answer
self.game_started = False
self.game_over = False
self.rolled_this_turn = False
# Properties we own (just names, for mortgage decisions)
self.my_properties = []
self.mortgaged = set()
# Track if we already responded to current prompt
self._prompt_answered = False
# Seen "goes first" yet
self._first_player_announced = False
def log(self, msg):
print(f"[{self.nick}] {msg}", flush=True)
def connect(self):
"""Connect to IRC and join channel."""
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.connect((self.host, self.port))
self._send(f"NICK {self.nick}")
self._send(f"USER {self.nick} 0 * :{self.nick}")
# Wait for registration
self.sock.settimeout(10)
deadline = time.time() + 15
while time.time() < deadline:
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")
# Handle pings during registration
while "\r\n" in self.buffer:
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.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())
def say(self, msg):
"""Send a prefixed message to the game channel."""
self._send(f"PRIVMSG {self.channel} :{PREFIX}{msg}")
self.log(f" -> .{msg}")
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)
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):
return (self.current_player and
self.current_player.lower() == self.nick.lower())
def run(self):
"""Main loop: read IRC messages and respond."""
self.connect()
while not self.game_over:
try:
data = self.sock.recv(4096)
if not data:
break
self.buffer += data.decode("utf-8", errors="replace")
while "\r\n" in self.buffer:
line, self.buffer = self.buffer.split("\r\n", 1)
self._handle_irc_line(line)
except Exception as e:
self.log(f"Error: {e}")
break
self.log("Exiting")
def _handle_irc_line(self, line):
"""Handle a raw IRC line."""
if line.startswith("PING"):
self._send("PONG" + line[4:])
return
# Parse PRIVMSG from monop bot
m = re.match(r':(\S+?)!\S+\s+PRIVMSG\s+(\S+)\s+:(.+)', line)
if not m:
return
sender = m.group(1)
target = m.group(2)
message = m.group(3).strip()
if target.lower() != self.channel.lower():
return
if sender.lower() != BOT_NICK.lower():
return
self._handle_bot_msg(message)
def _handle_bot_msg(self, msg):
"""Process a message from the monop bot."""
if not msg:
return
# ============================================================
# SETUP PHASE
# ============================================================
if self.setup_phase:
if self._handle_setup(msg):
return
# ============================================================
# GAME OVER
# ============================================================
if "WINS!!!!!" in msg or msg == "The party is over.":
self.game_over = True
self.log(f"Game over: {msg}")
return
# ============================================================
# CHECKPOINT LINE: "{name} ({N}) (cash ${M}) on {square}"
# This marks the START of someone's turn.
# ============================================================
m = re.match(
r'^(.+?)\s+\((\d+)\)\s+\(cash\s+\$(-?\d+)\)\s+on\s+(.+)$', msg
)
if m:
name = m.group(1)
money = int(m.group(3))
self.current_player = name
self.in_debt = False
self.awaiting_prompt = None
self._prompt_answered = False
self.rolled_this_turn = False
if name.lower() == self.nick.lower():
self.my_money = money
self.in_jail = False
self.jail_turns = 0
if self.is_my_turn():
self.say_delayed("roll")
self.rolled_this_turn = True
return
# ============================================================
# JAIL DETECTION
# ============================================================
m = re.match(r'^\(This is your (\w+) turn in JAIL\)$', msg)
if m:
if self.is_my_turn():
self.in_jail = True
ordinal = m.group(1)
if ordinal == "3rd":
self.jail_turns = 3
elif ordinal == "2nd":
self.jail_turns = 2
else:
self.jail_turns = 1
# Try rolling doubles
self.say_delayed("roll")
return
if msg == "Double roll gets you out.":
if self.is_my_turn():
self.in_jail = False
return
if msg.startswith("It's your third turn"):
# Forced to pay $50
if self.is_my_turn():
self.in_jail = False
return
# ============================================================
# DOUBLES — ROLL AGAIN
# ============================================================
m = re.match(r'^(.+?) rolled doubles\.\s+Goes again$', msg)
if m:
name = m.group(1)
if name.lower() == self.nick.lower():
self.rolled_this_turn = False
self.say_delayed("roll")
self.rolled_this_turn = True
return
# Triple doubles -> jail
if msg == "That's 3 doubles. You go to jail":
if self.is_my_turn():
self.in_jail = True
return
# ============================================================
# BUY PROPERTY
# ============================================================
if msg == "Do you want to buy?":
if self.is_my_turn():
# Buy if we can afford it (we'll always say yes for simplicity)
self.say_delayed("yes")
return
# Track what we bought
m = re.match(r'^That would cost \$(\d+)$', msg)
if m:
# Just informational
return
# ============================================================
# AUCTION
# ============================================================
if msg.startswith("So it goes up for auction"):
self.in_auction = True
self.auction_bid = 0
return
if self.in_auction:
# Bid prompt: "name:" (just the name followed by colon)
m = re.match(r'^(.+?)\s*:\s*$', msg)
if m:
name = m.group(1).strip()
if name.lower() == self.nick.lower():
if self.auction_bid == 0:
# Open with a small bid
self.say_delayed("1")
elif self.auction_bid < min(200, self.my_money // 2):
bid = self.auction_bid + random.randint(5, 20)
self.say_delayed(str(bid))
else:
# Drop out
self.say_delayed("0")
return
m = re.match(r'^You must bid higher than (\d+)', msg)
if m:
self.auction_bid = int(m.group(1))
return
if msg.startswith("It goes to") or msg.startswith("Nobody seems"):
self.in_auction = False
return
# ============================================================
# YES/NO PROMPTS
# ============================================================
if msg in (
"Do you want to mortgage it?",
"Do you want to unmortgage it?",
"Is that ok?",
):
if self.is_my_turn():
self.say_delayed("yes")
return
# Trade confirmation: "{name}, is the trade ok?"
m = re.match(r'^(.+?), is the trade ok\?$', msg)
if m:
name = m.group(1)
if name.lower() == self.nick.lower():
self.say_delayed("yes")
return
# Resign confirmation
if msg == "Do you really want to resign?":
if self.is_my_turn():
self.say_delayed("yes")
return
if msg.startswith("Who do you wish to resign to?"):
if self.is_my_turn():
self.say_delayed("bank")
return
if msg.startswith("Which player do you wish to trade with?"):
# We don't trade proactively — shouldn't hit this
return
if msg.startswith("Which property do you wish to trade?"):
return
# ============================================================
# DEBT / FORCED MORTGAGE
# ============================================================
if msg == "How are you going to fix it up?":
self.in_debt = True
if self.is_my_turn():
self.say_delayed("mortgage")
return
if msg == "-- You are now Solvent ---":
self.in_debt = False
return
if msg == "that leaves you broke":
return
if msg == "You don't have any un-mortgaged property.":
if self.is_my_turn() and self.in_debt:
self.say_delayed("sell houses")
return
if msg == "You don't have any houses to sell!!":
if self.is_my_turn() and self.in_debt:
self.say_delayed("resign")
return
# Mortgage property selection
if msg == "Which property do you want to mortgage?":
if self.is_my_turn():
if self.in_debt:
self.say_delayed("?") # get list, pick first
else:
self.say_delayed("done") # not in debt, stop mortgaging
return
if msg == "Which property do you want to unmortgage?":
if self.is_my_turn():
self.say_delayed("done") # don't unmortgage proactively
return
# ============================================================
# TAX CHOICE
# ============================================================
# "Do you wish to lose 10% of your total worth or $200? "
if msg.startswith("Do you wish to lose 10"):
if self.is_my_turn():
# 10% is usually cheaper early game
self.say_delayed("10%")
return
# ============================================================
# HOUSE BUYING/SELLING
# ============================================================
# "How many houses do you wish to buy for" / "sell from"
if msg.startswith("How many houses do you wish to"):
# We don't buy/sell houses proactively, but if prompted
# during debt resolution, respond with 0 or done
return
# Property prompt during house buy/sell: "{name} ({n}):" or "{name} (H):"
m = re.match(r'^(.+?)\s+\((?:\d+|H)\)\s*:\s*$', msg)
if m:
if self.is_my_turn():
# Don't buy/sell any houses
self.say_delayed("0")
return
# ============================================================
# COMMAND PROMPT
# ============================================================
if msg == "-- Command:":
if self.is_my_turn():
if self.in_debt:
self.say_delayed("mortgage")
elif not self.rolled_this_turn:
self.say_delayed("roll")
self.rolled_this_turn = True
# else: already rolled, waiting for prompts to resolve
return
# ============================================================
# VALID INPUTS (help/error recovery)
# ============================================================
if msg.startswith("Valid inputs are:"):
if self.is_my_turn():
options_str = msg[len("Valid inputs are:"):].strip()
options = [o.strip().strip("'\"") for o in options_str.split(",")]
self.log(f"Valid options: {options}")
# Pick first sensible option
for opt in options:
opt_l = opt.lower().strip()
if opt_l in ("quit", "save", "restore", ""):
continue
if opt_l == "done":
self.say_delayed("done")
return
# For yes/no, pick yes
if opt_l == "yes":
self.say_delayed("yes")
return
if opt_l == "no":
self.say_delayed("no")
return
# For property/player names, pick first
self.say_delayed(opt)
return
return
# ============================================================
# ILLEGAL ACTION — wrong player tried to act
# ============================================================
m = re.match(
r"^Illegal action: bad player \((.+?)'s turn, not (.+?)\)$", msg
)
if m:
correct = m.group(1)
wrong = m.group(2)
self.current_player = correct
self.log(f"Turn correction: {correct}'s turn (not {wrong})")
# If it's now our turn, roll
if self.is_my_turn() and not self.rolled_this_turn:
self.say_delayed("roll", delay=1.5)
self.rolled_this_turn = True
return
# Illegal response
if msg.startswith('Illegal response:'):
self.log(f"Illegal response error: {msg}")
return
# ============================================================
# OWNERSHIP TRACKING (for mortgage decisions)
# ============================================================
# "That got you $X" (mortgage succeeded)
m = re.match(r'^That got you \$(\d+)$', msg)
if m:
return
# ============================================================
# EVERYTHING ELSE — ignore silently
# ============================================================
# Card text, rent notifications, lucky messages, etc.
# We don't need to respond to these.
def _handle_setup(self, msg):
"""Handle setup phase. Returns True if message was handled."""
if msg == "How many players?":
# Only the first player sends the number
if self.player_index == 0:
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), force=True)
return True
# "Player N, say ''me'' please."
m = re.match(r"^Player (\d+), say ''me'' please\.$", msg)
if m:
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, force=True)
return True
# Duplicate name error
if "the same person" in msg:
return True
# Roll results during setup
m = re.match(r'^(.+?)\s+\((\d+)\)\s+rolls\s+(\d+)$', msg)
if m:
return True
# Re-roll for ties
if "rolled the same thing" in msg:
return True
# Who goes first — end of setup
m = re.match(r'^(.+?)\s+\((\d+)\)\s+goes first$', msg)
if m:
self.setup_phase = False
self.game_started = True
self.current_player = m.group(1)
self.log(f"Game started! {self.current_player} goes first.")
return True
return False
def main():
parser = argparse.ArgumentParser(description="Monop autopilot players")
parser.add_argument("--host", default=HOST)
parser.add_argument("--port", type=int, default=PORT)
parser.add_argument("--channel", default=CHANNEL)
parser.add_argument(
"--players", default="alice,bob",
help="Comma-separated player names"
)
args = parser.parse_args()
player_names = [n.strip() for n in args.players.split(",")]
print(f"Starting {len(player_names)} autopilot players: {player_names}")
print(f"Connecting to {args.host}:{args.port} {args.channel}")
threads = []
bots = []
for i, name in enumerate(player_names):
bot = PlayerBot(
nick=name,
channel=args.channel,
host=args.host,
port=args.port,
player_names=player_names,
player_index=i,
)
bots.append(bot)
t = threading.Thread(target=bot.run, daemon=True, name=f"player-{name}")
threads.append(t)
# Stagger connections slightly to avoid race conditions
for i, t in enumerate(threads):
t.start()
time.sleep(1.0)
# Wait for all to finish (or ctrl-c)
try:
while any(t.is_alive() for t in threads):
time.sleep(1)
except KeyboardInterrupt:
print("\nShutting down...")
for bot in bots:
bot.game_over = True
if __name__ == "__main__":
main()