Add IRC bot/parser for monop-irc game state tracking
This commit is contained in:
parent
48643ed653
commit
d3e7ed9375
6 changed files with 691 additions and 0 deletions
46
bot/README.md
Normal file
46
bot/README.md
Normal 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
99
bot/board_data.py
Normal 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
9
bot/config.json
Normal 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
144
bot/monopbot.py
Normal 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
392
bot/parser.py
Normal 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
1
bot/requirements.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
irc>=20.0.0
|
||||
Loading…
Reference in a new issue