From e604a3223342faa567a5a178d895b6f036e2bd3b Mon Sep 17 00:00:00 2001 From: Jarvis Date: Sat, 21 Feb 2026 03:50:08 +0000 Subject: [PATCH] Add integration test - parser matches monop state perfectly (7/7 checks pass) --- __pycache__/irc_client.cpython-310.pyc | Bin 0 -> 3179 bytes __pycache__/monop_parser.cpython-310.pyc | Bin 22999 -> 23372 bytes monop_parser.py | 16 + test_integration.py | 444 +++++++++++++++++++++++ 4 files changed, 460 insertions(+) create mode 100644 __pycache__/irc_client.cpython-310.pyc create mode 100644 test_integration.py diff --git a/__pycache__/irc_client.cpython-310.pyc b/__pycache__/irc_client.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2d30224168fa946df73aeedac7d304a8711ada9e GIT binary patch literal 3179 zcmZ`*TW{RP6`mOmce&iv)v_(CX~CjFgDqg$ZqUXNf;fR{3##i?BFS+gv>>P*YE79- zu7}*HB4nVb-ABW31(G7b>ajnezau~%_}Yg;&wX<)>UUokr-?d_!6+m3_wlk3~*AHD4~_wq2yqolKVgwt57 zP33qxm1YaQA%heQ63m;E`Ie<@Re8a1dQ~~7ZBWZqX2I{JsP?M+~bxlpHnHPMk zrWVvCHHVg~E~_gTtE;P!{6}RC5-%HpU)OH^@}0$-_--B57Z_{HF^Q-xke89O_fZ`1 z1Lm^>fs!9sD1~AuEk*sZLv1^+4629Ja?Dsw(QH>S9Bj2pcCT44WfHZYl=5+!<)!SU z8fBKEr?TDO*$H*&bi*tQI$>7U^2a(16qHuV-_aD?kEyEq)J;=;-A@vXHzhU+;~iar zoVrSdgW~T@&!T$m-N?JW8{ITXd)Ko($io|pHzM8khh|&s?PDh&=FEM+hWQySs_?VV zMcd`Y?5W}wN7LIJQ%8xkMFJR&eThcS2FSY{SsaokptW6VAW_=Ll^t=&Ibes8YVPdT z(B~59Ueeq;{_2CqA>+{JU_#N_$-l9$%o@`}`=CeBUNj= z%&UDRp7TEgSg?lM+H8ulk_WmI=4C|%A=FdYt<~lGUSU7nxV!EZ74N3^3va2gy_+|0 zc}L_;O>s2isJyqI6|bl8cqTnBlSCLZ-G4xSe)GgN9*oiH>>$#T;^W1;s3%gPCVx=vltNCxztG zTpkxQXGT8}xJ`B*C2EtW7@N1|+C0+R=P7sN(n^HRJ6w})2~s;tkM{*>y0O$2l(elMO4aV!SF*df1MW_1k4thduCZ2!vKbGJdAt#O063K}-43YHJ1MT5IKqtkrA` zv1WiMb=dw~6T0CCAsGWG>%~!Cx(007UIZfYFs>)5sGFHVuLld3)wPWw)T|-YqzVAg zw`d-*$Qamuf?tv-`#FjV7renG05=c7&2d*;;SQhVZydYd%0D%#7(br;cQH3+XyQEM z2>%~{bOh`{jKW7uk(14bJVPk7T7~`Fwbho_dE2C~?*Ne=)|Ha{7dMZ?us1?uwf#7N zlg1bBQp(*lN`8e^8EJDGf)|r#qS9cSU^q(DJ|#xPnK2;|AjF8ej9DR|^U?b1Cl5AO zyh3V@4l;q0#GK#W#5+W{&Wo5zsyO?17WPukGXEI@A^PJ+&xyh!HX_vkFF=sjG zco#$opmRM>?SXX&AdbWL0B?Yc{{ZyQ#sN)pn!MGN1~nxd8%$}iW4Gfl(4!l@M>mq) ztdm{5P6O2JkD?(>Gd$%0FcRQ#zn4<{;Qk=x^mkGgSI+J_zU*IEhBWuVIfIZfPC4SQ z_@PLJxGk=-XVyvEdcyQ7uFqe+1*O32cdh6AH|!7Bzyb1^jn{w;c37l-M6_Y-v4v5T z00KvzzQLYehN@2gjpx5Rt|**m4-tBHa@J6s&|2Jk1akNKNgl;s;GHPLJhTD??4;vm z{cY%#TI7*|&KAA(Fz@Rm^RN)FuvVIci>=O=Ur_qTon}Q7aEC^zg-IV_0WX4~Wvm|J z%&#$wStfGSPPf-Y?xmyPg+$RMk4g(hyI?a-G`)(oXhlX)kwVgeqHH^ND*9|PwGxHXsi3+GuA9)GW>ef)5B(_7l! z51aOo`8;emj6^_VnuYppbimh)lbvU-Qa2Hf&nb5P>94k#@C&6g2JF5V9nJ{N5#-6lkH(ZT69D*zDWf z_Xwf0X`s~BABC#7v{JAnf;#aZg&lROGydU>tqa^5-To_pUp=id7+me`NK$;!=0B;=vL7wWrnoZUl5X9v@TwaT&&-R=lR9Nyzb=l3WpUI zj}w>6pQx2Xax~6Sb4H#ojV+#8_NbOZ*a5L3x~>v$L!Rc)O@SVW4zb6?ccZthdXnsv zX8F=`UintnQ*|Y}ExQ-7hJg`;^6PrW9MW|zGjbUEAn-WwN#N7KLKHU?bXfw@ZAA$I zkCz3MP?Zu4v4F44$FrrGit+IO$<^A`PuD+2qbNg&!k-2{LvXZop_a!d=#<$gn?bp!ZDMLnHhgY?0QE_SWuXqO)&6?{9b1Y~JQ& z2q)`z0FvAhSc#*iAGY`zNWPqTz~KA^*iS>@Z)@Lz2;KZF9>X1p9eQuPZx^ZfSK&1V zOaY$)&}MR*@LiA|0Awf6LCOK26^END*D$B;P?fo z!>#fDHa*pzgyu^K_+`=1e7g2H#Akue1J47`0VjZy;*I83HX}A{K6jt2$1Jo*fUgmp z05zpsg7R(s@xHeCNn`8eW^>drYnXpS)U-_0xXI4%ACtLv{MNXhPmdY=6Nq+5oNrmj zBH~hu6BzN}*sMUBFSV?ev=7BtwbVX^oTj9)ALEQOqlo>7nG%dnJ` zrr5A&N-45Y*5=u>XG$%q8Kv4oR3*LDo{aC5I^`t|WNm_ORq|G5u6xe1qBrAj@{rDZ zLzfB6Dx?iIc`~8tqDn(rXn2SMdNUzXhNZ5HbYZCr({ML@**-e|D3otLR78dfVpbp|~AbPf6SA1jkHn52uHXjC=k@U1jv($G*tij; zh%x#7L3ec@91a0Dm&ge<<+7GNWR4Y@pBFC-G)}8-Gem~=b8h7ABwazY<;M{%)f+&D<&86pQJNb6{HT$%grrHcs;8(VCht|Hs`I3>c`K)#yd zWg7Fc5L>~@*m8CoqfaIIE4wb2v2vx1DNMVjD4=K_T-l1-T3*?)IKTOGgg*t-Pp}L> z-aDH!_)np7fL{T>0bUlfgSSrq0pf3gUju&x{sjCS_yzDY;Fo}g3j7Vy-wDnwTLyRO z9f`qIdrEIjq;U7~H(~t;tl_SBXOEtY%lq%oP`?8F1$Y(sD`A?y4)HbM4d4p!7Vs|c z9ze}UUlYK8@OTyY7w~W3Z9s+f0wgaaY(nk>{t2D@E|XXAckp-~xCC5Y2VMi-0dUgr zW?-R>q?Cm9sBrE-&W?)0ju%r67D)$}4TRVTpYW@pxcj#~c delta 2855 zcmZ{mYiv|S6vub&KD*nNZKRe8eHaM1BITu(NP(_xq0il=Ewo@^UAmWc%XaTF_d4~#dsI4h!$&HOeH_hpHJOcvshoEcJ*lQ^u& zR@$8CN?YjqpIi;k+LE`5&*B+^7rGhP!lBzj@wgld|0fTL@3Tgbd^;akp`wVG~&Gc>9Qi@7TiYfyBGGA@5xL#Hc8+9C!{un~7sW(E)T8 zAUgRCNDF|iY*j%z>0mnxJX2nP;za<35q+gwT9inP;yCN}YJX)VLTRurg31|~QCk!A z`=Fi8ZWjzEE7|Z#E67u9|D-vi_h5*-%vThw_0|XL{MEIk7`PW|SCO~6I9O8>fM!1& zzRt!>?#$T+aXYXR*ahqWUIKQrOOuO8CmT2A&_q#>E@)Q+Z*u4k-jqfOQm0jWeZ}2D zJ)^{7)6K1F6FtJR3m0b_$&M{*6}i{#>cU_w)GE{U@Yc=_7mgyy>`bBR?$w7d9ZEEM zGptn~a@k`w&V(qVsNhPB=rBy;bPwaLMxDR|9J*C$i}8~7b9p-P@zfJ$8`_ypVqN8# zq?#QqA4c-o>GEtckliZJ$jsx$Wod!X(%e!-jYqUlG3%d3c!Y}Q#(MTWAA z6`ABCX0I$IBNBni2Z`uZqs_jA_XeP+8D`^EEL`MJ^u`LV^Vp4aS)-AdtoB;JMU$6k zx|3%usA$bHRcSk1^xMxRt~+Pu-<-XPD^;h+JdrDxkt}6v0KQMS;coMA3J{ zif*|3eMS;>DL$4z6+3CiBIMEG!^kjoxri&)M8StVNxTc6(lT$&Ci&YlNM5J zT0{7+LFnKQ4&EdrfIvM5!Fp0{jarQ=Yu;G#I(YzVBXfqW}B$h@R9WYeD|a)a14NzYTFa z*(3|GkyI&}*ltOZ?bfpND%=%;5LIQGcW1m$lYQJT?h*lym-f9Z=?0jNbLe&~I}(-Y zCs3UNbl_{?Ti^^!tGjRfHxR!9egMt_p8=l(CxDZH6=k{t=_+fe8`S{PFl=Q0`t4*RJ6r#5&1gQz$1A*% z8+41pZ+`j~#=yVFqC4jE6vspS-m}F;sdO@*9>#zk$MC5q33D*2G;L*9XJs`MbHARH vUiGrHs7j2sVysPD8pp+r&Zi0>ZnU+KY60;d306emrdKFq53FVOcO>;+(mteJ 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)