2026-02-21 03:54:59 +00:00
|
|
|
"""
|
|
|
|
|
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)
|
2026-02-21 10:30:53 +00:00
|
|
|
# Game log for the web viewer
|
|
|
|
|
self.log = [] # list of {"timestamp": str, "text": str, "player": str|None}
|
2026-02-21 03:54:59 +00:00
|
|
|
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
|
|
|
|
|
|
2026-02-21 10:30:53 +00:00
|
|
|
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:]
|
|
|
|
|
|
2026-02-21 03:54:59 +00:00
|
|
|
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
|
2026-02-21 04:14:49 +00:00
|
|
|
bought = False
|
2026-02-21 03:54:59 +00:00
|
|
|
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
|
2026-02-21 04:14:49 +00:00
|
|
|
bought = True
|
2026-02-21 03:54:59 +00:00
|
|
|
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
|
2026-02-21 04:14:49 +00:00
|
|
|
bought = True
|
2026-02-21 03:54:59 +00:00
|
|
|
else:
|
|
|
|
|
cp_buy = g.current_player
|
|
|
|
|
if cp_buy and g.pending_buy_cost:
|
|
|
|
|
cp_buy.money -= g.pending_buy_cost
|
2026-02-21 04:14:49 +00:00
|
|
|
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
|
2026-02-21 03:54:59 +00:00
|
|
|
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)
|
2026-02-21 11:02:22 +00:00
|
|
|
# Set current player and record turn order start
|
2026-02-21 03:54:59 +00:00
|
|
|
for i, p in enumerate(g.players):
|
|
|
|
|
if p.name == name:
|
|
|
|
|
g.current_player_idx = i
|
2026-02-21 11:02:22 +00:00
|
|
|
g._first_player_idx = i
|
2026-02-21 03:54:59 +00:00
|
|
|
break
|
|
|
|
|
g.phase = "playing"
|
|
|
|
|
g.game_active = True
|
2026-02-21 10:30:53 +00:00
|
|
|
g.add_log(f"Game started! {name} goes first", timestamp=timestamp)
|
2026-02-21 03:54:59 +00:00
|
|
|
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
|
|
|
|
|
|
2026-02-21 10:30:53 +00:00
|
|
|
g.add_log(f"{name}'s turn — ${money} on {sq_name}", player=name, timestamp=timestamp)
|
|
|
|
|
|
2026-02-21 03:54:59 +00:00
|
|
|
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
|
2026-02-21 10:30:53 +00:00
|
|
|
g.add_log(f"roll is {d1}, {d2}", player=cp.name if cp else None, timestamp=timestamp)
|
2026-02-21 03:54:59 +00:00
|
|
|
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
|
2026-02-21 10:30:53 +00:00
|
|
|
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)
|
2026-02-21 03:54:59 +00:00
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# ===== PASS GO =====
|
|
|
|
|
if self.PASS_GO_RE.match(msg):
|
|
|
|
|
if cp:
|
|
|
|
|
cp.money += 200
|
2026-02-21 10:30:53 +00:00
|
|
|
g.add_log("Passed GO — collected $200", player=cp.name, timestamp=timestamp)
|
2026-02-21 03:54:59 +00:00
|
|
|
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
|
2026-02-21 10:30:53 +00:00
|
|
|
g.add_log("3 doubles — go to jail!", player=cp.name, timestamp=timestamp)
|
2026-02-21 03:54:59 +00:00
|
|
|
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
|
2026-02-21 04:14:49 +00:00
|
|
|
# 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
|
2026-02-21 10:30:53 +00:00
|
|
|
sq_name = g.location_name(sq_id)
|
|
|
|
|
g.add_log(f"Won auction for {sq_name} at ${price}", player=name, timestamp=timestamp)
|
2026-02-21 03:54:59 +00:00
|
|
|
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:
|
2026-02-21 10:30:53 +00:00
|
|
|
g.add_log(f"{cp.name} resigned to the bank", player=cp.name, timestamp=timestamp)
|
2026-02-21 03:54:59 +00:00
|
|
|
self._remove_player(cp)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
m = self.WINS_RE.match(msg)
|
|
|
|
|
if m:
|
2026-02-21 10:30:53 +00:00
|
|
|
winner = m.group(1)
|
|
|
|
|
g.add_log(f"{winner} WINS!", player=winner, timestamp=timestamp)
|
2026-02-21 03:54:59 +00:00
|
|
|
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
|
2026-02-21 10:30:53 +00:00
|
|
|
owner_name = g.pending_rent_owner
|
|
|
|
|
if owner_name:
|
|
|
|
|
owner = g.get_player(name=owner_name)
|
2026-02-21 03:54:59 +00:00
|
|
|
if owner:
|
|
|
|
|
owner.money += amount
|
2026-02-21 10:30:53 +00:00
|
|
|
g.add_log(f"Paid ${amount} rent to {owner_name}", player=cp.name)
|
|
|
|
|
else:
|
|
|
|
|
g.add_log(f"Paid ${amount} rent", player=cp.name)
|
2026-02-21 03:54:59 +00:00
|
|
|
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)
|
2026-02-21 10:30:53 +00:00
|
|
|
# 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)
|
2026-02-21 03:54:59 +00:00
|
|
|
|
|
|
|
|
# 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
|
2026-02-21 10:30:53 +00:00
|
|
|
g.add_log(f"Trade completed between {p1.name} and {p2.name}")
|
2026-02-21 03:54:59 +00:00
|
|
|
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
|
2026-02-21 10:30:53 +00:00
|
|
|
g.add_log(f"{cp.name} resigned to {target.name if target else 'bank'}", player=cp.name)
|
2026-02-21 03:54:59 +00:00
|
|
|
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)
|
2026-02-21 11:02:22 +00:00
|
|
|
# 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
|
|
|
|
|
|
2026-02-21 03:54:59 +00:00
|
|
|
return {
|
2026-02-21 11:02:22 +00:00
|
|
|
"players": [p.to_dict() for p in ordered],
|
2026-02-21 03:54:59 +00:00
|
|
|
"currentPlayer": g.current_player.number if g.current_player else None,
|
|
|
|
|
"squares": squares,
|
2026-02-21 10:30:53 +00:00
|
|
|
"log": g.log[-30:],
|
2026-02-21 03:54:59 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|