""" 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" 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): return { "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, } 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) # 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.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 --$') 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 # 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) 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 for resign target detection if sender != "monop": # Store last user input (strip bot prefix '.') user_msg = message.lstrip('.') if user_msg: self._last_user_input = user_msg 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 checkpoint 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 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)) # Ensure player exists if not g.get_player(name=name): p = Player(name, num) g.players.append(p) 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" - just note it m = self.SAY_ME_RE.match(msg) if m: 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: rent = int(m.group(2)) self._pay_rent(rent) return m = self.RENT_HOTEL_RE.match(msg) if m: rent = int(m.group(1)) 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 elif self._trade_state == 'gives1': self._trade_state = 'gives2' self._trade_player2 = (name, num) self._trade_cash2 = 0 self._trade_gojf2 = 0 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 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() else: self._execute_trade() 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 - remove them if cp: 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 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): """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 g.add_log(f"Trade completed between {p1.name} and {p2.name}") self._trade_state = None self._trade_player1 = None self._trade_player2 = None def _execute_resign_to_player(self): """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 and cp.money > 0: target.money += cp.money if target: target.get_out_of_jail_free_cards += cp.get_out_of_jail_free_cards g.add_log(f"{cp.name} resigned to {target.name if target else 'bank'}", player=cp.name) 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) g.players.remove(player) # Renumber remaining players (C code shifts array) for i, p in enumerate(g.players): p.number = i + 1 # 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 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 return { "players": [p.to_dict() for p in ordered], "currentPlayer": g.current_player.number if g.current_player else None, "squares": squares, "log": g.log[-30:], } 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