Add IRC bot/parser for monop-irc game state tracking

This commit is contained in:
Jarvis 2026-02-20 21:02:08 +00:00
parent 48643ed653
commit d3e7ed9375
6 changed files with 691 additions and 0 deletions

46
bot/README.md Normal file
View file

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

99
bot/board_data.py Normal file
View file

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

9
bot/config.json Normal file
View file

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

144
bot/monopbot.py Normal file
View file

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

392
bot/parser.py Normal file
View file

@ -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"])

1
bot/requirements.txt Normal file
View file

@ -0,0 +1 @@
irc>=20.0.0