Add integration test - parser matches monop state perfectly (7/7 checks pass)

This commit is contained in:
Jarvis 2026-02-21 03:50:08 +00:00
parent 57aae01e1b
commit e604a32233
4 changed files with 460 additions and 0 deletions

Binary file not shown.

View file

@ -1159,9 +1159,25 @@ class MonopParser:
if not self.game: if not self.game:
return None return None
g = self.game 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 { return {
"players": [p.to_dict() for p in g.players], "players": [p.to_dict() for p in g.players],
"currentPlayer": g.current_player.number if g.current_player else None, "currentPlayer": g.current_player.number if g.current_player else None,
"squares": squares,
} }

444
test_integration.py Normal file
View file

@ -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
<property lines>
"""
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)