"""
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
