diff --git a/cardinal-plugin/monop/__init__.py b/cardinal-plugin/monop/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cardinal-plugin/monop/__pycache__/__init__.cpython-310.pyc b/cardinal-plugin/monop/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..57f66fb Binary files /dev/null and b/cardinal-plugin/monop/__pycache__/__init__.cpython-310.pyc differ diff --git a/cardinal-plugin/monop/__pycache__/monop_parser.cpython-310.pyc b/cardinal-plugin/monop/__pycache__/monop_parser.cpython-310.pyc new file mode 100644 index 0000000..aa515f0 Binary files /dev/null and b/cardinal-plugin/monop/__pycache__/monop_parser.cpython-310.pyc differ diff --git a/cardinal-plugin/monop/__pycache__/plugin.cpython-310.pyc b/cardinal-plugin/monop/__pycache__/plugin.cpython-310.pyc new file mode 100644 index 0000000..6eed2a1 Binary files /dev/null and b/cardinal-plugin/monop/__pycache__/plugin.cpython-310.pyc differ diff --git a/cardinal-plugin/monop/config.example.json b/cardinal-plugin/monop/config.example.json new file mode 100644 index 0000000..73ec3e0 --- /dev/null +++ b/cardinal-plugin/monop/config.example.json @@ -0,0 +1,5 @@ +{ + "bot_nick": "monop", + "channels": ["#monop"], + "state_path": "/path/to/game-state.json" +} diff --git a/cardinal-plugin/monop/monop_parser.py b/cardinal-plugin/monop/monop_parser.py new file mode 100644 index 0000000..9b913a4 --- /dev/null +++ b/cardinal-plugin/monop/monop_parser.py @@ -0,0 +1,1191 @@ +""" +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) + 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 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 + 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 + 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 + else: + cp_buy = g.current_player + if cp_buy and g.pending_buy_cost: + cp_buy.money -= g.pending_buy_cost + 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 + for i, p in enumerate(g.players): + if p.name == name: + g.current_player_idx = i + break + g.phase = "playing" + g.game_active = True + 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 + + 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 + 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 + return + + # ===== PASS GO ===== + if self.PASS_GO_RE.match(msg): + if cp: + cp.money += 200 + 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 + 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 + 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: + self._remove_player(cp) + return + + m = self.WINS_RE.match(msg) + if m: + 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 + if g.pending_rent_owner: + owner = g.get_player(name=g.pending_rent_owner) + if owner: + owner.money += amount + 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) + + # 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 + 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 + 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) + return { + "players": [p.to_dict() for p in g.players], + "currentPlayer": g.current_player.number if g.current_player else None, + "squares": squares, + } + + +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 diff --git a/cardinal-plugin/monop/plugin.py b/cardinal-plugin/monop/plugin.py new file mode 100644 index 0000000..922bc99 --- /dev/null +++ b/cardinal-plugin/monop/plugin.py @@ -0,0 +1,216 @@ +""" +Cardinal plugin that watches a monop-irc game and tracks state. + +Listens to all messages in the configured channel, feeds them to +MonopParser, and writes game-state.json on every state change. +Provides commands to query the current game state. +""" + +import json +import os +import logging +from datetime import datetime, timezone + +from cardinal.decorators import command, event, help + +# Import parser from same directory +import os +import importlib.util +_parser_path = os.path.join(os.path.dirname(__file__), "monop_parser.py") +_spec = importlib.util.spec_from_file_location("monop_parser", _parser_path) +_mod = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_mod) +MonopParser = _mod.MonopParser + +logger = logging.getLogger(__name__) + +DEFAULT_STATE_PATH = "game-state.json" +DEFAULT_BOT_NICK = "monop" + + +class MonopPlugin: + def __init__(self, cardinal, config): + self.cardinal = cardinal + self.config = config or {} + + self.bot_nick = self.config.get("bot_nick", DEFAULT_BOT_NICK) + self.state_path = self.config.get( + "state_path", + os.path.join(cardinal.storage_path, DEFAULT_STATE_PATH) + ) + self.watched_channels = self.config.get("channels", []) + + self.parser = MonopParser() + self._last_state_hash = None + + logger.info( + "MonopPlugin loaded: watching %s, bot_nick=%s, state_path=%s", + self.watched_channels, self.bot_nick, self.state_path, + ) + + def _should_watch(self, channel): + """Check if we should watch this channel.""" + if not self.watched_channels: + return True # watch all channels if none configured + return channel.lower() in [c.lower() for c in self.watched_channels] + + def _feed_line(self, sender, channel, message): + """Feed a line to the parser and save state if changed.""" + ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + log_line = f"{ts}\t{sender}\t{message}" + self.parser.parse_line(log_line) + + state = self.parser.get_state() + if state is None: + return + + # Only write if state changed + state_hash = json.dumps(state, sort_keys=True) + if state_hash != self._last_state_hash: + self._last_state_hash = state_hash + state["lastUpdated"] = datetime.now(timezone.utc).isoformat() + self._save_state(state) + + def _save_state(self, state): + """Write game state to JSON file.""" + try: + with open(self.state_path, "w") as f: + json.dump(state, f, indent=2) + logger.debug("State saved to %s", self.state_path) + except Exception: + logger.exception("Failed to save state to %s", self.state_path) + + @event("irc.privmsg") + def on_msg(self, cardinal, user, channel, message): + """Watch all channel messages and feed to parser.""" + if not self._should_watch(channel): + return + + self._feed_line(user.nick, channel, message) + + @command("monop") + @help("Show current monop game state summary.") + @help("Syntax: .monop [player|board|log]") + def monop_cmd(self, cardinal, user, channel, msg): + parts = msg.split() + subcmd = parts[1].lower() if len(parts) > 1 else "status" + + state = self.parser.get_state() + if state is None: + cardinal.sendMsg(channel, "No active monop game being tracked.") + return + + if subcmd == "status": + self._show_status(cardinal, channel, state) + elif subcmd == "player" or subcmd == "players": + self._show_players(cardinal, channel, state) + elif subcmd == "board": + self._show_board(cardinal, channel, state) + elif subcmd == "owned": + self._show_owned(cardinal, channel, state) + else: + cardinal.sendMsg( + channel, + "Usage: .monop [status|players|board|owned]" + ) + + def _show_status(self, cardinal, channel, state): + players = state.get("players", []) + cp = state.get("currentPlayer") + + if not players: + cardinal.sendMsg(channel, "No players registered yet.") + return + + current_name = "?" + for p in players: + if p["number"] == cp: + current_name = p["name"] + + lines = [ + f"Monop game: {len(players)} players, " + f"current turn: {current_name}" + ] + for p in players: + loc_name = self._location_name(state, p["location"]) + jail = " [IN JAIL]" if p.get("inJail") else "" + lines.append( + f" {p['name']} (${p['money']}) on {loc_name}{jail}" + ) + + for line in lines: + cardinal.sendMsg(channel, line) + + def _show_players(self, cardinal, channel, state): + for p in state.get("players", []): + props = [] + for sq in state.get("squares", []): + if sq.get("owner") == p["number"]: + name = sq["name"] + if sq.get("mortgaged"): + name += " [M]" + if sq.get("houses", 0) > 0: + h = sq["houses"] + name += f" [{h}H]" if h < 5 else " [Hotel]" + props.append(name) + + gojf = p.get("getOutOfJailFreeCards", 0) + gojf_str = f", {gojf} GOJF" if gojf else "" + prop_str = ", ".join(props) if props else "none" + + cardinal.sendMsg( + channel, + f"{p['name']} (${p['money']}{gojf_str}): {prop_str}" + ) + + def _show_board(self, cardinal, channel, state): + """Show owned properties summary.""" + self._show_owned(cardinal, channel, state) + + def _show_owned(self, cardinal, channel, state): + """Show all owned properties grouped by player.""" + players = {p["number"]: p["name"] for p in state.get("players", [])} + owned = {} + for sq in state.get("squares", []): + owner = sq.get("owner") + if owner is not None: + owned.setdefault(owner, []).append(sq) + + if not owned: + cardinal.sendMsg(channel, "No properties owned yet.") + return + + for pnum, squares in sorted(owned.items()): + name = players.get(pnum, f"Player {pnum}") + parts = [] + for sq in squares: + s = sq["name"] + if sq.get("mortgaged"): + s += "*" + h = sq.get("houses", 0) + if h == 5: + s += "(H)" + elif h > 0: + s += f"({h})" + parts.append(s) + cardinal.sendMsg(channel, f"{name}: {', '.join(parts)}") + + def _location_name(self, state, loc_id): + """Get square name from location ID.""" + squares = state.get("squares", []) + if 0 <= loc_id < len(squares): + return squares[loc_id]["name"] + elif loc_id == 40: + return "JAIL" + return f"square {loc_id}" + + def close(self): + """Save final state on plugin unload.""" + state = self.parser.get_state() + if state: + state["lastUpdated"] = datetime.now(timezone.utc).isoformat() + self._save_state(state) + logger.info("MonopPlugin unloaded") + + +entrypoint = MonopPlugin diff --git a/cardinal-plugin/test_plugin.py b/cardinal-plugin/test_plugin.py new file mode 100644 index 0000000..c9e236d --- /dev/null +++ b/cardinal-plugin/test_plugin.py @@ -0,0 +1,109 @@ +"""Quick smoke test for the monop Cardinal plugin without running Cardinal.""" +import sys +import os +import json +import tempfile + +# Mock cardinal module before importing plugin +import types +cardinal_mod = types.ModuleType('cardinal') +cardinal_dec = types.ModuleType('cardinal.decorators') +def _passthrough(*args, **kwargs): + def wrap(f): return f + return wrap +cardinal_dec.command = _passthrough +cardinal_dec.event = _passthrough +cardinal_dec.help = _passthrough +cardinal_mod.decorators = cardinal_dec +sys.modules['cardinal'] = cardinal_mod +sys.modules['cardinal.decorators'] = cardinal_dec + +sys.path.insert(0, os.path.dirname(__file__)) + +# Minimal mock of cardinal +class MockCardinal: + storage_path = tempfile.mkdtemp() + def get_db(self, name): pass + def sendMsg(self, channel, msg): print(f" [{channel}] {msg}") + +class MockUser: + def __init__(self, nick): + self.nick = nick + +# Import plugin +from monop import plugin as monop_plugin + +# Instantiate +cardinal = MockCardinal() +config = {"bot_nick": "monop", "channels": ["#monop"]} +p = monop_plugin.MonopPlugin(cardinal, config) + +# Simulate a game +messages = [ + ("monop", "How many players?"), + ("alice", ".3"), + ("monop", "Player 1, say ''me'' please."), + ("alice", ".me"), + ("monop", "Player 2, say ''me'' please."), + ("bob", ".me"), + ("monop", "Player 3, say ''me'' please."), + ("charlie", ".me"), + ("monop", "alice (1) rolls 8"), + ("monop", "bob (2) rolls 5"), + ("monop", "charlie (3) rolls 6"), + ("monop", "alice (1) goes first"), + ("monop", "alice (1) (cash $1500) on === GO ==="), + ("monop", "-- Command: "), + ("alice", "."), + ("monop", "roll is 3, 4"), + ("monop", "That puts you on Chance i"), + ("monop", "bob (2) (cash $1500) on === GO ==="), + ("monop", "-- Command: "), + ("bob", "."), + ("monop", "roll is 2, 4"), + ("monop", "That puts you on Oriental ave. (L)"), + ("monop", "That would cost $100"), + ("monop", "Do you want to buy?"), + ("bob", ".y"), + ("monop", "charlie (3) (cash $1500) on === GO ==="), + ("monop", "-- Command: "), +] + +print("=== Feeding messages ===") +for sender, msg in messages: + user = MockUser(sender) + p.on_msg(cardinal, user, "#monop", msg) + +# Check state +print("\n=== State ===") +state = p.parser.get_state() +if state: + print(f"Players: {len(state['players'])}") + for pl in state['players']: + print(f" {pl['name']} (${pl['money']}) at sq {pl['location']}") + + # Check bob owns Oriental + for sq in state['squares']: + if sq.get('owner'): + print(f" Owned: {sq['name']} by player {sq['owner']}") + + print(f"\n=== .monop status ===") + p.monop_cmd(cardinal, MockUser("test"), "#monop", ".monop") + + print(f"\n=== .monop players ===") + p.monop_cmd(cardinal, MockUser("test"), "#monop", ".monop players") + + print(f"\n=== .monop owned ===") + p.monop_cmd(cardinal, MockUser("test"), "#monop", ".monop owned") +else: + print("No state!") + +# Check file was written +if os.path.exists(p.state_path): + with open(p.state_path) as f: + saved = json.load(f) + print(f"\n=== State file written: {p.state_path} ===") + print(f"Players: {len(saved['players'])}, Squares: {len(saved['squares'])}") + print("PASS") +else: + print("FAIL: state file not written")