monop-state/plugins/monop/monop_parser.py

1294 lines
51 KiB
Python
Raw Normal View History

"""
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)
# Game log for the web viewer
self.log = [] # list of {"timestamp": str, "text": str, "player": str|None}
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.num_players_expected = 0 # from "How many players?"
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 add_log(self, text, player=None, timestamp=None):
"""Append an entry to the game log (kept to last 100 entries)."""
entry = {"text": text, "player": player}
if timestamp:
entry["timestamp"] = timestamp
self.log.append(entry)
if len(self.log) > 100:
self.log = self.log[-100:]
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)
self._awaiting_player_count = True
def _handle_setup_input(self, sender, msg, timestamp):
"""Handle user input during setup phase."""
g = self.game
if not g:
return
# Capture player count (first numeric input after "How many players?")
if hasattr(self, '_awaiting_player_count') and self._awaiting_player_count:
m = re.match(r'^(\d+)$', msg.strip())
if m:
count = int(m.group(1))
if 1 <= count <= 9:
g.num_players_expected = count
self._awaiting_player_count = False
g.add_log(f"Game for {count} players", timestamp=timestamp)
return
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
if sender != "monop":
user_msg = message.lstrip('.')
if user_msg:
self._last_user_input = user_msg
# During setup, capture player count and registrations
if self.game and self.game.phase == "setup":
self._handle_setup_input(sender, user_msg, timestamp)
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
bought = False
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
bought = True
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
bought = True
else:
cp_buy = g.current_player
if cp_buy and g.pending_buy_cost:
cp_buy.money -= g.pending_buy_cost
bought = True
# Assign ownership if purchase confirmed
if bought and cp_buy:
sq_id = cp_buy.location
if 0 <= sq_id < 40:
g.property_owner[sq_id] = cp_buy.number
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))
existing = g.get_player(number=num)
if existing:
# Update placeholder name with real name
if existing.name.startswith("Player "):
existing.name = name
g.add_log(f"{name} registered!", player=name, timestamp=timestamp)
elif not g.get_player(name=name):
p = Player(name, num)
g.players.append(p)
g.add_log(f"{name} registered!", player=name, timestamp=timestamp)
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 and record turn order start
for i, p in enumerate(g.players):
if p.name == name:
g.current_player_idx = i
g._first_player_idx = i
break
g.phase = "playing"
g.game_active = True
g.add_log(f"Game started! {name} goes first", timestamp=timestamp)
return
# "Player N, say 'me' please" - create placeholder
m = self.SAY_ME_RE.match(msg)
if m:
num = int(m.group(1))
if not g.get_player(number=num):
p = Player(f"Player {num}", num)
g.players.append(p)
g.add_log(f"Waiting for Player {num} to register...", timestamp=timestamp)
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
g.add_log(f"{name}'s turn — ${money} on {sq_name}", player=name, timestamp=timestamp)
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
g.add_log(f"roll is {d1}, {d2}", player=cp.name if cp else None, timestamp=timestamp)
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
g.add_log(f"Landed on GO TO JAIL!", player=cp.name, timestamp=timestamp)
else:
g.add_log(f"Landed on {sq_name}", player=cp.name, timestamp=timestamp)
return
# ===== PASS GO =====
if self.PASS_GO_RE.match(msg):
if cp:
cp.money += 200
g.add_log("Passed GO — collected $200", player=cp.name, timestamp=timestamp)
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
g.add_log("3 doubles — go to jail!", player=cp.name, timestamp=timestamp)
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
# The auctioned property is at the current player's location
cp = g.current_player
if cp:
sq_id = cp.location
if 0 <= sq_id < 40:
g.property_owner[sq_id] = num
sq_name = g.location_name(sq_id)
g.add_log(f"Won auction for {sq_name} at ${price}", player=name, timestamp=timestamp)
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:
g.add_log(f"{cp.name} resigned to the bank", player=cp.name, timestamp=timestamp)
self._remove_player(cp)
return
m = self.WINS_RE.match(msg)
if m:
winner = m.group(1)
g.add_log(f"{winner} WINS!", player=winner, timestamp=timestamp)
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
owner_name = g.pending_rent_owner
if owner_name:
owner = g.get_player(name=owner_name)
if owner:
owner.money += amount
g.add_log(f"Paid ${amount} rent to {owner_name}", player=cp.name)
else:
g.add_log(f"Paid ${amount} rent", player=cp.name)
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)
# Log the card draw (use first non-empty line as summary)
card_summary = next((l.strip() for l in lines if l.strip()), "Drew a card")
g.add_log(card_summary, player=cp.name)
# 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
g.add_log(f"Trade completed between {p1.name} and {p2.name}")
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
g.add_log(f"{cp.name} resigned to {target.name if target else 'bank'}", player=cp.name)
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
# During setup, emit partial state so the UI can show registering players
if g.phase == "setup":
return {
"players": [p.to_dict() for p in g.players],
"currentPlayer": None,
"squares": [{"id": sq["id"], "name": sq["name"], "type": sq["type"]} for sq in g.squares],
"log": g.log[-30:],
"phase": "setup",
"numPlayersExpected": g.num_players_expected,
}
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)
# Emit players in turn order (rotated so first player is first)
# The C code doesn't reorder the array, it just sets the starting
# index. Turn order is: first_player, first_player+1, ..., wrapping.
if g.players and hasattr(g, '_first_player_idx'):
fp = g._first_player_idx
ordered = g.players[fp:] + g.players[:fp]
else:
ordered = g.players
return {
"players": [p.to_dict() for p in ordered],
"currentPlayer": g.current_player.number if g.current_player else None,
"squares": squares,
"log": g.log[-30:],
}
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