diff --git a/__pycache__/irc_client.cpython-310.pyc b/__pycache__/irc_client.cpython-310.pyc new file mode 100644 index 0000000..2d30224 Binary files /dev/null and b/__pycache__/irc_client.cpython-310.pyc differ diff --git a/__pycache__/monop_parser.cpython-310.pyc b/__pycache__/monop_parser.cpython-310.pyc index ae0c738..a3a2d8a 100644 Binary files a/__pycache__/monop_parser.cpython-310.pyc and b/__pycache__/monop_parser.cpython-310.pyc differ diff --git a/monop_parser.py b/monop_parser.py index f53328b..9b913a4 100644 --- a/monop_parser.py +++ b/monop_parser.py @@ -1159,9 +1159,25 @@ class MonopParser: 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, } diff --git a/test_integration.py b/test_integration.py new file mode 100644 index 0000000..16d06f9 --- /dev/null +++ b/test_integration.py @@ -0,0 +1,444 @@ +""" +Integration test: play a real game against monop-irc, periodically dump +state via .print and .own, and compare against the parser's tracked state. +""" + +import sys +import time +import re + +sys.path.insert(0, '.') +from irc_client import IRCClient +from monop_parser import MonopParser + +BOT = 'monop' +CHANNEL = '#monop' + + +def extract_bot_msgs(client, wait=1.5): + """Collect all PRIVMSG lines from monop bot.""" + time.sleep(wait) + lines = [] + for m in client.get_messages(): + if f'PRIVMSG {CHANNEL} :' in m: + sender = m.split('!')[0][1:] + text = m.split(f'PRIVMSG {CHANNEL} :', 1)[1] + lines.append((sender, text)) + return lines + + +def say(client, msg): + """Send a message to the channel.""" + client.say(CHANNEL, msg) + + +def feed_to_parser(parser, lines): + """Feed lines to the parser, return the lines for logging.""" + from datetime import datetime + ts = datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S') + for sender, text in lines: + # Parser expects tab-delimited log format: timestamp\tsender\tmessage + log_line = f"{ts}\t{sender}\t{text}" + parser.parse_line(log_line) + return lines + + +def get_current_player_name(parser): + """Get the name of the current player from parser state.""" + state = parser.get_state() + if not state.get('players'): + return None + cp = state.get('currentPlayer') + if cp is None: + return None + for p in state['players']: + if p['number'] == cp: + return p['name'] + return None + + +def parse_print_output(lines): + """ + Parse the output of .print command into a dict of square states. + Format: 'Name Own Price Mg # Rent' + Each row has two squares side by side separated by 4 spaces. + """ + squares = {} # id -> {owner, mortgaged, houses, rent} + + header_re = re.compile(r'Name\s+Own\s+Price\s+Mg\s+#\s+Rent') + + # Each printsq outputs: %-10.10s then type-specific fields + # Property: ' owner group cost mg houses rent' + # Unowned: ' - group cost ' + # Railroad owned: ' owner Railroad 200 mg count rent' + # Utility owned: ' owner 150 mg count' + + board_names = [ + "=== GO ===", "Mediterran", "Community ", "Baltic ave", + "Income Tax", "Reading RR", "Oriental a", "Chance i", + "Vermont av", "Connecticu", "Just Visit", "St. Charle", + "Electric C", "States ave", "Virginia a", "Pennsylvan", + "St. James ", "Community ", "Tennessee ", "New York a", + "Free Parki", "Kentucky a", "Chance ii", "Indiana av", + "Illinois a", "B&O RR", "Atlantic a", "Ventnor av", + "Water Work", "Marvin Gar", "GO TO JAIL", "Pacific av", + "N. Carolin", "Community ", "Pennsylvan", "Short Line", + "Chance iii", "Park place", "Luxury Tax", "Boardwalk " + ] + + # We'll parse the raw text more carefully + data_lines = [] + for sender, text in lines: + if sender == BOT and not header_re.search(text): + data_lines.append(text) + + # Each data line has two squares: left (chars 0-33) and right (chars 38+) + for idx, line in enumerate(data_lines): + if idx >= 20: + break + left_id = idx + right_id = idx + 20 + + # Parse left half (first ~34 chars) + left = line[:34] if len(line) >= 34 else line + right = line[38:] if len(line) >= 38 else "" + + sq_left = parse_square_str(left, left_id) + if sq_left: + squares[left_id] = sq_left + + sq_right = parse_square_str(right, right_id) + if sq_right: + squares[right_id] = sq_right + + return squares + + +def parse_square_str(s, sq_id): + """Parse a single square string from .print output.""" + if not s or len(s.strip()) == 0: + return None + + result = {'id': sq_id} + + # Property with owner: 'Mediterran 1 Purple 60 0 10' + # Property no owner: 'Mediterran - Purple 60 ' + # Railroad with owner: 'Reading RR 1 Railroad 200 2 100' + # Utility with owner: 'Electric C 1 150 2' + # Safe/CC/Chance: '=== GO === (blanks)' + + # Try to extract owner + m = re.match(r'.{10}\s+(\d+|-)\s+(.+)', s) + if not m: + # Non-ownable square + return {'id': sq_id, 'owner': None} + + owner_str = m.group(1) + rest = m.group(2).strip() + + if owner_str == '-': + result['owner'] = None + else: + result['owner'] = int(owner_str) + + # Try to extract mortgage flag and houses + # Look for ' * ' (mortgaged) or ' ' (not mortgaged) after cost + result['mortgaged'] = ' * ' in s[10:] if len(s) > 10 else False + + # Houses: look for digit before rent at end + # Format after mortgage flag: 'houses rent' e.g. '2 390' or 'H 1500' + tail = s[28:].strip() if len(s) > 28 else '' + if tail: + hm = re.match(r'(\d|H)\s+(\d+)', tail) + if hm: + h = hm.group(1) + result['houses'] = 5 if h == 'H' else int(h) + result['rent'] = int(hm.group(2)) + + return result + + +def parse_own_output(lines): + """ + Parse output of .own command. + Format: + {name}'s ({n}) holdings (Total worth: ${worth}): + ${money}[, N get-out-of-jail-free card[s]] + Name Own Price Mg # Rent + + """ + result = {} + current_player = None + + for sender, text in lines: + if sender != BOT: + continue + + # Holdings header + m = re.match(r"(\S+(?:\s+\S+)*)'s \((\d+)\) holdings \(Total worth: \$(\d+)\):", text) + if m: + current_player = { + 'name': m.group(1), + 'number': int(m.group(2)), + 'totalWorth': int(m.group(3)), + 'money': None, + 'gojf': 0, + 'properties': [] + } + result[current_player['number']] = current_player + continue + + # Money line + if current_player and text.strip().startswith('$'): + mm = re.match(r'\s*\$(\d+)', text) + if mm: + current_player['money'] = int(mm.group(1)) + gm = re.search(r'(\d+) get-out-of-jail-free card', text) + if gm: + current_player['gojf'] = int(gm.group(1)) + continue + + # Property line (starts with spaces then square name) + if current_player and text.startswith(' ') and 'Name' not in text: + current_player['properties'].append(text.strip()) + + return result + + +def compare_states(parser, print_data, own_data, log): + """Compare parser state against .print and .own dumps.""" + state = parser.get_state() + errors = [] + + # Compare square ownership from .print + for sq_id, print_sq in print_data.items(): + if 'owner' not in print_sq: + continue + parser_sq = state['squares'][sq_id] if sq_id < len(state['squares']) else None + if parser_sq is None: + continue + + parser_owner = parser_sq.get('owner') + print_owner = print_sq.get('owner') + + if parser_owner != print_owner: + errors.append( + f"Square {sq_id} ({state['squares'][sq_id]['name']}): " + f"parser owner={parser_owner}, print owner={print_owner}" + ) + + if 'mortgaged' in print_sq and 'mortgaged' in parser_sq: + if parser_sq['mortgaged'] != print_sq['mortgaged']: + errors.append( + f"Square {sq_id}: parser mortgaged={parser_sq['mortgaged']}, " + f"print mortgaged={print_sq['mortgaged']}" + ) + + if 'houses' in print_sq and 'houses' in parser_sq: + if parser_sq['houses'] != print_sq.get('houses', 0): + errors.append( + f"Square {sq_id}: parser houses={parser_sq['houses']}, " + f"print houses={print_sq.get('houses')}" + ) + + # Compare player money/gojf from .own + for pnum, own_p in own_data.items(): + parser_p = None + for p in state['players']: + if p['number'] == pnum: + parser_p = p + break + if parser_p is None: + errors.append(f"Player {pnum} in .own but not in parser") + continue + + if own_p['money'] is not None and parser_p['money'] != own_p['money']: + errors.append( + f"Player {own_p['name']}: parser money=${parser_p['money']}, " + f"own money=${own_p['money']}" + ) + + if parser_p.get('getOutOfJailFreeCards', 0) != own_p['gojf']: + errors.append( + f"Player {own_p['name']}: parser gojf={parser_p.get('getOutOfJailFreeCards', 0)}, " + f"own gojf={own_p['gojf']}" + ) + + if errors: + log.append(f" === MISMATCHES ({len(errors)}) ===") + for e in errors: + log.append(f" {e}") + else: + log.append(f" === ALL MATCH ===") + + return len(errors) + + +def play_turn(player_client, observer, parser, log, auto_buy=True): + """Play one turn for the given player. Returns lines from bot.""" + name = player_client.nick + + # Roll + say(player_client, '.') + lines = extract_bot_msgs(observer, 2) + all_lines = list(lines) + feed_to_parser(parser, lines) + + # Check if we need to respond to a buy prompt + for sender, text in lines: + if text == 'Do you want to buy?': + if auto_buy: + say(player_client, '.y') + else: + say(player_client, '.n') + more = extract_bot_msgs(observer, 1) + all_lines.extend(more) + feed_to_parser(parser, more) + break + + # Log the turn + for sender, text in all_lines: + log.append(f"<{sender}> {text}") + + return all_lines + + +def dump_and_compare(observer, current_player_client, parser, log, turn_num): + """Send .print and .own, parse results, compare to parser state.""" + log.append(f"\n--- State dump after turn {turn_num} ---") + + # .print + say(current_player_client, '.print') + print_lines = extract_bot_msgs(observer, 2) + feed_to_parser(parser, print_lines) + + # .own for each player (use .holdings which shows all) + say(current_player_client, '.holdings') + own_lines = extract_bot_msgs(observer, 2) + feed_to_parser(parser, own_lines) + + print_data = parse_print_output(print_lines) + own_data = parse_own_output(own_lines) + + return compare_states(parser, print_data, own_data, log) + + +def main(): + parser = MonopParser() + log = [] + total_errors = 0 + total_checks = 0 + + # Connect clients + alice = IRCClient('alice') + bob = IRCClient('bob') + charlie = IRCClient('charlie') + observer = IRCClient('observer') # dedicated message collector + + for c in [alice, bob, charlie, observer]: + c.connect() + c.join(CHANNEL) + time.sleep(1) + + players = {'alice': alice, 'bob': bob, 'charlie': charlie} + player_order = [] # filled after setup + + # Collect greeting + lines = extract_bot_msgs(observer, 2) + feed_to_parser(parser, lines) + for _, text in lines: + log.append(f"<{BOT}> {text}") + + # Setup: 3 players + say(alice, '.3') + lines = extract_bot_msgs(observer, 1) + feed_to_parser(parser, lines) + for _, t in lines: + log.append(f"<{BOT}> {t}") + + say(alice, '.me') + lines = extract_bot_msgs(observer, 1) + feed_to_parser(parser, lines) + for _, t in lines: + log.append(f"<{BOT}> {t}") + + say(bob, '.me') + lines = extract_bot_msgs(observer, 1) + feed_to_parser(parser, lines) + for _, t in lines: + log.append(f"<{BOT}> {t}") + + say(charlie, '.me') + lines = extract_bot_msgs(observer, 3) + feed_to_parser(parser, lines) + for _, t in lines: + log.append(f"<{BOT}> {t}") + + # Determine turn order from parser + state = parser.get_state() + if state.get('players'): + for p in state['players']: + player_order.append(p['name'].lower()) + log.append(f"\nTurn order: {player_order}") + + # Play turns with periodic state dumps + num_turns = 30 + turn = 0 + for i in range(num_turns): + if not player_order: + break + + current_name = player_order[turn % len(player_order)] + current_client = players.get(current_name) + if not current_client: + turn += 1 + continue + + log.append(f"\n=== Turn {i+1}: {current_name} ===") + turn_lines = play_turn(current_client, observer, parser, log) + + # Check for doubles (gets another turn) + got_doubles = False + for sender, text in turn_lines: + if 'rolled doubles' in text.lower(): + got_doubles = True + + if not got_doubles: + turn += 1 + + # Dump state every 5 turns + if (i + 1) % 5 == 0: + total_checks += 1 + errs = dump_and_compare(observer, current_client, parser, log, i + 1) + total_errors += errs + + # Final dump + current_name = player_order[turn % len(player_order)] + current_client = players[current_name] + total_checks += 1 + errs = dump_and_compare(observer, current_client, parser, log, num_turns) + total_errors += errs + + # Print results + print("=" * 60) + print("INTEGRATION TEST RESULTS") + print("=" * 60) + for line in log: + print(line) + print("=" * 60) + print(f"\nState comparisons: {total_checks}") + print(f"Total mismatches: {total_errors}") + if total_errors == 0: + print("PASS: Parser state matches monop internal state perfectly") + else: + print(f"FAIL: {total_errors} mismatches found") + + # Cleanup + for c in [alice, bob, charlie, observer]: + c.quit() + + return total_errors + + +if __name__ == '__main__': + sys.exit(0 if main() == 0 else 1)