Add house buying/selling sub-parser with full integration
New file: house_parser.py — self-contained state machine for the interactive
house buy/sell dialog. Parses:
- Property prompts ('PropName (N): ') to capture current house count
- Player responses (how many to buy/sell)
- Auto-skipped prompts (hotel during buy, 0 houses during sell)
- Error retries ('spread too wide', 'too many')
- Confirmation ('Is that ok?' → y/n)
Returns a result dict with {action, changes: {sq_id: new_count}, cost}
when the dialog completes.
Integration: monop_parser feeds every line (bot + player) to HouseParser.
On result, applies house count changes to property_houses.
Tests:
- test_house_parser.py (18 tests): buy/sell basics, hotel, error retry,
real log replay (lines 59, 354, 4386)
- test_parser_commands.py: 4 new integration tests (buy, sell, reject,
real log verification)
This closes the last tracking gap — house counts are now accurate at
purchase time, not just when rent is later charged.
This commit is contained in:
parent
22e98794d3
commit
ea99d36657
6 changed files with 998 additions and 0 deletions
252
house_parser.py
Normal file
252
house_parser.py
Normal file
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
252
plugins/monop/house_parser.py
Normal file
252
plugins/monop/house_parser.py
Normal file
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
346
test_house_parser.py
Normal file
346
test_house_parser.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue