monop-state/plugins/monop/monop_parser.py
Jarvis ea99d36657 Add house buying/selling sub-parser with full integration
New file: house_parser.py — self-contained state machine for the interactive
house buy/sell dialog. Parses:
- Property prompts ('PropName (N): ') to capture current house count
- Player responses (how many to buy/sell)
- Auto-skipped prompts (hotel during buy, 0 houses during sell)
- Error retries ('spread too wide', 'too many')
- Confirmation ('Is that ok?' → y/n)

Returns a result dict with {action, changes: {sq_id: new_count}, cost}
when the dialog completes.

Integration: monop_parser feeds every line (bot + player) to HouseParser.
On result, applies house count changes to property_houses.

Tests:
- test_house_parser.py (18 tests): buy/sell basics, hotel, error retry,
  real log replay (lines 59, 354, 4386)
- test_parser_commands.py: 4 new integration tests (buy, sell, reject,
  real log verification)

This closes the last tracking gap — house counts are now accurate at
purchase time, not just when rent is later charged.
2026-02-21 19:43:03 +00:00

1595 lines
64 KiB
Python

"""
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"
# Truncated name (10 chars, like C's printsq) + cost -> square id
# Used to identify properties in trade summary lines
_TRUNC_COST_TO_ID = {}
_TRUNC_TO_IDS = {} # trunc -> list of sq_ids (for ambiguous names)
for _sq in BOARD:
if _sq["type"] in ("property", "railroad", "utility"):
_trunc = _sq["name"][:10].rstrip()
_TRUNC_COST_TO_ID[(_trunc, _sq["cost"])] = _sq["id"]
_TRUNC_TO_IDS.setdefault(_trunc, []).append(_sq["id"])
def resolve_trade_property(trunc_name, cost=None):
"""Resolve a truncated property name (+ optional cost) to a square id."""
if cost is not None:
sq_id = _TRUNC_COST_TO_ID.get((trunc_name, cost))
if sq_id is not None:
return sq_id
ids = _TRUNC_TO_IDS.get(trunc_name, [])
if len(ids) == 1:
return ids[0]
return None
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):
d = {
"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,
}
if getattr(self, 'bankrupt', False):
d["bankrupt"] = True
return d
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)
self.bankrupt_players = [] # Players who resigned/went bankrupt
# 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 --$')
# Trade property line: " NAME OWNER GROUP COST ..."
# C's printsq uses %-10.10s for name, then owner+1, group, cost
TRADE_PROP_RE = re.compile(r'^\s(.{10})\s+(\d+)\s+\S*\s+(\d+)')
# Holdings property line (same format but with mortgage/houses/rent)
HOLDINGS_PROP_RE = re.compile(r'^\s(.{10})\s+(\d+)\s+(\S*)\s+(\d+)\s*(\*)?\s*(\d+)?\s+(\d+)?')
HOLDINGS_HEADER_RE = re.compile(r'^\s+Name\s+Own\s+Price')
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
self._trade_props1 = [] # list of square_ids player1 is giving
self._trade_props2 = [] # list of square_ids player2 is giving
# 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)
# Sub-parser for interactive house dialogs
from house_parser import HouseParser
self._house_parser = HouseParser()
self._resign_pending = False
self._resign_target = None
self._waiting_resign_target = False
self._last_user_input = ""
self._command_context = None # tracks what command was issued for disambiguation
self._holdings_player = None # player number during holdings display parsing
self._in_holdings = False
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 _process_player_command(self, sender, cmd, timestamp):
"""Track player commands to infer silent state changes."""
g = self.game
if not g:
return
cmd_lower = cmd.lower().strip()
# `.card` — use GOJF card to exit jail (no output on success)
if cmd_lower in ("card", "c"):
player = g.get_player(name=sender)
if player and player.in_jail and player.get_out_of_jail_free_cards > 0:
player.get_out_of_jail_free_cards -= 1
player.in_jail = False
player.jail_turns = 0
player.location = 10 # Just Visiting
g.add_log("Used Get Out of Jail Free card", player=sender, timestamp=timestamp)
return
# `.unmortgage` / `.unm` — next "That cost you $X" is an unmortgage
if cmd_lower.startswith("unm"):
self._command_context = "unmortgage"
return
# `.mortgage` / `.mor` — next "That got you $X" is a mortgage
if cmd_lower.startswith("mor"):
self._command_context = "mortgage"
return
# `.buy` — entering house buying mode
if cmd_lower in ("buy", "b"):
self._command_context = "buy_houses"
self._house_buy_props = {} # prop_name -> current houses (from prompts)
return
# `.sell` — entering house selling mode
if cmd_lower.startswith("sell"):
self._command_context = "sell_houses"
return
# `.holdings` / `.hold` — triggers holdings display (parsed from output)
if cmd_lower.startswith("hold"):
self._command_context = "holdings"
return
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
# Name registration — user sends their IRC nick as their name.
# In monop-irc, the bridge sends "nick name" to monop stdin,
# so the player name matches the IRC sender.
name = msg.strip()
if name and not name.isdigit() and name == sender:
for p in g.players:
if p.name.startswith("Player "):
p.name = name
g.add_log(f"{name} joined!", player=name, 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()
# Feed to house sub-parser (needs both bot and player lines)
if self.game:
house_result = self._house_parser.feed(sender, message)
if house_result is not None:
self._apply_house_result(house_result)
# Track user input
if sender != "monop":
user_msg = message.lstrip('.')
if user_msg:
self._last_user_input = user_msg
self._process_player_command(sender, user_msg, timestamp)
# 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 setup or checkpoint
m = self.SAY_ME_RE.match(msg)
if m:
self._new_game()
g = self.game
g.phase = "setup"
num = int(m.group(1))
g.num_players_expected = num
p = Player(f"Player {num}", num)
g.players.append(p)
g.add_log(f"Waiting for Player {num} to register...", timestamp=timestamp)
return
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
g._first_player_idx = 0 # assume first seen is first
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)
# Update expected count (in case we missed "How many players?")
if num > g.num_players_expected:
g.num_players_expected = num
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:
houses = int(m.group(1))
rent = int(m.group(2))
# Update house count for this property (player just landed here)
if cp and 0 <= cp.location < 40:
g.property_houses[cp.location] = houses
self._pay_rent(rent)
return
m = self.RENT_HOTEL_RE.match(msg)
if m:
rent = int(m.group(1))
# Hotel = 5 houses
if cp and 0 <= cp.location < 40:
g.property_houses[cp.location] = 5
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
# Try to identify which property was mortgaged
# Mortgage value = cost/2, so cost = amount * 2
self._resolve_mortgage(cp, amount)
return
# ===== UNMORTGAGE / JAIL PAY =====
# "That cost you $X" — ambiguous: could be unmortgage or jail pay
# Use _command_context to disambiguate
m = self.UNMORTGAGE_COST_RE.match(msg)
if m:
amount = int(m.group(1))
if self._command_context == "unmortgage" and cp:
cp.money -= amount
# Try to identify which property was unmortgaged
self._resolve_unmortgage(cp, amount)
self._command_context = None
elif cp and cp.in_jail and amount == 50 and self._command_context != "unmortgage":
# 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
self._trade_props1 = []
elif self._trade_state == 'gives1':
self._trade_state = 'gives2'
self._trade_player2 = (name, num)
self._trade_cash2 = 0
self._trade_gojf2 = 0
self._trade_props2 = []
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
# Trade property line (indented printsq output)
if self._trade_state:
m_tp = self.TRADE_PROP_RE.match(msg_raw)
if m_tp:
trunc_name = m_tp.group(1).rstrip()
cost = int(m_tp.group(3))
sq_id = resolve_trade_property(trunc_name, cost)
if sq_id is not None:
if self._trade_state == 'gives1':
self._trade_props1.append(sq_id)
elif self._trade_state == 'gives2':
self._trade_props2.append(sq_id)
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(timestamp=timestamp)
else:
self._execute_trade(timestamp=timestamp)
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 - properties go back to unowned
if cp:
for sq_id, owner_num in list(g.property_owner.items()):
if owner_num == cp.number:
del g.property_owner[sq_id]
g.property_mortgaged.pop(sq_id, None)
g.property_houses.pop(sq_id, None)
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 =====
# ===== HOLDINGS DISPLAY =====
m = self.HOLDINGS_RE.match(msg)
if m:
name = m.group(1)
num = int(m.group(2))
self._holdings_player = num
self._in_holdings = True
return
if self._in_holdings:
# Header line
if self.HOLDINGS_HEADER_RE.match(msg_raw):
return
# Property line
m_h = self.HOLDINGS_PROP_RE.match(msg_raw)
if m_h:
trunc_name = m_h.group(1).rstrip()
cost = int(m_h.group(4))
mortgaged = m_h.group(5) == '*'
houses = int(m_h.group(6)) if m_h.group(6) else 0
sq_id = resolve_trade_property(trunc_name, cost)
if sq_id is not None:
# Resync property state from holdings
g.property_owner[sq_id] = self._holdings_player
if mortgaged:
g.property_mortgaged[sq_id] = True
else:
g.property_mortgaged.pop(sq_id, None)
if houses > 0:
g.property_houses[sq_id] = houses
else:
g.property_houses.pop(sq_id, None)
return
# Any non-matching line ends the holdings display
self._in_holdings = False
self._holdings_player = None
# Fall through to process this line normally
# ===== 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 _apply_house_result(self, result):
"""Apply the result from the house sub-parser to game state."""
g = self.game
if not g or result["action"] == "cancel":
return
for sq_id, new_count in result["changes"].items():
if new_count > 0:
g.property_houses[sq_id] = new_count
else:
g.property_houses.pop(sq_id, None)
action = "Built" if result["action"] == "buy" else "Sold"
total = sum(result["changes"].values()) if result["action"] == "buy" else result["cost"] // max(1, result.get("cost", 1))
cp = g.current_player
if cp:
g.add_log(f"{action} houses (${result['cost']})", player=cp.name)
def _resolve_mortgage(self, player, amount):
"""Try to identify which property was mortgaged and set its mortgage flag."""
g = self.game
if not g:
return
# Mortgage value = cost/2, so find properties where cost/2 == amount
candidates = []
for sq_id, owner_num in g.property_owner.items():
if owner_num != player.number:
continue
if g.property_mortgaged.get(sq_id):
continue # already mortgaged
sq = BOARD[sq_id] if sq_id < len(BOARD) else None
if sq and sq["cost"] // 2 == amount:
candidates.append(sq_id)
if len(candidates) == 1:
g.property_mortgaged[candidates[0]] = True
elif len(candidates) > 1 and self._last_user_input:
inp = self._last_user_input.lower()
for sq_id in candidates:
sq_name = BOARD[sq_id]["name"].lower()
if sq_name.startswith(inp) or inp in sq_name:
g.property_mortgaged[sq_id] = True
break
def _resolve_unmortgage(self, player, cost):
"""Try to identify which property was unmortgaged and clear its mortgage flag."""
g = self.game
if not g:
return
# Candidates: mortgaged properties owned by this player where unmortgage cost matches
candidates = []
for sq_id, mortgaged in list(g.property_mortgaged.items()):
if not mortgaged:
continue
if g.property_owner.get(sq_id) != player.number:
continue
sq = BOARD[sq_id] if sq_id < len(BOARD) else None
if sq:
half = sq["cost"] // 2
expected_cost = half + half // 10
if expected_cost == cost:
candidates.append(sq_id)
if len(candidates) == 1:
# Unambiguous
g.property_mortgaged.pop(candidates[0], None)
elif len(candidates) > 1 and self._last_user_input:
# Use user's input to disambiguate (prefix match like C's getinp)
inp = self._last_user_input.lower()
for sq_id in candidates:
sq_name = BOARD[sq_id]["name"].lower()
if sq_name.startswith(inp) or inp in sq_name:
g.property_mortgaged.pop(sq_id, None)
break
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
g.spec = False # Clear spec flag after rent (matches C's get_card cleanup)
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, timestamp=None):
"""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
# p1 gives properties to p2
for sq_id in self._trade_props1:
g.property_owner[sq_id] = p2.number
# p2 gives properties to p1
for sq_id in self._trade_props2:
g.property_owner[sq_id] = p1.number
g.add_log(f"Trade completed between {p1.name} and {p2.name}", timestamp=timestamp)
self._trade_state = None
self._trade_player1 = None
self._trade_player2 = None
self._trade_props1 = []
self._trade_props2 = []
def _execute_resign_to_player(self, timestamp=None):
"""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:
# Transfer money
if cp.money > 0:
target.money += cp.money
# Transfer GOJF cards
target.get_out_of_jail_free_cards += cp.get_out_of_jail_free_cards
# Transfer properties
for sq_id, owner_num in list(g.property_owner.items()):
if owner_num == cp.number:
g.property_owner[sq_id] = target.number
g.add_log(f"{cp.name} resigned to {target.name}", player=cp.name, timestamp=timestamp)
else:
# No target found — treat as bank resignation
for sq_id, owner_num in list(g.property_owner.items()):
if owner_num == cp.number:
del g.property_owner[sq_id]
g.property_mortgaged.pop(sq_id, None)
g.property_houses.pop(sq_id, None)
g.add_log(f"{cp.name} resigned to the bank", player=cp.name, timestamp=timestamp)
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)
player.bankrupt = True
g.bankrupt_players.append(player)
g.players.remove(player)
# Build old->new number mapping before renumbering
old_numbers = {p: p.number for p in g.players}
# Renumber remaining players (C code shifts array)
for i, p in enumerate(g.players):
p.number = i + 1
# Update property_owner references to match new numbers
number_map = {old_numbers[p]: p.number for p in g.players}
for sq_id in list(g.property_owner.keys()):
old_num = g.property_owner[sq_id]
if old_num in number_map:
g.property_owner[sq_id] = number_map[old_num]
# 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
# Append bankrupt players at the end
all_players = [p.to_dict() for p in ordered] + [p.to_dict() for p in g.bankrupt_players]
result = {
"players": all_players,
"currentPlayer": g.current_player.number if g.current_player else None,
"squares": squares,
"log": g.log[-30:],
}
result["phase"] = g.phase # "playing" or "over"
return result
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