From d3e7ed93759b012bcf654e7dd1eb69dad5d6bcc8 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Fri, 20 Feb 2026 21:02:08 +0000 Subject: [PATCH] Add IRC bot/parser for monop-irc game state tracking --- bot/README.md | 46 +++++ bot/board_data.py | 99 +++++++++++ bot/config.json | 9 + bot/monopbot.py | 144 ++++++++++++++++ bot/parser.py | 392 +++++++++++++++++++++++++++++++++++++++++++ bot/requirements.txt | 1 + 6 files changed, 691 insertions(+) create mode 100644 bot/README.md create mode 100644 bot/board_data.py create mode 100644 bot/config.json create mode 100644 bot/monopbot.py create mode 100644 bot/parser.py create mode 100644 bot/requirements.txt diff --git a/bot/README.md b/bot/README.md new file mode 100644 index 0000000..87cdd9e --- /dev/null +++ b/bot/README.md @@ -0,0 +1,46 @@ +# monop-board IRC Bot + +Watches an IRC channel for monop-irc game output and writes `game-state.json`. + +## Setup + +```bash +pip install -r requirements.txt +``` + +## Configuration + +Edit `config.json`: + +```json +{ + "server": "irc.darkscience.net", + "port": 6697, + "tls": true, + "nick": "monopbot", + "channel": "#your-channel", + "game_nick": "monop", + "state_file": "../game-state.json" +} +``` + +- `game_nick` — the IRC nick of the monop-irc game process +- `state_file` — where to write the JSON game state (relative to bot dir) + +## Usage + +```bash +python3 monopbot.py +``` + +Or with a custom config: + +```bash +MONOPBOT_CONFIG=/path/to/config.json python3 monopbot.py +``` + +The bot will: +1. Connect to IRC and join the configured channel +2. Watch for messages from the game bot +3. Parse game events (rolls, moves, purchases, rent, etc.) +4. Write updated state to `game-state.json` after each change diff --git a/bot/board_data.py b/bot/board_data.py new file mode 100644 index 0000000..d6d8c2f --- /dev/null +++ b/bot/board_data.py @@ -0,0 +1,99 @@ +"""Static Monopoly board data — all 40 squares.""" + +SQUARES = [ + {"id": 0, "name": "Go", "type": "safe", "cost": 0, "group": None}, + {"id": 1, "name": "Mediterranean Ave.", "type": "property", "cost": 60, "group": "purple", + "rent": [2, 10, 30, 90, 160, 250]}, + {"id": 2, "name": "Community Chest", "type": "cc", "cost": 0, "group": None}, + {"id": 3, "name": "Baltic Ave.", "type": "property", "cost": 60, "group": "purple", + "rent": [4, 20, 60, 180, 320, 450]}, + {"id": 4, "name": "Income Tax", "type": "tax", "cost": 0, "group": None}, + {"id": 5, "name": "Reading Railroad", "type": "railroad", "cost": 200, "group": "railroad"}, + {"id": 6, "name": "Oriental Ave.", "type": "property", "cost": 100, "group": "lightblue", + "rent": [6, 30, 90, 270, 400, 550]}, + {"id": 7, "name": "Chance", "type": "chance", "cost": 0, "group": None}, + {"id": 8, "name": "Vermont Ave.", "type": "property", "cost": 100, "group": "lightblue", + "rent": [6, 30, 90, 270, 400, 550]}, + {"id": 9, "name": "Connecticut Ave.", "type": "property", "cost": 120, "group": "lightblue", + "rent": [8, 40, 100, 300, 450, 600]}, + {"id": 10, "name": "Just Visiting", "type": "jail", "cost": 0, "group": None}, + {"id": 11, "name": "St. Charles Place", "type": "property", "cost": 140, "group": "pink", + "rent": [10, 50, 150, 450, 625, 750]}, + {"id": 12, "name": "Electric Company", "type": "utility", "cost": 150, "group": "utility"}, + {"id": 13, "name": "States Ave.", "type": "property", "cost": 140, "group": "pink", + "rent": [10, 50, 150, 450, 625, 750]}, + {"id": 14, "name": "Virginia Ave.", "type": "property", "cost": 160, "group": "pink", + "rent": [12, 60, 180, 500, 700, 900]}, + {"id": 15, "name": "Pennsylvania Railroad", "type": "railroad", "cost": 200, "group": "railroad"}, + {"id": 16, "name": "St. James Place", "type": "property", "cost": 180, "group": "orange", + "rent": [14, 70, 200, 550, 750, 950]}, + {"id": 17, "name": "Community Chest", "type": "cc", "cost": 0, "group": None}, + {"id": 18, "name": "Tennessee Ave.", "type": "property", "cost": 180, "group": "orange", + "rent": [14, 70, 200, 550, 750, 950]}, + {"id": 19, "name": "New York Ave.", "type": "property", "cost": 200, "group": "orange", + "rent": [16, 80, 220, 600, 800, 1000]}, + {"id": 20, "name": "Free Parking", "type": "safe", "cost": 0, "group": None}, + {"id": 21, "name": "Kentucky Ave.", "type": "property", "cost": 220, "group": "red", + "rent": [18, 90, 250, 700, 875, 1050]}, + {"id": 22, "name": "Chance", "type": "chance", "cost": 0, "group": None}, + {"id": 23, "name": "Indiana Ave.", "type": "property", "cost": 220, "group": "red", + "rent": [18, 90, 250, 700, 875, 1050]}, + {"id": 24, "name": "Illinois Ave.", "type": "property", "cost": 240, "group": "red", + "rent": [20, 100, 300, 750, 925, 1100]}, + {"id": 25, "name": "B&O Railroad", "type": "railroad", "cost": 200, "group": "railroad"}, + {"id": 26, "name": "Atlantic Ave.", "type": "property", "cost": 260, "group": "yellow", + "rent": [22, 110, 330, 800, 975, 1150]}, + {"id": 27, "name": "Ventnor Ave.", "type": "property", "cost": 260, "group": "yellow", + "rent": [22, 110, 330, 800, 975, 1150]}, + {"id": 28, "name": "Water Works", "type": "utility", "cost": 150, "group": "utility"}, + {"id": 29, "name": "Marvin Gardens", "type": "property", "cost": 280, "group": "yellow", + "rent": [24, 120, 360, 850, 1025, 1200]}, + {"id": 30, "name": "Go to Jail", "type": "gotojail", "cost": 0, "group": None}, + {"id": 31, "name": "Pacific Ave.", "type": "property", "cost": 300, "group": "green", + "rent": [26, 130, 390, 900, 1100, 1275]}, + {"id": 32, "name": "North Carolina Ave.", "type": "property", "cost": 300, "group": "green", + "rent": [26, 130, 390, 900, 1100, 1275]}, + {"id": 33, "name": "Community Chest", "type": "cc", "cost": 0, "group": None}, + {"id": 34, "name": "Pennsylvania Ave.", "type": "property", "cost": 320, "group": "green", + "rent": [28, 150, 450, 1000, 1200, 1400]}, + {"id": 35, "name": "Short Line Railroad", "type": "railroad", "cost": 200, "group": "railroad"}, + {"id": 36, "name": "Chance", "type": "chance", "cost": 0, "group": None}, + {"id": 37, "name": "Park Place", "type": "property", "cost": 350, "group": "darkblue", + "rent": [35, 175, 500, 1100, 1300, 1500]}, + {"id": 38, "name": "Luxury Tax", "type": "tax", "cost": 0, "group": None}, + {"id": 39, "name": "Boardwalk", "type": "property", "cost": 400, "group": "darkblue", + "rent": [50, 200, 600, 1400, 1700, 2000]}, +] + +# Map square names (lowercase, stripped) to square IDs for lookup +NAME_TO_ID = {} +for sq in SQUARES: + # The game uses short names like "Mediterranean" not "Mediterranean Ave." + # Build multiple lookup keys + name = sq["name"].lower() + NAME_TO_ID[name] = sq["id"] + # Also without trailing punctuation + NAME_TO_ID[name.rstrip(".")] = sq["id"] + # Also first word for some + parts = name.split() + if len(parts) > 1 and sq["type"] in ("property", "railroad", "utility"): + NAME_TO_ID[parts[0]] = sq["id"] + +# The monop game uses these exact short names (from bsdgames source): +MONOP_NAMES = { + "go": 0, "mediterranean": 1, "community chest": 2, "baltic": 3, + "income tax": 4, "reading": 5, "oriental": 6, "chance": 7, + "vermont": 8, "connecticut": 9, "just visiting": 10, "jail": 10, + "st. charles": 11, "electric co.": 12, "electric company": 12, + "states": 13, "virginia": 14, "pennsylvania rr": 15, + "pennsylvania railroad": 15, "st. james": 16, + "community chest ": 17, "tennessee": 18, "new york": 19, + "free parking": 20, "kentucky": 21, "chance ": 22, + "indiana": 23, "illinois": 24, "b&o": 25, "b&o railroad": 25, + "atlantic": 26, "ventnor": 27, "water works": 28, + "marvin gardens": 29, "go to jail": 30, "pacific": 31, + "north carolina": 32, "community chest ": 33, + "pennsylvania": 34, "short line": 35, "short line railroad": 35, + "chance ": 36, "park place": 37, "luxury tax": 38, "boardwalk": 39, +} +NAME_TO_ID.update(MONOP_NAMES) diff --git a/bot/config.json b/bot/config.json new file mode 100644 index 0000000..f216aff --- /dev/null +++ b/bot/config.json @@ -0,0 +1,9 @@ +{ + "server": "irc.darkscience.net", + "port": 6697, + "tls": true, + "nick": "monopbot", + "channel": "#monop-test", + "game_nick": "monop", + "state_file": "../game-state.json" +} diff --git a/bot/monopbot.py b/bot/monopbot.py new file mode 100644 index 0000000..c04cf06 --- /dev/null +++ b/bot/monopbot.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""IRC bot that watches monop-irc games and writes game state to JSON.""" + +import json +import os +import re +import ssl +import sys +import time +import irc.client +import irc.connection +from datetime import datetime, timezone +from parser import MonopParser + +CONFIG_FILE = os.environ.get("MONOPBOT_CONFIG", + os.path.join(os.path.dirname(__file__), "config.json")) + + +def load_config(): + with open(CONFIG_FILE) as f: + return json.load(f) + + +class MonopBot: + def __init__(self, config): + self.config = config + self.parser = MonopParser() + self.state_file = config.get("state_file", "../game-state.json") + if not os.path.isabs(self.state_file): + self.state_file = os.path.join(os.path.dirname(__file__), self.state_file) + self.game_nick = config.get("game_nick", "monop").lower() + self._last_write = 0 + + # Track player setup + self._awaiting_player_count = False + self._awaiting_player_name = False + self._expected_players = 0 + self._setup_done = False + + def write_state(self): + """Write current game state to JSON file.""" + now = time.time() + # Rate limit writes to every 0.5s + if now - self._last_write < 0.5: + return + self._last_write = now + + state = self.parser.get_state() + state["lastUpdated"] = datetime.now(timezone.utc).isoformat() + + tmp = self.state_file + ".tmp" + with open(tmp, "w") as f: + json.dump(state, f, indent=2) + os.replace(tmp, self.state_file) + + def on_connect(self, connection, event): + channel = self.config["channel"] + print(f"Connected. Joining {channel}") + connection.join(channel) + + def on_join(self, connection, event): + print(f"Joined {event.target}") + + def on_pubmsg(self, connection, event): + """Handle public messages in channel.""" + nick = event.source.nick.lower() if event.source else "" + msg = event.arguments[0] if event.arguments else "" + + # We care about messages from the game bot + if nick == self.game_nick or self._is_game_output(nick, msg): + self._process_game_line(msg) + + def on_privmsg(self, connection, event): + """Handle private messages (game might DM).""" + nick = event.source.nick.lower() if event.source else "" + msg = event.arguments[0] if event.arguments else "" + if nick == self.game_nick: + self._process_game_line(msg) + + def _is_game_output(self, nick, msg): + """Heuristic: detect if a message looks like monop output.""" + game_patterns = [ + r"roll is \d+, \d+", + r"That puts you on", + r"That would cost \$", + r"rent is \d+", + r"Name\s+Own\s+Price", + r"'s \(\d+\) holdings", + ] + for pat in game_patterns: + if re.search(pat, msg): + return True + return False + + def _process_game_line(self, line): + """Process a line of game output.""" + # Handle multi-line messages (some IRC clients split them) + for subline in line.split("\n"): + changed = self.parser.parse_line(subline) + if changed: + self.write_state() + + def run(self): + config = self.config + reactor = irc.client.Reactor() + + try: + connect_params = {} + if config.get("tls", False): + ssl_factory = irc.connection.Factory(wrapper=ssl.wrap_socket) + connect_params["connect_factory"] = ssl_factory + + server = reactor.server() + server.connect( + config["server"], + config["port"], + config["nick"], + **connect_params, + ) + except irc.client.ServerConnectionError as e: + print(f"Connection error: {e}") + sys.exit(1) + + server.add_global_handler("welcome", self.on_connect) + server.add_global_handler("join", self.on_join) + server.add_global_handler("pubmsg", self.on_pubmsg) + server.add_global_handler("privmsg", self.on_privmsg) + + print(f"Connecting to {config['server']}:{config['port']}...") + + # Write initial empty state + self.write_state() + + reactor.process_forever() + + +def main(): + config = load_config() + bot = MonopBot(config) + bot.run() + + +if __name__ == "__main__": + main() diff --git a/bot/parser.py b/bot/parser.py new file mode 100644 index 0000000..5d6ae86 --- /dev/null +++ b/bot/parser.py @@ -0,0 +1,392 @@ +"""Parser for monop-irc game output messages.""" + +import re +from board_data import NAME_TO_ID, SQUARES + + +def find_square_id(name): + """Look up a square ID from a name string.""" + name = name.strip().lower() + if name in NAME_TO_ID: + return NAME_TO_ID[name] + # Try partial match + for key, sid in NAME_TO_ID.items(): + if name in key or key in name: + return sid + return None + + +class MonopParser: + """Parses monop-irc game messages and updates game state.""" + + def __init__(self): + self.reset() + + def reset(self): + self.players = [] + self.current_player_idx = 0 + self.squares = [self._init_square(sq) for sq in SQUARES] + self.log = [] + self.game_started = False + self._parsing_holdings = False + self._holdings_player = None + self._parsing_board = False + self._awaiting_buy = False + self._last_roll = None + + def _init_square(self, sq): + return { + "id": sq["id"], + "name": sq["name"], + "type": sq["type"], + "owner": -1, + "cost": sq["cost"], + "mortgaged": False, + "houses": 0, + "monopoly": False, + "group": sq.get("group"), + "rent": sq.get("rent", [0]), + } + + def get_state(self): + return { + "players": [ + { + "name": p["name"], + "number": p["number"], + "money": p["money"], + "location": p["location"], + "inJail": p["in_jail"], + "jailTurns": p["jail_turns"], + "goJailFreeCards": p["gojf"], + "properties": p["properties"], + "numRailroads": p["num_rr"], + "numUtilities": p["num_util"], + } + for p in self.players + ], + "currentPlayer": self.current_player_idx, + "squares": self.squares, + "log": self.log[-100:], # keep last 100 log entries + } + + def _cur_player(self): + if self.players: + return self.players[self.current_player_idx] + return None + + def _find_player_by_name(self, name): + for i, p in enumerate(self.players): + if p["name"].lower() == name.lower(): + return i, p + return None, None + + def _add_log(self, text, player=None): + from datetime import datetime, timezone + self.log.append({ + "timestamp": datetime.now(timezone.utc).isoformat(), + "text": text, + "player": player, + }) + + def add_player(self, name, number): + self.players.append({ + "name": name, + "number": number, + "money": 1500, + "location": 0, + "in_jail": False, + "jail_turns": 0, + "gojf": 0, + "properties": [], + "num_rr": 0, + "num_util": 0, + }) + self._add_log(f"{name} joined as player {number}", name) + + def parse_line(self, line): + """Parse a single line of monop-irc output. Returns True if state changed.""" + line = line.strip() + if not line: + return False + + changed = False + + # --- Player setup --- + # "How many players? " -> game init + m = re.match(r"How many players\?", line) + if m: + self.reset() + self.game_started = True + return True + + # Player name prompt: "Player N's name:" + m = re.match(r"Player (\d+)'s name:", line) + if m: + return False # the actual name comes as input + + # --- Roll --- + m = re.match(r"roll is (\d+), (\d+)", line) + if m: + d1, d2 = int(m.group(1)), int(m.group(2)) + self._last_roll = (d1, d2) + cp = self._cur_player() + if cp: + self._add_log(f"roll is {d1}, {d2}", cp["name"]) + return True + + # --- Movement --- + m = re.match(r"That puts you on (.+)", line) + if m: + sq_name = m.group(1) + sq_id = find_square_id(sq_name) + cp = self._cur_player() + if cp and sq_id is not None: + cp["location"] = sq_id + self._add_log(f"Landed on {sq_name}", cp["name"]) + changed = True + return changed + + # --- Pass Go --- + m = re.match(r"You pass .+ and get \$200", line) + if m: + cp = self._cur_player() + if cp: + cp["money"] += 200 + self._add_log("Passed Go, collected $200", cp["name"]) + return True + + # --- Doubles --- + m = re.match(r"(.+) rolled doubles\.\s+Goes again", line) + if m: + self._add_log(f"{m.group(1)} rolled doubles", m.group(1)) + return True + + # --- 3 doubles -> jail --- + m = re.match(r"That's 3 doubles\.\s+You go to jail", line) + if m: + cp = self._cur_player() + if cp: + cp["location"] = 10 + cp["in_jail"] = True + cp["jail_turns"] = 0 + self._add_log("3 doubles! Go to jail", cp["name"]) + return True + + # --- Go to jail (from square) --- + if line == "Go directly to Jail": + cp = self._cur_player() + if cp: + cp["location"] = 10 + cp["in_jail"] = True + cp["jail_turns"] = 0 + self._add_log("Go to Jail!", cp["name"]) + return True + + # --- Property cost --- + m = re.match(r"That would cost \$(\d+)", line) + if m: + self._awaiting_buy = True + return False + + # --- Buy prompt --- + m = re.match(r"Do you want to buy\?", line) + if m: + self._awaiting_buy = True + return False + + # --- Rent --- + m = re.match(r"Owned by (.+)", line) + if m: + return False # rent line follows + + m = re.match(r"rent is (\d+)", line) + if m: + rent_amt = int(m.group(1)) + cp = self._cur_player() + if cp: + cp["money"] -= rent_amt + sq_id = cp["location"] + owner_idx = self.squares[sq_id]["owner"] + if 0 <= owner_idx < len(self.players): + self.players[owner_idx]["money"] += rent_amt + self._add_log(f"Paid ${rent_amt} rent to {self.players[owner_idx]['name']}", cp["name"]) + return True + + m = re.match(r"with (\d+) houses?, rent is (\d+)", line) + if m: + rent_amt = int(m.group(2)) + cp = self._cur_player() + if cp: + cp["money"] -= rent_amt + sq_id = cp["location"] + owner_idx = self.squares[sq_id]["owner"] + if 0 <= owner_idx < len(self.players): + self.players[owner_idx]["money"] += rent_amt + self._add_log(f"Paid ${rent_amt} rent ({m.group(1)} houses)", cp["name"]) + return True + + m = re.match(r"with a hotel, rent is (\d+)", line) + if m: + rent_amt = int(m.group(1)) + cp = self._cur_player() + if cp: + cp["money"] -= rent_amt + sq_id = cp["location"] + owner_idx = self.squares[sq_id]["owner"] + if 0 <= owner_idx < len(self.players): + self.players[owner_idx]["money"] += rent_amt + self._add_log(f"Paid ${rent_amt} rent (hotel)", cp["name"]) + return True + + # Utility rent: "rent is 4 * roll (N) = N" or "rent is 10 * roll (N) = N" + m = re.match(r"rent is (\d+) \* roll \((\d+)\) = (\d+)", line) + if m: + rent_amt = int(m.group(3)) + cp = self._cur_player() + if cp: + cp["money"] -= rent_amt + sq_id = cp["location"] + owner_idx = self.squares[sq_id]["owner"] + if 0 <= owner_idx < len(self.players): + self.players[owner_idx]["money"] += rent_amt + self._add_log(f"Paid ${rent_amt} utility rent", cp["name"]) + return True + + # --- Safe place --- + if line == "That is a safe place": + return False + + # --- Income tax --- + m = re.match(r"You pay \$(\d+)", line) + if m: + cp = self._cur_player() + if cp: + cp["money"] -= int(m.group(1)) + self._add_log(f"Paid ${m.group(1)} tax", cp["name"]) + return True + + # --- Luxury tax --- + if "Luxury tax" in line and "$75" in line: + cp = self._cur_player() + if cp: + cp["money"] -= 75 + self._add_log("Paid $75 luxury tax", cp["name"]) + return True + + # --- You own it --- + if line == "You own it.": + return False + + # --- Holdings --- + m = re.match(r"(.+)'s \((\d+)\) holdings \(Total worth: \$(\d+)\):", line) + if m: + name = m.group(1) + self._parsing_holdings = True + idx, player = self._find_player_by_name(name) + self._holdings_player = idx + return False + + # Holdings cash line + if self._parsing_holdings and self._holdings_player is not None: + m = re.match(r"\s+\$(\d+)", line) + if m: + self.players[self._holdings_player]["money"] = int(m.group(1)) + # Check for GOJF cards + gojf_m = re.search(r"(\d+) get-out-of-jail-free card", line) + if gojf_m: + self.players[self._holdings_player]["gojf"] = int(gojf_m.group(1)) + return True + + # End holdings parsing on blank or non-indented line + if self._parsing_holdings and not line.startswith(" ") and not line.startswith("Name"): + self._parsing_holdings = False + self._holdings_player = None + + # --- Board header --- + if "Name Own Price Mg # Rent" in line: + self._parsing_board = True + return False + + # --- Mortgage --- + m = re.match(r"(.+) is mortgaged", line) + if m: + sq_id = find_square_id(m.group(1)) + if sq_id is not None: + self.squares[sq_id]["mortgaged"] = True + self._add_log(f"{m.group(1)} mortgaged", self._cur_player()["name"] if self._cur_player() else None) + return True + + m = re.match(r"(.+) is unmortgaged", line) + if m: + sq_id = find_square_id(m.group(1)) + if sq_id is not None: + self.squares[sq_id]["mortgaged"] = False + return True + + # --- Houses bought/sold --- + m = re.match(r"(\d+) house(?:s)? (?:bought|added) (?:on|to) (.+)", line) + if m: + num = int(m.group(1)) + sq_id = find_square_id(m.group(2)) + if sq_id is not None: + self.squares[sq_id]["houses"] += num + self._add_log(f"{num} house(s) added to {m.group(2)}") + return True + + # --- Next player turn --- + m = re.match(r"(.+)'s turn", line) + if m: + name = m.group(1) + idx, _ = self._find_player_by_name(name) + if idx is not None: + self.current_player_idx = idx + self._add_log(f"{name}'s turn", name) + return True + + # --- Player leaves jail --- + if "out of jail" in line.lower(): + cp = self._cur_player() + if cp: + cp["in_jail"] = False + cp["jail_turns"] = 0 + self._add_log("Got out of jail", cp["name"]) + return True + + # --- Property bought (inferred from game flow) --- + # When a player buys, the game deducts money. We track via + # the "That would cost $N" + purchase flow. The game prints + # ownership changes in the board/holdings output. + + # --- Debt message --- + m = re.match(r"That leaves you \$(\d+) in debt", line) + if m: + cp = self._cur_player() + if cp: + cp["money"] = -int(m.group(1)) + return True + + if line == "that leaves you broke": + cp = self._cur_player() + if cp: + cp["money"] = 0 + return True + + # --- Solvent --- + if "You are now Solvent" in line: + return False + + return changed + + def process_buy(self, player_idx, square_id): + """Record a property purchase.""" + p = self.players[player_idx] + sq = self.squares[square_id] + sq["owner"] = player_idx + p["properties"].append(square_id) + p["money"] -= sq["cost"] + if sq["type"] == "railroad": + p["num_rr"] += 1 + elif sq["type"] == "utility": + p["num_util"] += 1 + self._add_log(f"Bought {sq['name']} for ${sq['cost']}", p["name"]) diff --git a/bot/requirements.txt b/bot/requirements.txt new file mode 100644 index 0000000..805c740 --- /dev/null +++ b/bot/requirements.txt @@ -0,0 +1 @@ +irc>=20.0.0