From ea99d366572338e70eed11de9c2c3d237af81eb3 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Sat, 21 Feb 2026 19:43:03 +0000 Subject: [PATCH] Add house buying/selling sub-parser with full integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- house_parser.py | 252 +++++++++++++++++++++++++ monop_parser.py | 26 +++ plugins/monop/house_parser.py | 252 +++++++++++++++++++++++++ plugins/monop/monop_parser.py | 26 +++ test_house_parser.py | 346 ++++++++++++++++++++++++++++++++++ test_parser_commands.py | 96 ++++++++++ 6 files changed, 998 insertions(+) create mode 100644 house_parser.py create mode 100644 plugins/monop/house_parser.py create mode 100644 test_house_parser.py diff --git a/house_parser.py b/house_parser.py new file mode 100644 index 0000000..8dbf49d --- /dev/null +++ b/house_parser.py @@ -0,0 +1,252 @@ +""" +Sub-parser for the interactive house buying/selling dialog in BSD monop. + +The dialog flow (buying): + 1. list_cur: "Kentucky ave. (R) (0) Indiana ave. (R) (0) Illinois ave. (R) (0)" + 2. "Houses will cost $N" + 3. "How many houses do you wish to buy for" + 4. Prompts: "Kentucky ave. (R) (0): " → user enters number + 5. Repeat for each property in monopoly + 6. "You asked for N houses for $X" + 7. "Is that ok? " → y/n + +Selling is identical except: + - "Houses will get you $N apiece" (step 2) + - "How many houses do you wish to sell from" (step 3) + - "You asked to sell N houses for $X" (step 6) + +This parser tracks the prompts (which tell us property name + current houses) +and the user responses (how many to add/remove), then applies the changes +when confirmed. + +Usage: + hp = HouseParser() + for each line: + result = hp.feed(sender, message) + if result is not None: + # result is a dict: {"action": "buy"|"sell", "changes": {sq_id: new_count}} + # or {"action": "cancel"} if rejected +""" + +import re + +# Lazy import to avoid circular dependency with monop_parser +_SQUARE_BY_NAME = None + +def _get_square_by_name(): + global _SQUARE_BY_NAME + if _SQUARE_BY_NAME is None: + from monop_parser import SQUARE_BY_NAME + _SQUARE_BY_NAME = SQUARE_BY_NAME + return _SQUARE_BY_NAME + + +# Property prompt: "PropName (N): " where N is digit or H +PROMPT_RE = re.compile(r'^(.+?) \((\d|H)\):\s*$') + +# list_cur line: "PropName (N) PropName (N) ..." (space-separated, trailing space) +LIST_CUR_RE = re.compile(r'^((?:.+? \(\d|H\) )+)\s*$') + +HOUSES_COST_RE = re.compile(r'^Houses will cost \$(\d+)$') +HOUSES_GET_RE = re.compile(r'^Houses will get you \$(\d+) apiece$') +HOW_MANY_BUY_RE = re.compile(r'^How many houses do you wish to buy for$') +HOW_MANY_SELL_RE = re.compile(r'^How many houses do you wish to sell from$') +ASKED_BUY_RE = re.compile(r'^You asked for (\d+) houses for \$(\d+)$') +ASKED_SELL_RE = re.compile(r'^You asked to sell (\d+) houses for \$(\d+)$') +IS_THAT_OK_RE = re.compile(r'^Is that ok\?') +SPREAD_TOO_WIDE_RE = re.compile(r"^That makes the spread too wide") +TOO_MANY_RE = re.compile(r"^That's too many") + + +def _resolve_property_name(name): + """Resolve a full property name from a prompt to a square ID. + + Prompts use the full name like 'Kentucky ave. (R)' — + this is the square name from the C board array. + """ + return _get_square_by_name().get(name) + + +class HouseParser: + """State machine for parsing house buy/sell dialogs.""" + + def __init__(self): + self.reset() + + def reset(self): + """Reset to idle state.""" + self.state = "idle" # idle, prompting, confirming + self.action = None # "buy" or "sell" + self.price_per = 0 # cost per house (buy) or refund per house (sell) + self.prompts = [] # list of (sq_id, current_houses) in prompt order + self.responses = [] # list of int (user's count per prompt) + self._current_prompt = None # (sq_id, current_houses) waiting for response + + @property + def active(self): + """True if we're in the middle of a house dialog.""" + return self.state != "idle" + + def feed(self, sender, msg): + """Feed a line. Returns a result dict when the dialog completes, else None. + + sender: "monop" for bot lines, anything else for player input + msg: the message text (stripped of IRC prefix) + + Returns: + None — dialog still in progress (or not in a dialog) + {"action": "buy"|"sell", "changes": {sq_id: new_house_count}, "cost": int} + {"action": "cancel"} — player rejected or dialog reset + """ + msg = msg.strip() + is_bot = (sender == "monop") + + if self.state == "idle": + return self._handle_idle(msg, is_bot) + elif self.state == "prompting": + return self._handle_prompting(msg, is_bot) + elif self.state == "confirming": + return self._handle_confirming(msg, is_bot) + return None + + def _handle_idle(self, msg, is_bot): + if not is_bot: + return None + + m = HOUSES_COST_RE.match(msg) + if m: + self.action = "buy" + self.price_per = int(m.group(1)) + return None + + m = HOUSES_GET_RE.match(msg) + if m: + self.action = "sell" + self.price_per = int(m.group(1)) + return None + + if HOW_MANY_BUY_RE.match(msg): + self.state = "prompting" + self.action = "buy" + self.prompts = [] + self.responses = [] + return None + + if HOW_MANY_SELL_RE.match(msg): + self.state = "prompting" + self.action = "sell" + self.prompts = [] + self.responses = [] + return None + + return None + + def _handle_prompting(self, msg, is_bot): + if is_bot: + # Property prompt from monop + m = PROMPT_RE.match(msg) + if m: + name = m.group(1) + houses_str = m.group(2) + houses = 5 if houses_str == 'H' else int(houses_str) + sq_id = _resolve_property_name(name) + if sq_id is not None: + self._current_prompt = (sq_id, houses) + self.prompts.append((sq_id, houses)) + else: + # Can't resolve — still track position + self._current_prompt = (None, houses) + self.prompts.append((None, houses)) + + # Hotel prompt during buying: C auto-sets input to 0, no user input + if self.action == "buy" and houses == 5: + self.responses.append(0) + self._current_prompt = None + # Zero houses prompt during selling: C auto-sets input to 0 + elif self.action == "sell" and houses == 0: + self.responses.append(0) + self._current_prompt = None + return None + + # Confirmation line — all prompts done + m = ASKED_BUY_RE.match(msg) or ASKED_SELL_RE.match(msg) + if m: + self.state = "confirming" + self._current_prompt = None + return None + + # Error: spread too wide or too many — retry + if SPREAD_TOO_WIDE_RE.match(msg) or TOO_MANY_RE.match(msg): + # C code retries — for "too many" it re-prompts same property, + # for "spread too wide" it restarts all prompts + if SPREAD_TOO_WIDE_RE.match(msg): + self.prompts = [] + self.responses = [] + self._current_prompt = None + elif TOO_MANY_RE.match(msg): + # Re-prompt the same property (pop last response and prompt; + # C will re-output the prompt line which re-adds it) + if self.responses: + self.responses.pop() + if self.prompts: + self.prompts.pop() + self._current_prompt = None + return None + + return None + else: + # Player response to a prompt + if self._current_prompt is not None: + try: + count = int(msg.lstrip('.')) + self.responses.append(count) + except ValueError: + pass # invalid input, C will re-prompt + self._current_prompt = None + return None + + def _handle_confirming(self, msg, is_bot): + if is_bot: + # "Is that ok?" — wait for player response + return None + + # Player says yes or no + answer = msg.lstrip('.').lower() + if answer in ('y', 'yes'): + result = self._compute_result() + self.reset() + return result + else: + # Rejected + self.reset() + return {"action": "cancel"} + + def _compute_result(self): + """Compute the final house counts after confirmation.""" + changes = {} + + for i, (sq_id, current) in enumerate(self.prompts): + if sq_id is None: + continue + if i >= len(self.responses): + continue + + delta = self.responses[i] + if delta == 0: + continue + + if self.action == "buy": + new_count = current + delta + else: # sell + new_count = current - delta + + changes[sq_id] = max(0, min(5, new_count)) + + total_houses = sum(self.responses[:len(self.prompts)]) + cost = total_houses * self.price_per + + return { + "action": self.action, + "changes": changes, + "cost": cost, + } diff --git a/monop_parser.py b/monop_parser.py index 58f6971..633dea3 100644 --- a/monop_parser.py +++ b/monop_parser.py @@ -259,6 +259,10 @@ class MonopParser: # 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 @@ -366,6 +370,12 @@ class MonopParser: 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('.') @@ -1235,6 +1245,22 @@ class MonopParser: 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 diff --git a/plugins/monop/house_parser.py b/plugins/monop/house_parser.py new file mode 100644 index 0000000..8dbf49d --- /dev/null +++ b/plugins/monop/house_parser.py @@ -0,0 +1,252 @@ +""" +Sub-parser for the interactive house buying/selling dialog in BSD monop. + +The dialog flow (buying): + 1. list_cur: "Kentucky ave. (R) (0) Indiana ave. (R) (0) Illinois ave. (R) (0)" + 2. "Houses will cost $N" + 3. "How many houses do you wish to buy for" + 4. Prompts: "Kentucky ave. (R) (0): " → user enters number + 5. Repeat for each property in monopoly + 6. "You asked for N houses for $X" + 7. "Is that ok? " → y/n + +Selling is identical except: + - "Houses will get you $N apiece" (step 2) + - "How many houses do you wish to sell from" (step 3) + - "You asked to sell N houses for $X" (step 6) + +This parser tracks the prompts (which tell us property name + current houses) +and the user responses (how many to add/remove), then applies the changes +when confirmed. + +Usage: + hp = HouseParser() + for each line: + result = hp.feed(sender, message) + if result is not None: + # result is a dict: {"action": "buy"|"sell", "changes": {sq_id: new_count}} + # or {"action": "cancel"} if rejected +""" + +import re + +# Lazy import to avoid circular dependency with monop_parser +_SQUARE_BY_NAME = None + +def _get_square_by_name(): + global _SQUARE_BY_NAME + if _SQUARE_BY_NAME is None: + from monop_parser import SQUARE_BY_NAME + _SQUARE_BY_NAME = SQUARE_BY_NAME + return _SQUARE_BY_NAME + + +# Property prompt: "PropName (N): " where N is digit or H +PROMPT_RE = re.compile(r'^(.+?) \((\d|H)\):\s*$') + +# list_cur line: "PropName (N) PropName (N) ..." (space-separated, trailing space) +LIST_CUR_RE = re.compile(r'^((?:.+? \(\d|H\) )+)\s*$') + +HOUSES_COST_RE = re.compile(r'^Houses will cost \$(\d+)$') +HOUSES_GET_RE = re.compile(r'^Houses will get you \$(\d+) apiece$') +HOW_MANY_BUY_RE = re.compile(r'^How many houses do you wish to buy for$') +HOW_MANY_SELL_RE = re.compile(r'^How many houses do you wish to sell from$') +ASKED_BUY_RE = re.compile(r'^You asked for (\d+) houses for \$(\d+)$') +ASKED_SELL_RE = re.compile(r'^You asked to sell (\d+) houses for \$(\d+)$') +IS_THAT_OK_RE = re.compile(r'^Is that ok\?') +SPREAD_TOO_WIDE_RE = re.compile(r"^That makes the spread too wide") +TOO_MANY_RE = re.compile(r"^That's too many") + + +def _resolve_property_name(name): + """Resolve a full property name from a prompt to a square ID. + + Prompts use the full name like 'Kentucky ave. (R)' — + this is the square name from the C board array. + """ + return _get_square_by_name().get(name) + + +class HouseParser: + """State machine for parsing house buy/sell dialogs.""" + + def __init__(self): + self.reset() + + def reset(self): + """Reset to idle state.""" + self.state = "idle" # idle, prompting, confirming + self.action = None # "buy" or "sell" + self.price_per = 0 # cost per house (buy) or refund per house (sell) + self.prompts = [] # list of (sq_id, current_houses) in prompt order + self.responses = [] # list of int (user's count per prompt) + self._current_prompt = None # (sq_id, current_houses) waiting for response + + @property + def active(self): + """True if we're in the middle of a house dialog.""" + return self.state != "idle" + + def feed(self, sender, msg): + """Feed a line. Returns a result dict when the dialog completes, else None. + + sender: "monop" for bot lines, anything else for player input + msg: the message text (stripped of IRC prefix) + + Returns: + None — dialog still in progress (or not in a dialog) + {"action": "buy"|"sell", "changes": {sq_id: new_house_count}, "cost": int} + {"action": "cancel"} — player rejected or dialog reset + """ + msg = msg.strip() + is_bot = (sender == "monop") + + if self.state == "idle": + return self._handle_idle(msg, is_bot) + elif self.state == "prompting": + return self._handle_prompting(msg, is_bot) + elif self.state == "confirming": + return self._handle_confirming(msg, is_bot) + return None + + def _handle_idle(self, msg, is_bot): + if not is_bot: + return None + + m = HOUSES_COST_RE.match(msg) + if m: + self.action = "buy" + self.price_per = int(m.group(1)) + return None + + m = HOUSES_GET_RE.match(msg) + if m: + self.action = "sell" + self.price_per = int(m.group(1)) + return None + + if HOW_MANY_BUY_RE.match(msg): + self.state = "prompting" + self.action = "buy" + self.prompts = [] + self.responses = [] + return None + + if HOW_MANY_SELL_RE.match(msg): + self.state = "prompting" + self.action = "sell" + self.prompts = [] + self.responses = [] + return None + + return None + + def _handle_prompting(self, msg, is_bot): + if is_bot: + # Property prompt from monop + m = PROMPT_RE.match(msg) + if m: + name = m.group(1) + houses_str = m.group(2) + houses = 5 if houses_str == 'H' else int(houses_str) + sq_id = _resolve_property_name(name) + if sq_id is not None: + self._current_prompt = (sq_id, houses) + self.prompts.append((sq_id, houses)) + else: + # Can't resolve — still track position + self._current_prompt = (None, houses) + self.prompts.append((None, houses)) + + # Hotel prompt during buying: C auto-sets input to 0, no user input + if self.action == "buy" and houses == 5: + self.responses.append(0) + self._current_prompt = None + # Zero houses prompt during selling: C auto-sets input to 0 + elif self.action == "sell" and houses == 0: + self.responses.append(0) + self._current_prompt = None + return None + + # Confirmation line — all prompts done + m = ASKED_BUY_RE.match(msg) or ASKED_SELL_RE.match(msg) + if m: + self.state = "confirming" + self._current_prompt = None + return None + + # Error: spread too wide or too many — retry + if SPREAD_TOO_WIDE_RE.match(msg) or TOO_MANY_RE.match(msg): + # C code retries — for "too many" it re-prompts same property, + # for "spread too wide" it restarts all prompts + if SPREAD_TOO_WIDE_RE.match(msg): + self.prompts = [] + self.responses = [] + self._current_prompt = None + elif TOO_MANY_RE.match(msg): + # Re-prompt the same property (pop last response and prompt; + # C will re-output the prompt line which re-adds it) + if self.responses: + self.responses.pop() + if self.prompts: + self.prompts.pop() + self._current_prompt = None + return None + + return None + else: + # Player response to a prompt + if self._current_prompt is not None: + try: + count = int(msg.lstrip('.')) + self.responses.append(count) + except ValueError: + pass # invalid input, C will re-prompt + self._current_prompt = None + return None + + def _handle_confirming(self, msg, is_bot): + if is_bot: + # "Is that ok?" — wait for player response + return None + + # Player says yes or no + answer = msg.lstrip('.').lower() + if answer in ('y', 'yes'): + result = self._compute_result() + self.reset() + return result + else: + # Rejected + self.reset() + return {"action": "cancel"} + + def _compute_result(self): + """Compute the final house counts after confirmation.""" + changes = {} + + for i, (sq_id, current) in enumerate(self.prompts): + if sq_id is None: + continue + if i >= len(self.responses): + continue + + delta = self.responses[i] + if delta == 0: + continue + + if self.action == "buy": + new_count = current + delta + else: # sell + new_count = current - delta + + changes[sq_id] = max(0, min(5, new_count)) + + total_houses = sum(self.responses[:len(self.prompts)]) + cost = total_houses * self.price_per + + return { + "action": self.action, + "changes": changes, + "cost": cost, + } diff --git a/plugins/monop/monop_parser.py b/plugins/monop/monop_parser.py index 58f6971..633dea3 100644 --- a/plugins/monop/monop_parser.py +++ b/plugins/monop/monop_parser.py @@ -259,6 +259,10 @@ class MonopParser: # 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 @@ -366,6 +370,12 @@ class MonopParser: 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('.') @@ -1235,6 +1245,22 @@ class MonopParser: 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 diff --git a/test_house_parser.py b/test_house_parser.py new file mode 100644 index 0000000..cd52d8e --- /dev/null +++ b/test_house_parser.py @@ -0,0 +1,346 @@ +#!/usr/bin/env python3 +"""Tests for the house buying/selling sub-parser.""" + +import sys +import os +import unittest + +sys.path.insert(0, os.path.dirname(__file__)) +from house_parser import HouseParser + + +def feed_lines(hp, lines): + """Feed lines, return the first non-None result.""" + for sender, msg in lines: + result = hp.feed(sender, msg) + if result is not None: + return result + return None + + +class TestBuyBasic(unittest.TestCase): + """Basic house buying flow.""" + + def test_buy_3_properties(self): + """Buy 1+1+2 houses on Red monopoly.""" + hp = HouseParser() + result = feed_lines(hp, [ + ("monop", "Kentucky ave. (R) (0) Indiana ave. (R) (0) Illinois ave. (R) (0) "), + ("monop", "Houses will cost $150"), + ("monop", "How many houses do you wish to buy for"), + ("monop", "Kentucky ave. (R) (0): "), + ("merp", ".1"), + ("monop", "Indiana ave. (R) (0): "), + ("merp", ".1"), + ("monop", "Illinois ave. (R) (0): "), + ("merp", ".2"), + ("monop", "You asked for 4 houses for $600"), + ("monop", "Is that ok? "), + ("merp", ".y"), + ]) + self.assertIsNotNone(result) + self.assertEqual(result["action"], "buy") + self.assertEqual(result["changes"], {21: 1, 23: 1, 24: 2}) + self.assertEqual(result["cost"], 600) + + def test_buy_2_property_monopoly(self): + """Buy houses on a 2-property monopoly (Purple).""" + hp = HouseParser() + result = feed_lines(hp, [ + ("monop", "Mediterranean ave. (P) (0) Baltic ave. (P) (0) "), + ("monop", "Houses will cost $50"), + ("monop", "How many houses do you wish to buy for"), + ("monop", "Mediterranean ave. (P) (0): "), + ("fbs", ".2"), + ("monop", "Baltic ave. (P) (0): "), + ("fbs", ".2"), + ("monop", "You asked for 4 houses for $200"), + ("monop", "Is that ok? "), + ("fbs", ".y"), + ]) + self.assertEqual(result["changes"], {1: 2, 3: 2}) + self.assertEqual(result["cost"], 200) + + def test_buy_with_existing_houses(self): + """Buy more houses on properties that already have some.""" + hp = HouseParser() + result = feed_lines(hp, [ + ("monop", "Houses will cost $100"), + ("monop", "How many houses do you wish to buy for"), + ("monop", "St. James pl. (O) (2): "), + ("merp", ".1"), + ("monop", "Tennessee ave. (O) (1): "), + ("merp", ".2"), + ("monop", "New York ave. (O) (1): "), + ("merp", ".2"), + ("monop", "You asked for 5 houses for $500"), + ("monop", "Is that ok? "), + ("merp", ".y"), + ]) + self.assertEqual(result["changes"], {16: 3, 18: 3, 19: 3}) + + def test_buy_rejected(self): + """Player says no to 'Is that ok?'.""" + hp = HouseParser() + result = feed_lines(hp, [ + ("monop", "Houses will cost $150"), + ("monop", "How many houses do you wish to buy for"), + ("monop", "Kentucky ave. (R) (0): "), + ("merp", ".1"), + ("monop", "Indiana ave. (R) (0): "), + ("merp", ".1"), + ("monop", "Illinois ave. (R) (0): "), + ("merp", ".1"), + ("monop", "You asked for 3 houses for $450"), + ("monop", "Is that ok? "), + ("merp", ".n"), + ]) + self.assertEqual(result, {"action": "cancel"}) + self.assertFalse(hp.active) + + def test_buy_to_hotel(self): + """Buy up to hotel (5 houses).""" + hp = HouseParser() + result = feed_lines(hp, [ + ("monop", "Houses will cost $200"), + ("monop", "How many houses do you wish to buy for"), + ("monop", "Park place (D) (4): "), + ("fbs", ".1"), + ("monop", "Boardwalk (D) (4): "), + ("fbs", ".1"), + ("monop", "You asked for 2 houses for $400"), + ("monop", "Is that ok? "), + ("fbs", ".y"), + ]) + self.assertEqual(result["changes"], {37: 5, 39: 5}) + + def test_buy_skip_hotel(self): + """Property already has hotel — prompt shows (H), auto-skipped.""" + hp = HouseParser() + result = feed_lines(hp, [ + ("monop", "Houses will cost $50"), + ("monop", "How many houses do you wish to buy for"), + ("monop", "Oriental ave. (L) (3): "), + ("fbs", ".2"), + ("monop", "Vermont ave. (L) (H):"), # auto-skipped + ("monop", "Connecticut ave. (L) (4): "), + ("fbs", ".1"), + ("monop", "You asked for 3 houses for $150"), + ("monop", "Is that ok? "), + ("fbs", ".y"), + ]) + self.assertEqual(result["changes"], {6: 5, 9: 5}) + # Vermont (8) not in changes — already at hotel, no change + self.assertNotIn(8, result["changes"]) + + +class TestSellBasic(unittest.TestCase): + """Basic house selling flow.""" + + def test_sell_houses(self): + """Sell houses from Red monopoly.""" + hp = HouseParser() + result = feed_lines(hp, [ + ("monop", "Houses will get you $75 apiece"), + ("monop", "Kentucky ave. (R) (1) Indiana ave. (R) (1) Illinois ave. (R) (2) "), + ("monop", "How many houses do you wish to sell from"), + ("monop", "Kentucky ave. (R) (1): "), + ("merp", ".1"), + ("monop", "Indiana ave. (R) (1): "), + ("merp", ".1"), + ("monop", "Illinois ave. (R) (2): "), + ("merp", ".2"), + ("monop", "You asked to sell 4 houses for $300"), + ("monop", "Is that ok? "), + ("merp", ".y"), + ]) + self.assertEqual(result["action"], "sell") + self.assertEqual(result["changes"], {21: 0, 23: 0, 24: 0}) + self.assertEqual(result["cost"], 300) + + def test_sell_from_hotel(self): + """Sell 1 house from a hotel.""" + hp = HouseParser() + result = feed_lines(hp, [ + ("monop", "Houses will get you $25 apiece"), + ("monop", "How many houses do you wish to sell from"), + ("monop", "Oriental ave. (L) (H): "), + ("fbs", ".1"), + ("monop", "Vermont ave. (L) (H): "), + ("fbs", ".0"), + ("monop", "Connecticut ave. (L) (H): "), + ("fbs", ".0"), + ("monop", "You asked to sell 1 houses for $25"), + ("monop", "Is that ok? "), + ("fbs", ".y"), + ]) + self.assertEqual(result["changes"], {6: 4}) # hotel(5) - 1 = 4 + + def test_sell_skip_zero(self): + """Property with 0 houses — auto-skipped during sell.""" + hp = HouseParser() + result = feed_lines(hp, [ + ("monop", "Houses will get you $100 apiece"), + ("monop", "How many houses do you wish to sell from"), + ("monop", "St. James pl. (O) (0):"), # auto-skipped + ("monop", "Tennessee ave. (O) (2): "), + ("merp", ".1"), + ("monop", "New York ave. (O) (3): "), + ("merp", ".2"), + ("monop", "You asked to sell 3 houses for $300"), + ("monop", "Is that ok? "), + ("merp", ".y"), + ]) + self.assertEqual(result["changes"], {18: 1, 19: 1}) + self.assertNotIn(16, result["changes"]) # St. James had 0 + + +class TestErrorHandling(unittest.TestCase): + """Error cases and retries.""" + + def test_spread_too_wide_retry(self): + """'Spread too wide' resets all prompts.""" + hp = HouseParser() + result = feed_lines(hp, [ + ("monop", "Houses will cost $150"), + ("monop", "How many houses do you wish to buy for"), + ("monop", "Kentucky ave. (R) (0): "), + ("merp", ".3"), + ("monop", "Indiana ave. (R) (0): "), + ("merp", ".0"), + ("monop", "Illinois ave. (R) (0): "), + ("merp", ".0"), + ("monop", "That makes the spread too wide. Try again"), + # Retry + ("monop", "Kentucky ave. (R) (0): "), + ("merp", ".1"), + ("monop", "Indiana ave. (R) (0): "), + ("merp", ".1"), + ("monop", "Illinois ave. (R) (0): "), + ("merp", ".1"), + ("monop", "You asked for 3 houses for $450"), + ("monop", "Is that ok? "), + ("merp", ".y"), + ]) + self.assertIsNotNone(result) + self.assertEqual(result["changes"], {21: 1, 23: 1, 24: 1}) + + def test_too_many_retry(self): + """'Too many' re-prompts same property.""" + hp = HouseParser() + result = feed_lines(hp, [ + ("monop", "Houses will cost $150"), + ("monop", "How many houses do you wish to buy for"), + ("monop", "Kentucky ave. (R) (3): "), + ("merp", ".5"), + ("monop", "That's too many. The most you can buy is 2"), + ("monop", "Kentucky ave. (R) (3): "), + ("merp", ".2"), + ("monop", "Indiana ave. (R) (3): "), + ("merp", ".2"), + ("monop", "Illinois ave. (R) (3): "), + ("merp", ".2"), + ("monop", "You asked for 6 houses for $900"), + ("monop", "Is that ok? "), + ("merp", ".y"), + ]) + self.assertEqual(result["changes"], {21: 5, 23: 5, 24: 5}) + + def test_resets_after_completion(self): + """Parser resets to idle after completion.""" + hp = HouseParser() + feed_lines(hp, [ + ("monop", "Houses will cost $50"), + ("monop", "How many houses do you wish to buy for"), + ("monop", "Mediterranean ave. (P) (0): "), + ("fbs", ".1"), + ("monop", "Baltic ave. (P) (0): "), + ("fbs", ".1"), + ("monop", "You asked for 2 houses for $100"), + ("monop", "Is that ok? "), + ("fbs", ".y"), + ]) + self.assertFalse(hp.active) + self.assertEqual(hp.state, "idle") + + +class TestRealLog(unittest.TestCase): + """Replay real log segments through the sub-parser.""" + + def _replay_segment(self, start, end): + """Replay lines start..end from test log through HouseParser.""" + hp = HouseParser() + results = [] + with open("test_data/monop.log") as f: + for i, line in enumerate(f, 1): + if i < start: + continue + if i > end: + break + parts = line.rstrip('\n').split('\t', 2) + if len(parts) < 3: + continue + sender = parts[1].strip().lstrip('@') + msg = parts[2] + r = hp.feed(sender, msg) + if r is not None: + results.append(r) + return results, hp + + def test_real_buy_line_59(self): + """Real log: merp buys 1+1+2 houses on Red (lines 59-73).""" + results, hp = self._replay_segment(59, 73) + self.assertEqual(len(results), 1) + r = results[0] + self.assertEqual(r["action"], "buy") + # Kentucky=21, Indiana=23, Illinois=24 + self.assertEqual(r["changes"], {21: 1, 23: 1, 24: 2}) + self.assertEqual(r["cost"], 600) + + def test_real_sell_line_354(self): + """Real log: merp sells 1+1+2 houses from Red (lines 354-366).""" + results, hp = self._replay_segment(354, 366) + self.assertEqual(len(results), 1) + r = results[0] + self.assertEqual(r["action"], "sell") + self.assertEqual(r["changes"], {21: 0, 23: 0, 24: 0}) + + def test_real_sell_hotel_line_4386(self): + """Real log: Derecho sells 1 hotel from Oriental (lines 4386-4399).""" + results, hp = self._replay_segment(4386, 4399) + self.assertEqual(len(results), 1) + r = results[0] + self.assertEqual(r["action"], "sell") + # Oriental (6): H(5) - 1 = 4 + self.assertEqual(r["changes"][6], 4) + + +class TestActiveState(unittest.TestCase): + """Test the active property.""" + + def test_not_active_initially(self): + hp = HouseParser() + self.assertFalse(hp.active) + + def test_active_during_prompts(self): + hp = HouseParser() + hp.feed("monop", "Houses will cost $150") + hp.feed("monop", "How many houses do you wish to buy for") + self.assertTrue(hp.active) + + def test_active_during_confirm(self): + hp = HouseParser() + feed_lines(hp, [ + ("monop", "Houses will cost $50"), + ("monop", "How many houses do you wish to buy for"), + ("monop", "Mediterranean ave. (P) (0): "), + ("fbs", ".1"), + ("monop", "Baltic ave. (P) (0): "), + ("fbs", ".1"), + ("monop", "You asked for 2 houses for $100"), + ]) + self.assertTrue(hp.active) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/test_parser_commands.py b/test_parser_commands.py index 8a013bf..145ae1b 100644 --- a/test_parser_commands.py +++ b/test_parser_commands.py @@ -288,5 +288,101 @@ class TestRealLogUnmortgage(unittest.TestCase): "Indiana ave should be unmortgaged after .unm at line 36") +# ===================================================================== +# House buying/selling integration (via HouseParser sub-parser) +# ===================================================================== +class TestHouseIntegration(unittest.TestCase): + + def test_buy_houses_updates_state(self): + """Full house buying flow updates property_houses in main parser.""" + p = setup_3player_game() + g = p.game + give_properties(g, 3, [1, 3]) # fbs owns purple monopoly + + feed(p, [ + "monop\tMediterranean ave. (P) (0) Baltic ave. (P) (0) ", + "monop\tHouses will cost $50", + "monop\tHow many houses do you wish to buy for", + "monop\tMediterranean ave. (P) (0): ", + "fbs\t.2", + "monop\tBaltic ave. (P) (0): ", + "fbs\t.2", + "monop\tYou asked for 4 houses for $200", + "monop\tIs that ok? ", + "fbs\t.y", + "monop\tfbs (3) (cash $1300) on === GO ===", + "monop\t-- Command: ", + ]) + + self.assertEqual(g.property_houses.get(1), 2) # Mediterranean + self.assertEqual(g.property_houses.get(3), 2) # Baltic + + def test_sell_houses_updates_state(self): + """Full house selling flow updates property_houses.""" + p = setup_3player_game() + g = p.game + give_properties(g, 3, [21, 23, 24]) # fbs owns Red + g.property_houses[21] = 2 + g.property_houses[23] = 2 + g.property_houses[24] = 3 + + feed(p, [ + "monop\tHouses will get you $75 apiece", + "monop\tKentucky ave. (R) (2) Indiana ave. (R) (2) Illinois ave. (R) (3) ", + "monop\tHow many houses do you wish to sell from", + "monop\tKentucky ave. (R) (2): ", + "fbs\t.2", + "monop\tIndiana ave. (R) (2): ", + "fbs\t.2", + "monop\tIllinois ave. (R) (3): ", + "fbs\t.3", + "monop\tYou asked to sell 7 houses for $525", + "monop\tIs that ok? ", + "fbs\t.y", + "monop\tfbs (3) (cash $2025) on === GO ===", + "monop\t-- Command: ", + ]) + + self.assertNotIn(21, g.property_houses) # all gone + self.assertNotIn(23, g.property_houses) + self.assertNotIn(24, g.property_houses) + + def test_buy_rejected_no_change(self): + """Rejected house purchase doesn't change state.""" + p = setup_3player_game() + g = p.game + give_properties(g, 3, [1, 3]) + + feed(p, [ + "monop\tHouses will cost $50", + "monop\tHow many houses do you wish to buy for", + "monop\tMediterranean ave. (P) (0): ", + "fbs\t.2", + "monop\tBaltic ave. (P) (0): ", + "fbs\t.2", + "monop\tYou asked for 4 houses for $200", + "monop\tIs that ok? ", + "fbs\t.n", + ]) + + self.assertNotIn(1, g.property_houses) + self.assertNotIn(3, g.property_houses) + + def test_real_log_house_buy_line_59(self): + """Real log: merp buys houses on Red at line 59-73.""" + p = MonopParser() + with open("test_data/monop.log") as f: + for i, line in enumerate(f, 1): + p.parse_line(line.rstrip('\n')) + if i >= 73: + break + + g = p.game + # Kentucky(21)=1, Indiana(23)=1, Illinois(24)=2 + self.assertEqual(g.property_houses.get(21), 1) + self.assertEqual(g.property_houses.get(23), 1) + self.assertEqual(g.property_houses.get(24), 2) + + if __name__ == "__main__": unittest.main(verbosity=2)