""" Parser for monop-irc bot output. Tracks game state from IRC log lines and validates against checkpoint lines. """ import re import json import copy from typing import Optional # Board squares - exact names from brd.dat BOARD = [ {"id": 0, "name": "=== GO ===", "type": "safe"}, {"id": 1, "name": "Mediterranean ave. (P)", "type": "property", "group": "purple", "cost": 60}, {"id": 2, "name": "Community Chest i", "type": "cc"}, {"id": 3, "name": "Baltic ave. (P)", "type": "property", "group": "purple", "cost": 60}, {"id": 4, "name": "Income Tax", "type": "tax"}, {"id": 5, "name": "Reading RR", "type": "railroad", "group": "railroad", "cost": 200}, {"id": 6, "name": "Oriental ave. (L)", "type": "property", "group": "lightblue", "cost": 100}, {"id": 7, "name": "Chance i", "type": "chance"}, {"id": 8, "name": "Vermont ave. (L)", "type": "property", "group": "lightblue", "cost": 100}, {"id": 9, "name": "Connecticut ave. (L)", "type": "property", "group": "lightblue", "cost": 120}, {"id": 10, "name": "Just Visiting", "type": "safe"}, {"id": 11, "name": "St. Charles pl. (V)", "type": "property", "group": "violet", "cost": 140}, {"id": 12, "name": "Electric Co.", "type": "utility", "group": "utility", "cost": 150}, {"id": 13, "name": "States ave. (V)", "type": "property", "group": "violet", "cost": 140}, {"id": 14, "name": "Virginia ave. (V)", "type": "property", "group": "violet", "cost": 160}, {"id": 15, "name": "Pennsylvania RR", "type": "railroad", "group": "railroad", "cost": 200}, {"id": 16, "name": "St. James pl. (O)", "type": "property", "group": "orange", "cost": 180}, {"id": 17, "name": "Community Chest ii", "type": "cc"}, {"id": 18, "name": "Tennessee ave. (O)", "type": "property", "group": "orange", "cost": 180}, {"id": 19, "name": "New York ave. (O)", "type": "property", "group": "orange", "cost": 200}, {"id": 20, "name": "Free Parking", "type": "safe"}, {"id": 21, "name": "Kentucky ave. (R)", "type": "property", "group": "red", "cost": 220}, {"id": 22, "name": "Chance ii", "type": "chance"}, {"id": 23, "name": "Indiana ave. (R)", "type": "property", "group": "red", "cost": 220}, {"id": 24, "name": "Illinois ave. (R)", "type": "property", "group": "red", "cost": 240}, {"id": 25, "name": "B&O RR", "type": "railroad", "group": "railroad", "cost": 200}, {"id": 26, "name": "Atlantic ave. (Y)", "type": "property", "group": "yellow", "cost": 260}, {"id": 27, "name": "Ventnor ave. (Y)", "type": "property", "group": "yellow", "cost": 260}, {"id": 28, "name": "Water Works", "type": "utility", "group": "utility", "cost": 150}, {"id": 29, "name": "Marvin Gardens (Y)", "type": "property", "group": "yellow", "cost": 280}, {"id": 30, "name": "GO TO JAIL", "type": "gotojail"}, {"id": 31, "name": "Pacific ave. (G)", "type": "property", "group": "green", "cost": 300}, {"id": 32, "name": "N. Carolina ave. (G)", "type": "property", "group": "green", "cost": 300}, {"id": 33, "name": "Community Chest iii", "type": "cc"}, {"id": 34, "name": "Pennsylvania ave. (G)", "type": "property", "group": "green", "cost": 320}, {"id": 35, "name": "Short Line RR", "type": "railroad", "group": "railroad", "cost": 200}, {"id": 36, "name": "Chance iii", "type": "chance"}, {"id": 37, "name": "Park place (D)", "type": "property", "group": "darkblue", "cost": 350}, {"id": 38, "name": "Luxury Tax", "type": "tax"}, {"id": 39, "name": "Boardwalk (D)", "type": "property", "group": "darkblue", "cost": 400}, ] # Name -> square id lookup SQUARE_BY_NAME = {sq["name"]: sq["id"] for sq in BOARD} SQUARE_BY_NAME["JAIL"] = 40 # Special: in-jail location # Reverse: id -> name SQUARE_NAME_BY_ID = {sq["id"]: sq["name"] for sq in BOARD} SQUARE_NAME_BY_ID[40] = "JAIL" # Truncated name (10 chars, like C's printsq) + cost -> square id # Used to identify properties in trade summary lines _TRUNC_COST_TO_ID = {} _TRUNC_TO_IDS = {} # trunc -> list of sq_ids (for ambiguous names) for _sq in BOARD: if _sq["type"] in ("property", "railroad", "utility"): _trunc = _sq["name"][:10].rstrip() _TRUNC_COST_TO_ID[(_trunc, _sq["cost"])] = _sq["id"] _TRUNC_TO_IDS.setdefault(_trunc, []).append(_sq["id"]) def resolve_trade_property(trunc_name, cost=None): """Resolve a truncated property name (+ optional cost) to a square id.""" if cost is not None: sq_id = _TRUNC_COST_TO_ID.get((trunc_name, cost)) if sq_id is not None: return sq_id ids = _TRUNC_TO_IDS.get(trunc_name, []) if len(ids) == 1: return ids[0] return None class Player: def __init__(self, name, number): self.name = name self.number = number # 1-based self.money = 1500 self.location = 0 self.in_jail = False self.jail_turns = 0 self.doubles_count = 0 self.get_out_of_jail_free_cards = 0 def to_dict(self): d = { "name": self.name, "number": self.number, "money": self.money, "location": self.location, "inJail": self.in_jail, "jailTurns": self.jail_turns, "doublesCount": self.doubles_count, "getOutOfJailFreeCards": self.get_out_of_jail_free_cards, } if getattr(self, 'bankrupt', False): d["bankrupt"] = True return d class GameState: """Tracks the state of a single Monopoly game.""" def __init__(self): self.players = [] self.current_player_idx = 0 # 0-based index into players list self.phase = "setup" # setup, playing, over self.squares = copy.deepcopy(BOARD) self.bankrupt_players = [] # Players who resigned/went bankrupt # Property ownership tracking self.property_owner = {} # square_id -> player_number (1-based) self.property_mortgaged = {} # square_id -> bool self.property_houses = {} # square_id -> int (5 = hotel) # Game log for the web viewer self.log = [] # list of {"timestamp": str, "text": str, "player": str|None} self.last_roll = (0, 0) self.last_roll_total = 0 self.pending_buy_cost = None # cost of property being offered self._buy_pending = False self.in_card = False # inside card separator block self.card_lines = [] self.setup_names = [] self.setup_rolls = [] self.num_players_expected = 0 # from "How many players?" self.game_active = False # Track spec flag (chance card: nearest RR/utility) self.spec = False # Track current player's location before card movement self.pending_rent_owner = None # name of rent owner def add_log(self, text, player=None, timestamp=None): """Append an entry to the game log (kept to last 100 entries).""" entry = {"text": text, "player": player} if timestamp: entry["timestamp"] = timestamp self.log.append(entry) if len(self.log) > 100: self.log = self.log[-100:] def get_player(self, name=None, number=None): """Find player by name or number (1-based).""" for p in self.players: if name is not None and p.name == name: return p if number is not None and p.number == number: return p return None @property def current_player(self): if not self.players or self.current_player_idx >= len(self.players): return None return self.players[self.current_player_idx] def location_name(self, loc): if loc == 40: return "JAIL" return SQUARE_NAME_BY_ID.get(loc, f"Unknown({loc})") def square_id_for_name(self, name): return SQUARE_BY_NAME.get(name) class MonopParser: """Parses monop IRC bot output lines and tracks game state.""" # Regex for checkpoint line: name (number) (cash $money) on square_name CHECKPOINT_RE = re.compile( r'^(.+?) \((\d+)\) \(cash \$(-?\d+)\) on (.+)$' ) ROLL_RE = re.compile(r'^roll is (\d+), (\d+)$') PUTS_ON_RE = re.compile(r'^That puts you on (.+)$') COST_RE = re.compile(r'^That would cost \$(\d+)$') RENT_BASIC_RE = re.compile(r'^rent is (\d+)$') RENT_HOUSES_RE = re.compile(r'^with (\d+) houses, rent is (\d+)$') RENT_HOTEL_RE = re.compile(r'^with a hotel, rent is (\d+)$') RENT_UTIL_10_RE = re.compile(r'^rent is 10 \* roll \((\d+)\) = (\d+)$') RENT_UTIL_4_RE = re.compile(r'^rent is 4 \* roll \((\d+)\) = (\d+)$') OWNED_BY_RE = re.compile(r'^Owned by (.+)$') PASS_GO_RE = re.compile(r'^You pass .+ and get \$200$') DOUBLES_RE = re.compile(r'^(.+) rolled doubles\. Goes again$') TRIPLE_DOUBLES_RE = re.compile(r"^That's 3 doubles\. You go to jail$") PLAYER_ROLLS_RE = re.compile(r'^(.+?) \((\d+)\) rolls (\d+)$') GOES_FIRST_RE = re.compile(r'^(.+?) \((\d+)\) goes first$') HOW_MANY_RE = re.compile(r'^How many players\?') SAY_ME_RE = re.compile(r'^Player (\d+), say .+me.+ please\.') HOLDINGS_RE = re.compile(r"^(.+?)'s \((\d+)\) holdings \(Total worth: \$(\d+)\):") MORTGAGE_GOT_RE = re.compile(r'^That got you \$(\d+)$') UNMORTGAGE_COST_RE = re.compile(r'^That cost you \$(\d+)$') BUY_HOUSES_COST_RE = re.compile(r'^Houses will cost \$(\d+)$') BUY_HOUSES_ASKED_RE = re.compile(r'^You asked for (\d+) houses for \$(\d+)$') SELL_HOUSES_ASKED_RE = re.compile(r'^You asked to sell (\d+) houses for \$(\d+)$') JAIL_PAY_RE = re.compile(r'^That cost you \$50$') JAIL_DOUBLES_RE = re.compile(r'^Double roll gets you out\.$') JAIL_SORRY_RE = re.compile(r"^Sorry, that doesn't get you out$") JAIL_THIRD_RE = re.compile(r"^It's your third turn and you didn't roll doubles\. You have to pay \$50$") JAIL_TURN_RE = re.compile(r'^\(This is your (1st|2nd|3rd \(and final\)) turn in JAIL\)$') LUX_TAX_RE = re.compile(r'^You lose \$75$') INC_TAX_WORTH_RE = re.compile(r'^You were worth \$(\d+)') INC_TAX_PAY_RE = re.compile(r'^You were worth \$(\d+), so you pay \$(\d+)') CARD_SEP_RE = re.compile(r'^-{20,}$') CARD_REPAIR_RE = re.compile(r'^You had (\d+) Houses and (\d+) Hotels, so that cost you \$(\d+)$') AUCTION_GOES_RE = re.compile(r'^It goes to (.+?) \((\d+)\) for \$(\d+)$') RESIGN_TO_PLAYER_RE = re.compile(r'^resigning to player$') RESIGN_TO_BANK_RE = re.compile(r'^resigning to bank$') WINS_RE = re.compile(r'^Then (.+?) WINS!!!!!$') GRAND_WORTH_RE = re.compile(r"^That's a grand worth of \$(\d+)\.$") TRADE_DONE_RE = re.compile(r'^Trade is done!$') TRADE_GIVES_RE = re.compile(r'^Player (.+?) \((\d+)\) gives:$') TRADE_CASH_RE = re.compile(r'^\s+\$(\d+)$') TRADE_GOJF_RE = re.compile(r'^\s+(\d+) get-out-of-jail-free card\(s\)$') TRADE_NOTHING_RE = re.compile(r'^\s+-- Nothing --$') # Trade property line: " NAME OWNER GROUP COST ..." # C's printsq uses %-10.10s for name, then owner+1, group, cost TRADE_PROP_RE = re.compile(r'^\s(.{10})\s+(\d+)\s+\S*\s+(\d+)') SOLVENT_RE = re.compile(r'^-- You are now Solvent ---$') DEBT_RE = re.compile(r'^That leaves you \$(\d+) in debt$') BROKE_RE = re.compile(r'^that leaves you broke$') PARTY_OVER_RE = re.compile(r'^The party is over\.$') BAD_PLAYER_RE = re.compile(r"^Illegal action: bad player \((.+?)'s turn, not (.+?)\)$") NOBODY_RE = re.compile(r"^Nobody seems to want it") def __init__(self): self.game = None self.games = [] self.checkpoints_validated = 0 self.checkpoints_failed = 0 self.checkpoint_errors = [] self.line_num = 0 # State for parsing trade summaries self._trade_state = None # None, 'gives1', 'gives2' self._trade_player1 = None self._trade_player2 = None self._trade_cash1 = 0 self._trade_cash2 = 0 self._trade_gojf1 = 0 self._trade_gojf2 = 0 self._trade_props1 = [] # list of square_ids player1 is giving self._trade_props2 = [] # list of square_ids player2 is giving # State for income tax self._inc_tax_pending = False self._inc_tax_worth = 0 # House buy/sell confirmation pending self._house_buy_pending = None # (count, cost) self._house_sell_pending = None # (count, price) self._resign_pending = False self._resign_target = None self._waiting_resign_target = False self._last_user_input = "" self._last_debt_amount = None # Card context self._in_card_block = False self._card_text = [] # Track if we need to handle card effects after separator self._card_effect_pending = False self._pending_card_lines = [] def _new_game(self): self.game = GameState() self.games.append(self.game) self._awaiting_player_count = True def _handle_setup_input(self, sender, msg, timestamp): """Handle user input during setup phase.""" g = self.game if not g: return # Capture player count (first numeric input after "How many players?") if hasattr(self, '_awaiting_player_count') and self._awaiting_player_count: m = re.match(r'^(\d+)$', msg.strip()) if m: count = int(m.group(1)) if 1 <= count <= 9: g.num_players_expected = count self._awaiting_player_count = False g.add_log(f"Game for {count} players", timestamp=timestamp) return # Name registration — user sends their IRC nick as their name. # In monop-irc, the bridge sends "nick name" to monop stdin, # so the player name matches the IRC sender. name = msg.strip() if name and not name.isdigit() and name == sender: for p in g.players: if p.name.startswith("Player "): p.name = name g.add_log(f"{name} joined!", player=name, timestamp=timestamp) return def parse_line(self, line): """Parse a single IRC log line. Returns any events generated.""" self.line_num += 1 # Parse IRC log format: timestamp\tsender\tmessage # Use maxsplit=2 to preserve tabs in message parts = line.split('\t', 2) if len(parts) < 3: return timestamp = parts[0] sender = parts[1].strip() message_full = parts[2] if len(parts) > 2 else "" # Keep original with leading spaces for indented matching message_raw = message_full.rstrip() message = message_full.strip() # Track user input if sender != "monop": user_msg = message.lstrip('.') if user_msg: self._last_user_input = user_msg # During setup, capture player count and registrations if self.game and self.game.phase == "setup": self._handle_setup_input(sender, user_msg, timestamp) return self._process_bot_line(message, timestamp, message_raw) def _process_bot_line(self, msg, timestamp="", msg_raw=""): """Process a single bot message.""" g = self.game # Resolve pending property buy if g and hasattr(g, '_buy_pending') and g._buy_pending: if msg.startswith("So it goes up for auction"): # Player declined, auction follows g._buy_pending = False elif msg.startswith("Do you want to buy"): pass # re-prompt, still pending else: # Check if this is an informational line is_info = False if re.match(r".+'s \(\d+\) holdings", msg): is_info = True elif msg_raw.lstrip().startswith("$") or msg.startswith("Name"): is_info = True elif msg.startswith("-- Command:"): is_info = True elif msg.startswith("Illegal"): is_info = True elif msg.startswith("Valid inputs"): is_info = True elif re.match(r'^\s', msg_raw) and not self.CHECKPOINT_RE.match(msg): is_info = True elif msg.startswith("Nobody seems"): is_info = False g._buy_pending = False # auction happened, nobody bought if not is_info and g._buy_pending: # Resolve: use checkpoint peek if available bought = False m_ck = self.CHECKPOINT_RE.match(msg) if m_ck: ck_money = int(m_ck.group(3)) ck_name = m_ck.group(1) cp_buy = g.current_player if cp_buy and g.pending_buy_cost: expected = cp_buy.money - g.pending_buy_cost # If this checkpoint is for the buyer, compare directly if ck_name == cp_buy.name: if abs(ck_money - expected) < abs(ck_money - cp_buy.money): cp_buy.money -= g.pending_buy_cost bought = True else: # Checkpoint is for next player; can't peek buyer's money # Use last user input: if "n" or "no", declined if self._last_user_input.lower() in ('n', 'no'): pass # declined else: cp_buy.money -= g.pending_buy_cost bought = True else: cp_buy = g.current_player if cp_buy and g.pending_buy_cost: cp_buy.money -= g.pending_buy_cost bought = True # Assign ownership if purchase confirmed if bought and cp_buy: sq_id = cp_buy.location if 0 <= sq_id < 40: g.property_owner[sq_id] = cp_buy.number g._buy_pending = False g.pending_buy_cost = None # Resolve pending house buy/sell based on next meaningful line if g and (self._house_buy_pending or self._house_sell_pending): if not msg.startswith("Is that ok?"): # Check checkpoint to decide confirmed vs denied m_ck = self.CHECKPOINT_RE.match(msg) if m_ck: # Peek at checkpoint money to decide ck_money = int(m_ck.group(3)) cp_h = g.current_player if g else None if cp_h: if self._house_buy_pending: expected_if_confirmed = cp_h.money - self._house_buy_pending[1] if abs(ck_money - expected_if_confirmed) < abs(ck_money - cp_h.money): cp_h.money -= self._house_buy_pending[1] # else: declined, don't apply elif self._house_sell_pending: expected_if_confirmed = cp_h.money + self._house_sell_pending[1] if abs(ck_money - expected_if_confirmed) < abs(ck_money - cp_h.money): cp_h.money += self._house_sell_pending[1] self._house_buy_pending = None self._house_sell_pending = None else: # Non-checkpoint line (debt msg, solvent, etc.) # Check if debt amount changed (confirmed) or same (denied) m_debt = self.DEBT_RE.match(msg) if m_debt and self._last_debt_amount is not None: new_debt = int(m_debt.group(1)) if new_debt == self._last_debt_amount: # Same debt = denied self._house_buy_pending = None self._house_sell_pending = None else: # Different debt = confirmed cp_h = g.current_player if g else None if self._house_buy_pending and cp_h: cp_h.money -= self._house_buy_pending[1] elif self._house_sell_pending and cp_h: cp_h.money += self._house_sell_pending[1] self._house_buy_pending = None self._house_sell_pending = None elif not m_debt: # No debt message = confirmed (solvent, next action, etc.) cp_h = g.current_player if g else None if self._house_buy_pending and cp_h: cp_h.money -= self._house_buy_pending[1] elif self._house_sell_pending and cp_h: cp_h.money += self._house_sell_pending[1] self._house_buy_pending = None self._house_sell_pending = None # Check for game setup if self.HOW_MANY_RE.match(msg): self._new_game() g = self.game g.phase = "setup" return if g is None: # No game yet - try to pick up from setup or checkpoint m = self.SAY_ME_RE.match(msg) if m: self._new_game() g = self.game g.phase = "setup" num = int(m.group(1)) g.num_players_expected = num p = Player(f"Player {num}", num) g.players.append(p) g.add_log(f"Waiting for Player {num} to register...", timestamp=timestamp) return m = self.CHECKPOINT_RE.match(msg) if m: self._new_game() g = self.game g.phase = "playing" g.game_active = True name, num, money, sq_name = m.group(1), int(m.group(2)), int(m.group(3)), m.group(4) loc = SQUARE_BY_NAME.get(sq_name) if loc is None: return p = Player(name, num) p.money = money p.location = loc if sq_name == "JAIL": p.in_jail = True g.players.append(p) g.current_player_idx = 0 g._first_player_idx = 0 # assume first seen is first self.checkpoints_validated += 1 return # Handle setup phase if g.phase == "setup": m = self.PLAYER_ROLLS_RE.match(msg) if m: name, num, roll_val = m.group(1), int(m.group(2)), int(m.group(3)) existing = g.get_player(number=num) if existing: # Update placeholder name with real name if existing.name.startswith("Player "): existing.name = name g.add_log(f"{name} registered!", player=name, timestamp=timestamp) elif not g.get_player(name=name): p = Player(name, num) g.players.append(p) g.add_log(f"{name} registered!", player=name, timestamp=timestamp) return m = self.GOES_FIRST_RE.match(msg) if m: name, num = m.group(1), int(m.group(2)) if not g.get_player(name=name): p = Player(name, num) g.players.append(p) # Set current player and record turn order start for i, p in enumerate(g.players): if p.name == name: g.current_player_idx = i g._first_player_idx = i break g.phase = "playing" g.game_active = True g.add_log(f"Game started! {name} goes first", timestamp=timestamp) return # "Player N, say 'me' please" - create placeholder m = self.SAY_ME_RE.match(msg) if m: num = int(m.group(1)) if not g.get_player(number=num): p = Player(f"Player {num}", num) g.players.append(p) g.add_log(f"Waiting for Player {num} to register...", timestamp=timestamp) # Update expected count (in case we missed "How many players?") if num > g.num_players_expected: g.num_players_expected = num return return # Playing phase if g.phase == "over" or not g.game_active: # Still check for new game start return cp = g.current_player if cp is None: return # ===== CHECKPOINT LINE ===== m = self.CHECKPOINT_RE.match(msg) if m: name = m.group(1) num = int(m.group(2)) money = int(m.group(3)) sq_name = m.group(4) loc = SQUARE_BY_NAME.get(sq_name) if loc is None: self.checkpoints_failed += 1 self.checkpoint_errors.append( f"Line {self.line_num}: Unknown square '{sq_name}'" ) return g.add_log(f"{name}'s turn — ${money} on {sq_name}", player=name, timestamp=timestamp) player = g.get_player(name=name, number=num) if player is None: # New player we haven't seen (mid-game join) player = Player(name, num) g.players.append(player) player.money = money player.location = loc if sq_name == "JAIL": player.in_jail = True # Set as current for i, p in enumerate(g.players): if p.name == name: g.current_player_idx = i break self.checkpoints_validated += 1 return # Validate tracked state vs checkpoint errors = [] if player.money != money: errors.append(f"money: tracked={player.money} actual={money}") if player.location != loc: errors.append(f"location: tracked={g.location_name(player.location)} actual={sq_name}") if errors: self.checkpoints_failed += 1 self.checkpoint_errors.append( f"Line {self.line_num}: {name} ({num}): {'; '.join(errors)}" ) # Sync to actual values player.money = money player.location = loc else: self.checkpoints_validated += 1 # Update current player to this player for i, p in enumerate(g.players): if p.name == name and p.number == num: g.current_player_idx = i break if sq_name == "JAIL": player.in_jail = True elif player.location == 10: # Just Visiting - not in jail if not player.in_jail: pass # already correct return # ===== ROLL ===== m = self.ROLL_RE.match(msg) if m: d1, d2 = int(m.group(1)), int(m.group(2)) g.last_roll = (d1, d2) g.last_roll_total = d1 + d2 g.add_log(f"roll is {d1}, {d2}", player=cp.name if cp else None, timestamp=timestamp) return # ===== MOVEMENT ===== m = self.PUTS_ON_RE.match(msg) if m: sq_name = m.group(1) loc = SQUARE_BY_NAME.get(sq_name) if loc is not None and cp: cp.location = loc # GO TO JAIL square sends you to jail if loc == 30: # GO TO JAIL cp.location = 40 # JAIL cp.in_jail = True cp.jail_turns = 0 g.add_log(f"Landed on GO TO JAIL!", player=cp.name, timestamp=timestamp) else: g.add_log(f"Landed on {sq_name}", player=cp.name, timestamp=timestamp) return # ===== PASS GO ===== if self.PASS_GO_RE.match(msg): if cp: cp.money += 200 g.add_log("Passed GO — collected $200", player=cp.name, timestamp=timestamp) return # ===== SAFE PLACE ===== if msg == "That is a safe place": return # ===== PROPERTY COST / BUY ===== m = self.COST_RE.match(msg) if m: cost = int(m.group(1)) g.pending_buy_cost = cost return if msg.startswith("Do you want to buy?"): # Track that we're waiting for buy decision g._buy_pending = True return if msg == "You own it.": return # ===== RENT ===== m = self.OWNED_BY_RE.match(msg) if m: g.pending_rent_owner = m.group(1) return # Mortgaged property - lucky, no rent if msg.startswith("The thing is mortgaged."): g.pending_rent_owner = None return m = self.RENT_BASIC_RE.match(msg) if m: rent = int(m.group(1)) self._pay_rent(rent) return m = self.RENT_HOUSES_RE.match(msg) if m: houses = int(m.group(1)) rent = int(m.group(2)) # Update house count for this property (player just landed here) if cp and 0 <= cp.location < 40: g.property_houses[cp.location] = houses self._pay_rent(rent) return m = self.RENT_HOTEL_RE.match(msg) if m: rent = int(m.group(1)) # Hotel = 5 houses if cp and 0 <= cp.location < 40: g.property_houses[cp.location] = 5 self._pay_rent(rent) return m = self.RENT_UTIL_10_RE.match(msg) if m: rent = int(m.group(2)) self._pay_rent(rent) return m = self.RENT_UTIL_4_RE.match(msg) if m: rent = int(m.group(2)) self._pay_rent(rent) return # ===== DOUBLES ===== m = self.DOUBLES_RE.match(msg) if m: return if self.TRIPLE_DOUBLES_RE.match(msg): if cp: cp.location = 40 # JAIL cp.in_jail = True cp.jail_turns = 0 cp.doubles_count = 0 g.add_log("3 doubles — go to jail!", player=cp.name, timestamp=timestamp) return # ===== GO TO JAIL (landing on square) ===== # This is handled by show_move -> goto_jail when landing on GO TO JAIL square # The location is set when "That puts you on GO TO JAIL" is seen, # then goto_jail sets location to JAIL # Actually in the C code, show_move calls goto_jail() which sets loc=JAIL # But we already set location from "That puts you on" line # We need to detect GO TO JAIL landing # Actually the C code: case GOTO_J: goto_jail(); break; # goto_jail sets cur_p->loc = JAIL (40) # So when we see "That puts you on GO TO JAIL", we set loc=30 # Then we need to move to JAIL. But there's no extra output for this. # The next line would be the next player's checkpoint. # So we need to handle this: after "That puts you on GO TO JAIL", set loc=40 (JAIL) # Let me fix the PUTS_ON handler above... no, let me do it here: # We handle it in PUTS_ON by checking if the square is GO TO JAIL # ===== JAIL ===== if msg == "That cost you $50" and cp and cp.in_jail: # Paying to get out of jail cp.money -= 50 cp.location = 10 # Just Visiting cp.in_jail = False cp.jail_turns = 0 return if self.JAIL_DOUBLES_RE.match(msg): if cp: cp.in_jail = False cp.jail_turns = 0 cp.location = 10 # Will move from here return if self.JAIL_SORRY_RE.match(msg): if cp: cp.jail_turns += 1 return if self.JAIL_THIRD_RE.match(msg): if cp: cp.money -= 50 cp.in_jail = False cp.jail_turns = 0 cp.location = 10 return m = self.JAIL_TURN_RE.match(msg) if m: return # ===== TAXES ===== if self.LUX_TAX_RE.match(msg): if cp: cp.money -= 75 return m = self.INC_TAX_PAY_RE.match(msg) if m: worth = int(m.group(1)) pay_amount = int(m.group(2)) if cp: cp.money -= pay_amount self._inc_tax_pending = False return m = self.INC_TAX_WORTH_RE.match(msg) if m: worth = int(m.group(1)) self._inc_tax_worth = worth # Check if this is the "$200" choice line (no "so you pay") if "Good try, but not quite" in msg: # Chose $200 but was worth less if cp: cp.money -= 200 self._inc_tax_pending = False elif "so you pay" not in msg: # Just "You were worth $X" - chose $200 if cp: cp.money -= 200 self._inc_tax_pending = False return # ===== CARDS ===== if self.CARD_SEP_RE.match(msg): if not self._in_card_block: self._in_card_block = True self._card_text = [] else: # End of card block - process card self._in_card_block = False self._process_card(self._card_text) return if self._in_card_block: self._card_text.append(msg_raw) return m = self.CARD_REPAIR_RE.match(msg) if m: houses = int(m.group(1)) hotels = int(m.group(2)) cost = int(m.group(3)) if cp: cp.money -= cost return # ===== MORTGAGE ===== m = self.MORTGAGE_GOT_RE.match(msg) if m: amount = int(m.group(1)) if cp: cp.money += amount return # ===== UNMORTGAGE ===== # "That cost you $X" - but also used for jail pay m = self.UNMORTGAGE_COST_RE.match(msg) if m: amount = int(m.group(1)) if cp and not cp.in_jail: cp.money -= amount elif cp and cp.in_jail and amount == 50: # Jail pay cp.money -= 50 cp.location = 10 cp.in_jail = False cp.jail_turns = 0 else: if cp: cp.money -= amount return # ===== BUY HOUSES ===== m = self.BUY_HOUSES_ASKED_RE.match(msg) if m: count = int(m.group(1)) cost = int(m.group(2)) self._house_buy_pending = (count, cost) return if msg.startswith("Is that ok?"): # Don't apply yet - wait for confirmation via checkpoint or other signal # The "Is that ok?" can be followed by: # - A checkpoint (user said yes, money changed) # - "That leaves you" / "How are you going to fix" (user said no during debt) # - "Houses will" (user said no, starting new sell/buy) # Track both pending amounts - resolved by next meaningful line return m = self.SELL_HOUSES_ASKED_RE.match(msg) if m: count = int(m.group(1)) price = int(m.group(2)) self._house_sell_pending = (count, price) return # ===== AUCTION ===== m = self.AUCTION_GOES_RE.match(msg) if m: name = m.group(1) num = int(m.group(2)) price = int(m.group(3)) buyer = g.get_player(name=name, number=num) if buyer: buyer.money -= price # The auctioned property is at the current player's location cp = g.current_player if cp: sq_id = cp.location if 0 <= sq_id < 40: g.property_owner[sq_id] = num sq_name = g.location_name(sq_id) g.add_log(f"Won auction for {sq_name} at ${price}", player=name, timestamp=timestamp) return if self.NOBODY_RE.match(msg): return # ===== TRADING ===== m = self.TRADE_GIVES_RE.match(msg) if m: name = m.group(1) num = int(m.group(2)) if self._trade_state is None or self._trade_state == 'gives2': # New trade or fresh start self._trade_state = 'gives1' self._trade_player1 = (name, num) self._trade_cash1 = 0 self._trade_gojf1 = 0 self._trade_props1 = [] elif self._trade_state == 'gives1': self._trade_state = 'gives2' self._trade_player2 = (name, num) self._trade_cash2 = 0 self._trade_gojf2 = 0 self._trade_props2 = [] return m = self.TRADE_CASH_RE.match(msg_raw) if m and self._trade_state: cash = int(m.group(1)) if self._trade_state == 'gives1': self._trade_cash1 = cash elif self._trade_state == 'gives2': self._trade_cash2 = cash return m = self.TRADE_GOJF_RE.match(msg_raw) if m and self._trade_state: gojf = int(m.group(1)) if self._trade_state == 'gives1': self._trade_gojf1 = gojf elif self._trade_state == 'gives2': self._trade_gojf2 = gojf return # Trade property line (indented printsq output) if self._trade_state: m_tp = self.TRADE_PROP_RE.match(msg_raw) if m_tp: trunc_name = m_tp.group(1).rstrip() cost = int(m_tp.group(3)) sq_id = resolve_trade_property(trunc_name, cost) if sq_id is not None: if self._trade_state == 'gives1': self._trade_props1.append(sq_id) elif self._trade_state == 'gives2': self._trade_props2.append(sq_id) return if self.TRADE_NOTHING_RE.match(msg_raw) and self._trade_state: return if self.TRADE_DONE_RE.match(msg): if hasattr(self, '_resign_pending') and self._resign_pending: self._execute_resign_to_player(timestamp=timestamp) else: self._execute_trade(timestamp=timestamp) return # ===== RESIGN ===== if self.RESIGN_TO_PLAYER_RE.match(msg): # Track resign pending - will complete on "Trade is done!" self._resign_pending = True return if self.RESIGN_TO_BANK_RE.match(msg): # Player resigns to bank - properties go back to unowned if cp: for sq_id, owner_num in list(g.property_owner.items()): if owner_num == cp.number: del g.property_owner[sq_id] g.property_mortgaged.pop(sq_id, None) g.property_houses.pop(sq_id, None) g.add_log(f"{cp.name} resigned to the bank", player=cp.name, timestamp=timestamp) self._remove_player(cp) return m = self.WINS_RE.match(msg) if m: winner = m.group(1) g.add_log(f"{winner} WINS!", player=winner, timestamp=timestamp) g.phase = "over" g.game_active = False return if self.PARTY_OVER_RE.match(msg): g.phase = "over" g.game_active = False return # ===== SOLVENT ===== if self.SOLVENT_RE.match(msg): self._last_debt_amount = None return m = self.DEBT_RE.match(msg) if m: self._last_debt_amount = int(m.group(1)) return if self.BROKE_RE.match(msg): return # ===== BAD PLAYER ===== m = self.BAD_PLAYER_RE.match(msg) if m: return # ===== Holdings display ===== m = self.HOLDINGS_RE.match(msg) if m: return # ===== Various prompts and info ===== if msg.startswith("-- Command:"): return if msg.startswith("Which property"): return if msg.startswith("How many houses"): return if msg.startswith("Houses will cost"): return if msg.startswith("Houses will get"): return if msg.startswith("Do you want to mortgage"): return if msg.startswith("Do you want to unmortgage"): return if msg.startswith("Your only mort"): return if msg.startswith("Which player"): return if msg.startswith("player "): return if msg.startswith("You have $"): return if msg.startswith("You have "): return if msg == "Who do you wish to resign to?": self._waiting_resign_target = True return if msg.startswith("Who do you wish"): return if msg.startswith("Do you really want to resign"): if hasattr(self, '_waiting_resign_target') and self._waiting_resign_target: self._waiting_resign_target = False # Match last user input against player names if hasattr(self, '_last_user_input') and self._last_user_input and g: inp = self._last_user_input.lower() for p in g.players: if p.name.lower().startswith(inp) and p != cp: self._resign_target = p.name break return if msg.startswith("You would resign to "): target = msg[len("You would resign to "):] if target != "the bank": self._resign_target = target return if msg.startswith("You would resign"): return if msg.startswith("You can't"): return if msg.startswith("But you"): return if msg.startswith("You don't"): return if msg.startswith("Illegal"): return if msg.startswith("Valid inputs"): return if msg.startswith("I can't understand"): return if msg.startswith("So it goes up for auction"): return if msg.startswith("You must bid"): return if msg.startswith("(bid of 0"): return if msg.startswith("There ain't"): return if msg.startswith("That makes the spread"): return if msg.startswith("That's too many"): return if msg.startswith("You've already"): return if msg.startswith("Sorry. Number"): return if msg.startswith("Hey!!!"): return if msg.startswith('"done"'): return if msg.startswith("Which file"): return if msg.startswith("How are you"): return if msg.strip().startswith("$"): # Holdings cash line like " $114" return if msg.strip().startswith("Name"): return if re.match(r'^\s*(Mediterranean|Baltic|Oriental|Vermont|Connecticut|' r'St\. Charles|Electric|States|Virginia|Pennsylvania|' r'St\. James|Tennessee|New York|Kentucky|Indiana|Illinois|' r'B&O|Atlantic|Ventnor|Water|Marvin|Pacific|N\. Carolina|' r'Short Line|Park place|Boardwalk|Reading)', msg): return if msg.strip().startswith("unmortgage"): return # Lucky messages after various events lucky_msgs = [ "You lucky stiff", "You got lucky", "What a lucky person!", "You must have a 4-leaf clover", "My, my!", "Luck smiles upon you", "You got lucky this time", "Lucky person!", "Your karma must certainly", "How beautifully Cosmic", "Wow, you must be really with it", "Good guess.", "It makes no difference!" ] for lm in lucky_msgs: if msg.startswith(lm): return # Auction bid prompts (player names followed by colon) if msg.endswith(":") and not msg.startswith("--"): return # Grand worth if self.GRAND_WORTH_RE.match(msg): return # Trade prompts if msg.endswith("is the trade ok?"): return # people rolled same thing if "rolled the same thing" in msg: return # "Then NOBODY wins" if msg.startswith("Then NOBODY"): g.phase = "over" g.game_active = False return def _pay_rent(self, amount): g = self.game if not g: return cp = g.current_player if not cp: return cp.money -= amount # Pay to owner owner_name = g.pending_rent_owner if owner_name: owner = g.get_player(name=owner_name) if owner: owner.money += amount g.add_log(f"Paid ${amount} rent to {owner_name}", player=cp.name) else: g.add_log(f"Paid ${amount} rent", player=cp.name) g.pending_rent_owner = None g.spec = False # Clear spec flag after rent (matches C's get_card cleanup) def _process_card(self, lines): """Process card text after both separators have been seen.""" g = self.game if not g: return cp = g.current_player if not cp: return text = "\n".join(lines) # Log the card draw (use first non-empty line as summary) card_summary = next((l.strip() for l in lines if l.strip()), "Drew a card") g.add_log(card_summary, player=cp.name) # GET OUT OF JAIL FREE if "GET OUT OF JAIL FREE" in text: cp.get_out_of_jail_free_cards += 1 return # GO TO JAIL if "GO TO JAIL" in text or "GO DIRECTLY TO JAIL" in text: cp.location = 40 cp.in_jail = True cp.jail_turns = 0 return # Money cards # Community Chest if "Receive for Services $25" in text: cp.money += 25 elif "Bank Error in Your Favor" in text: cp.money += 200 elif "Income Tax Refund" in text: cp.money += 20 elif "Pay Hospital $100" in text: cp.money -= 100 elif "Life Insurance Matures" in text: cp.money += 100 elif "From sale of Stock You get $45" in text: cp.money += 45 elif "X-mas Fund Matures" in text: cp.money += 100 elif "You have won Second Prize" in text: cp.money += 11 elif "Advance to GO" in text and "Do not pass GO" not in text: # Advance to GO - collect $200 # Movement will be handled by "That puts you on" / "You pass GO" pass # money handled by pass GO line elif "You inherit $100" in text: cp.money += 100 elif "Pay School Tax of $150" in text: cp.money -= 150 elif "GRAND OPERA OPENING" in text: # Collect $50 from each player num_others = len(g.players) - 1 for p in g.players: if p != cp: p.money -= 50 cp.money += 50 * num_others elif "Doctor's Fee" in text: cp.money -= 50 elif "street repairs" in text or "general repairs" in text: # Cost calculated separately in "You had X Houses..." line pass # Chance cards elif "Pay Poor Tax of $15" in text: cp.money -= 15 elif "Bank pays you Dividend of $50" in text: cp.money += 50 elif "Building and Loan Matures" in text: cp.money += 150 elif "Chairman of the Board" in text: # Pay each player $50 num_others = len(g.players) - 1 for p in g.players: if p != cp: p.money += 50 cp.money -= 50 * num_others elif "Advance to the nearest Railroad" in text: # Movement handled by "That puts you on" # spec=True for double rent g.spec = True elif "Advance to the nearest Utility" in text: g.spec = True elif "Go Back 3 Spaces" in text: pass # Movement handled by "That puts you on" elif "Take a Ride on the Reading" in text: pass # Movement + pass GO handled elif "Take a Walk on the Board Walk" in text: pass # Movement handled elif "Advance to Illinois" in text: pass # Movement handled elif "Advance to Go" in text: pass # Movement handled elif "Advance to St. Charles" in text: pass # Movement handled def _execute_trade(self, timestamp=None): """Execute a completed trade.""" g = self.game if not g: return if self._trade_player1 and self._trade_player2: p1 = g.get_player(name=self._trade_player1[0]) p2 = g.get_player(name=self._trade_player2[0]) if p1 and p2: # p1 gives cash/gojf to p2, p2 gives cash/gojf to p1 p1.money -= self._trade_cash1 p2.money += self._trade_cash1 p2.money -= self._trade_cash2 p1.money += self._trade_cash2 p1.get_out_of_jail_free_cards -= self._trade_gojf1 p2.get_out_of_jail_free_cards += self._trade_gojf1 p2.get_out_of_jail_free_cards -= self._trade_gojf2 p1.get_out_of_jail_free_cards += self._trade_gojf2 # p1 gives properties to p2 for sq_id in self._trade_props1: g.property_owner[sq_id] = p2.number # p2 gives properties to p1 for sq_id in self._trade_props2: g.property_owner[sq_id] = p1.number g.add_log(f"Trade completed between {p1.name} and {p2.name}", timestamp=timestamp) self._trade_state = None self._trade_player1 = None self._trade_player2 = None self._trade_props1 = [] self._trade_props2 = [] def _execute_resign_to_player(self, timestamp=None): """Handle resign-to-player: transfer all assets then remove player.""" g = self.game if not g: return self._resign_pending = False cp = g.current_player if not cp: return # Find resign target target = None if hasattr(self, '_resign_target') and self._resign_target: target = g.get_player(name=self._resign_target) self._resign_target = None if target: # Transfer money if cp.money > 0: target.money += cp.money # Transfer GOJF cards target.get_out_of_jail_free_cards += cp.get_out_of_jail_free_cards # Transfer properties for sq_id, owner_num in list(g.property_owner.items()): if owner_num == cp.number: g.property_owner[sq_id] = target.number g.add_log(f"{cp.name} resigned to {target.name}", player=cp.name, timestamp=timestamp) else: # No target found — treat as bank resignation for sq_id, owner_num in list(g.property_owner.items()): if owner_num == cp.number: del g.property_owner[sq_id] g.property_mortgaged.pop(sq_id, None) g.property_houses.pop(sq_id, None) g.add_log(f"{cp.name} resigned to the bank", player=cp.name, timestamp=timestamp) self._remove_player(cp) def _remove_player(self, player): """Remove a player who resigned. Renumber remaining players like C code.""" g = self.game if not g: return idx = g.players.index(player) player.bankrupt = True g.bankrupt_players.append(player) g.players.remove(player) # Build old->new number mapping before renumbering old_numbers = {p: p.number for p in g.players} # Renumber remaining players (C code shifts array) for i, p in enumerate(g.players): p.number = i + 1 # Update property_owner references to match new numbers number_map = {old_numbers[p]: p.number for p in g.players} for sq_id in list(g.property_owner.keys()): old_num = g.property_owner[sq_id] if old_num in number_map: g.property_owner[sq_id] = number_map[old_num] # C code: player = --player < 0 ? num_play - 1 : player # then next_play() increments to next # After removal, the C code decrements player index then calls next_play # which increments it. Net effect: current becomes the player that was after # the removed one (now at the removed index position) if len(g.players) > 0: # The next player is at position idx (or wrapped) g.current_player_idx = idx % len(g.players) else: g.current_player_idx = 0 def get_state(self): """Return current game state as dict matching game-state.json schema.""" if not self.game: return None g = self.game # During setup, emit partial state so the UI can show registering players if g.phase == "setup": return { "players": [p.to_dict() for p in g.players], "currentPlayer": None, "squares": [{"id": sq["id"], "name": sq["name"], "type": sq["type"]} for sq in g.squares], "log": g.log[-30:], "phase": "setup", "numPlayersExpected": g.num_players_expected, } squares = [] for sq in g.squares: sq_out = {"id": sq["id"], "name": sq["name"], "type": sq["type"]} if sq["type"] in ("property", "railroad", "utility"): sq_out["owner"] = g.property_owner.get(sq["id"]) sq_out["mortgaged"] = g.property_mortgaged.get(sq["id"], False) if "group" in sq: sq_out["group"] = sq["group"] if "cost" in sq: sq_out["cost"] = sq["cost"] if sq["type"] == "property": sq_out["houses"] = g.property_houses.get(sq["id"], 0) if "rent" in sq: sq_out["rent"] = sq["rent"] squares.append(sq_out) # Emit players in turn order (rotated so first player is first) # The C code doesn't reorder the array, it just sets the starting # index. Turn order is: first_player, first_player+1, ..., wrapping. if g.players and hasattr(g, '_first_player_idx'): fp = g._first_player_idx ordered = g.players[fp:] + g.players[:fp] else: ordered = g.players # Append bankrupt players at the end all_players = [p.to_dict() for p in ordered] + [p.to_dict() for p in g.bankrupt_players] result = { "players": all_players, "currentPlayer": g.current_player.number if g.current_player else None, "squares": squares, "log": g.log[-30:], } result["phase"] = g.phase # "playing" or "over" return result def parse_log(filepath): """Parse an entire log file and return the parser with results.""" parser = MonopParser() with open(filepath, 'r') as f: for line in f: line = line.rstrip('\n') parser.parse_line(line) return parser