"""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 detection from status lines --- # "Alice (1) (cash $1500) on === GO ===" or "Alice (1) rolls 10" m = re.match(r"(\w+) \((\d+)\) (?:rolls \d+|\(cash \$(\d+)\) on (.+))", line) if m: name = m.group(1) num = int(m.group(2)) idx, player = self._find_player_by_name(name) if idx is None: # Auto-add player self.add_player(name, num) idx = len(self.players) - 1 player = self.players[idx] if m.group(3): player["money"] = int(m.group(3)) self.current_player_idx = idx self._add_log(f"{name}'s turn", name) return True # --- 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 and line.strip()[0] in "$0123456789": 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"])