From 2b022f15c3b68f7656a1c160f4292455f239e2ba Mon Sep 17 00:00:00 2001 From: Jarvis Date: Sat, 21 Feb 2026 03:54:59 +0000 Subject: [PATCH] Add Cardinal plugin for monop state tracking - Watches channel messages via @event('irc.privmsg') - Feeds to MonopParser, writes game-state.json on state change - Commands: .monop [status|players|board|owned] - Bundled monop_parser.py in plugin dir - Standalone test passes --- cardinal-plugin/monop/__init__.py | 0 .../__pycache__/__init__.cpython-310.pyc | Bin 0 -> 143 bytes .../__pycache__/monop_parser.cpython-310.pyc | Bin 0 -> 23394 bytes .../monop/__pycache__/plugin.cpython-310.pyc | Bin 0 -> 6859 bytes cardinal-plugin/monop/config.example.json | 5 + cardinal-plugin/monop/monop_parser.py | 1191 +++++++++++++++++ cardinal-plugin/monop/plugin.py | 216 +++ cardinal-plugin/test_plugin.py | 109 ++ 8 files changed, 1521 insertions(+) create mode 100644 cardinal-plugin/monop/__init__.py create mode 100644 cardinal-plugin/monop/__pycache__/__init__.cpython-310.pyc create mode 100644 cardinal-plugin/monop/__pycache__/monop_parser.cpython-310.pyc create mode 100644 cardinal-plugin/monop/__pycache__/plugin.cpython-310.pyc create mode 100644 cardinal-plugin/monop/config.example.json create mode 100644 cardinal-plugin/monop/monop_parser.py create mode 100644 cardinal-plugin/monop/plugin.py create mode 100644 cardinal-plugin/test_plugin.py 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 0000000000000000000000000000000000000000..57f66fb90f10bca448e098678cbbf67f8362674f GIT binary patch literal 143 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 G@&5oP*vL!( literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6eed2a10f52ced65c0a2201f0c726a0878f98532 GIT binary patch literal 6859 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 Yp6!_{*yRfTaB^!gfM(V<{>EGXf7D;nDF6Tf literal 0 HcmV?d00001 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")