monop-board/bot/parser.py

392 lines
13 KiB
Python

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