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.
252 lines
8.9 KiB
Python
252 lines
8.9 KiB
Python
"""
|
|
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,
|
|
}
|