Parse player commands and holdings displays for full state tracking
Player command tracking: - .card: detect GOJF card usage in jail (previously invisible) - .mortgage/.mor: set property_mortgaged flag using cost+name disambiguation - .unmortgage/.unm: clear property_mortgaged flag using cost+name disambiguation - Command context (_command_context) disambiguates 'That cost you $X' between jail pay and unmortgage Holdings display parsing: - Parse 'NAME's (N) holdings' header, then printsq-format property lines - Full resync of property_owner, property_mortgaged, property_houses - Reuses resolve_trade_property() for name matching - Holdings end on any non-property line (checkpoint, command prompt, etc.) 13 new tests in test_parser_commands.py: - GOJF card (4): exit jail, no card, not in jail, log entry - Unmortgage (2): unique property, disambiguation with user input - Mortgage (1): flag set correctly - Holdings (5): ownership, mortgage, houses, stale clearing, real log replay - Real log (1): unmortgage at line 36-41 All 1551 checkpoints + 80 unit tests passing.
This commit is contained in:
parent
1aadcddabe
commit
22e98794d3
3 changed files with 602 additions and 8 deletions
159
monop_parser.py
159
monop_parser.py
|
|
@ -225,6 +225,9 @@ class MonopParser:
|
|||
# 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$')
|
||||
|
|
@ -260,6 +263,9 @@ class MonopParser:
|
|||
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
|
||||
|
|
@ -273,6 +279,51 @@ class MonopParser:
|
|||
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
|
||||
|
|
@ -320,6 +371,7 @@ class MonopParser:
|
|||
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)
|
||||
|
|
@ -833,16 +885,23 @@ class MonopParser:
|
|||
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 =====
|
||||
# "That cost you $X" - but also used for jail pay
|
||||
# ===== 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 cp and not cp.in_jail:
|
||||
if self._command_context == "unmortgage" and cp:
|
||||
cp.money -= amount
|
||||
elif cp and cp.in_jail and amount == 50:
|
||||
# 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
|
||||
|
|
@ -1011,10 +1070,45 @@ class MonopParser:
|
|||
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
|
||||
|
|
@ -1141,6 +1235,63 @@ class MonopParser:
|
|||
g.game_active = False
|
||||
return
|
||||
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -225,6 +225,9 @@ class MonopParser:
|
|||
# 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$')
|
||||
|
|
@ -260,6 +263,9 @@ class MonopParser:
|
|||
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
|
||||
|
|
@ -273,6 +279,51 @@ class MonopParser:
|
|||
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
|
||||
|
|
@ -320,6 +371,7 @@ class MonopParser:
|
|||
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)
|
||||
|
|
@ -833,16 +885,23 @@ class MonopParser:
|
|||
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 =====
|
||||
# "That cost you $X" - but also used for jail pay
|
||||
# ===== 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 cp and not cp.in_jail:
|
||||
if self._command_context == "unmortgage" and cp:
|
||||
cp.money -= amount
|
||||
elif cp and cp.in_jail and amount == 50:
|
||||
# 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
|
||||
|
|
@ -1011,10 +1070,45 @@ class MonopParser:
|
|||
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
|
||||
|
|
@ -1141,6 +1235,63 @@ class MonopParser:
|
|||
g.game_active = False
|
||||
return
|
||||
|
||||
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:
|
||||
|
|
|
|||
292
test_parser_commands.py
Normal file
292
test_parser_commands.py
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Tests for player command tracking and holdings display parsing."""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from monop_parser import MonopParser
|
||||
|
||||
TS = "2026-01-01 00:00:00"
|
||||
|
||||
|
||||
def feed(parser, lines):
|
||||
for line in lines:
|
||||
parser.parse_line(f"{TS}\t{line}")
|
||||
|
||||
|
||||
def setup_3player_game():
|
||||
"""fbs goes first (turn order: fbs→merp→hiro)."""
|
||||
p = MonopParser()
|
||||
feed(p, [
|
||||
"monop\tHow many players? ",
|
||||
"monop\tPlayer 1, say 'me' please.",
|
||||
"monop\tmerp (1) rolls 5",
|
||||
"monop\tPlayer 2, say 'me' please.",
|
||||
"monop\thiro (2) rolls 3",
|
||||
"monop\tPlayer 3, say 'me' please.",
|
||||
"monop\tfbs (3) rolls 8",
|
||||
"monop\tfbs (3) goes first",
|
||||
"monop\tfbs (3) (cash $1500) on === GO ===",
|
||||
"monop\t-- Command: ",
|
||||
])
|
||||
return p
|
||||
|
||||
|
||||
def give_properties(game, player_num, sq_ids):
|
||||
for sq_id in sq_ids:
|
||||
game.property_owner[sq_id] = player_num
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# GOJF card usage from player command
|
||||
# =====================================================================
|
||||
class TestGOJFCardCommand(unittest.TestCase):
|
||||
|
||||
def test_card_command_exits_jail(self):
|
||||
"""Player types .card while in jail with GOJF card."""
|
||||
p = setup_3player_game()
|
||||
g = p.game
|
||||
# Put fbs in jail with a GOJF card
|
||||
fbs = g.get_player(name="fbs")
|
||||
fbs.in_jail = True
|
||||
fbs.jail_turns = 1
|
||||
fbs.location = 40
|
||||
fbs.get_out_of_jail_free_cards = 1
|
||||
|
||||
feed(p, ["fbs\t.card"])
|
||||
|
||||
self.assertFalse(fbs.in_jail)
|
||||
self.assertEqual(fbs.location, 10) # Just Visiting
|
||||
self.assertEqual(fbs.get_out_of_jail_free_cards, 0)
|
||||
self.assertEqual(fbs.jail_turns, 0)
|
||||
|
||||
def test_card_command_no_gojf(self):
|
||||
"""Player types .card but has no GOJF cards — nothing happens."""
|
||||
p = setup_3player_game()
|
||||
g = p.game
|
||||
fbs = g.get_player(name="fbs")
|
||||
fbs.in_jail = True
|
||||
fbs.location = 40
|
||||
fbs.get_out_of_jail_free_cards = 0
|
||||
|
||||
feed(p, ["fbs\t.card"])
|
||||
|
||||
# Should still be in jail
|
||||
self.assertTrue(fbs.in_jail)
|
||||
self.assertEqual(fbs.location, 40)
|
||||
|
||||
def test_card_command_not_in_jail(self):
|
||||
"""Player types .card but isn't in jail — nothing happens."""
|
||||
p = setup_3player_game()
|
||||
g = p.game
|
||||
fbs = g.get_player(name="fbs")
|
||||
fbs.get_out_of_jail_free_cards = 1
|
||||
|
||||
feed(p, ["fbs\t.card"])
|
||||
|
||||
# GOJF card should not be consumed
|
||||
self.assertEqual(fbs.get_out_of_jail_free_cards, 1)
|
||||
|
||||
def test_card_log_entry(self):
|
||||
p = setup_3player_game()
|
||||
g = p.game
|
||||
fbs = g.get_player(name="fbs")
|
||||
fbs.in_jail = True
|
||||
fbs.location = 40
|
||||
fbs.get_out_of_jail_free_cards = 2
|
||||
|
||||
feed(p, ["fbs\t.card"])
|
||||
|
||||
log_texts = [e["text"] for e in g.log]
|
||||
self.assertTrue(any("Get Out of Jail" in t for t in log_texts))
|
||||
self.assertEqual(fbs.get_out_of_jail_free_cards, 1)
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Unmortgage tracking via command context
|
||||
# =====================================================================
|
||||
class TestUnmortgageTracking(unittest.TestCase):
|
||||
|
||||
def test_unmortgage_clears_flag(self):
|
||||
"""Player unmortgages a uniquely-identifiable property."""
|
||||
p = setup_3player_game()
|
||||
g = p.game
|
||||
# fbs owns Boardwalk (39), mortgaged
|
||||
give_properties(g, 3, [39])
|
||||
g.property_mortgaged[39] = True
|
||||
|
||||
feed(p, [
|
||||
"fbs\t.unm",
|
||||
"monop\tYour only mortaged property is Boardwalk (D)",
|
||||
"monop\tDo you want to unmortgage it? ",
|
||||
"fbs\t.y",
|
||||
"monop\tThat cost you $220", # 400/2 + 400/2/10 = 220
|
||||
"monop\tfbs (3) (cash $1280) on === GO ===",
|
||||
"monop\t-- Command: ",
|
||||
])
|
||||
|
||||
self.assertNotIn(39, g.property_mortgaged)
|
||||
|
||||
def test_unmortgage_with_disambiguation(self):
|
||||
"""Player unmortgages one of two same-cost properties."""
|
||||
p = setup_3player_game()
|
||||
g = p.game
|
||||
# fbs owns both Kentucky (21) and Indiana (23), both mortgaged
|
||||
# Both cost $220 → unmortgage cost $121 each
|
||||
give_properties(g, 3, [21, 23])
|
||||
g.property_mortgaged[21] = True
|
||||
g.property_mortgaged[23] = True
|
||||
|
||||
feed(p, [
|
||||
"fbs\t.unm",
|
||||
"monop\tWhich property do you want to unmortgage? ",
|
||||
"fbs\t.indiana", # user specifies Indiana
|
||||
"monop\tThat cost you $121",
|
||||
"monop\tfbs (3) (cash $1379) on === GO ===",
|
||||
"monop\t-- Command: ",
|
||||
])
|
||||
|
||||
# Indiana unmortgaged, Kentucky still mortgaged
|
||||
self.assertNotIn(23, g.property_mortgaged)
|
||||
self.assertTrue(g.property_mortgaged.get(21))
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Mortgage tracking
|
||||
# =====================================================================
|
||||
class TestMortgageTracking(unittest.TestCase):
|
||||
|
||||
def test_mortgage_sets_flag(self):
|
||||
"""Player mortgages a property — flag should be set."""
|
||||
p = setup_3player_game()
|
||||
g = p.game
|
||||
give_properties(g, 3, [39]) # Boardwalk
|
||||
|
||||
feed(p, [
|
||||
"fbs\t.mor",
|
||||
"monop\tWhich property do you want to mortgage? ",
|
||||
"fbs\t.boardwalk",
|
||||
"monop\tThat got you $200", # 400/2
|
||||
"monop\tfbs (3) (cash $1700) on === GO ===",
|
||||
"monop\t-- Command: ",
|
||||
])
|
||||
|
||||
self.assertTrue(g.property_mortgaged.get(39))
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Holdings display parsing
|
||||
# =====================================================================
|
||||
class TestHoldingsDisplay(unittest.TestCase):
|
||||
|
||||
def test_holdings_syncs_properties(self):
|
||||
"""Holdings display should resync property ownership."""
|
||||
p = setup_3player_game()
|
||||
g = p.game
|
||||
|
||||
feed(p, [
|
||||
"monop\tmerp's (1) holdings (Total worth: $1806):",
|
||||
"monop\t Name Own Price Mg # Rent",
|
||||
"monop\t Electric C 1 150 1",
|
||||
"monop\t New York a 1 Orange 200 16",
|
||||
"monop\t Kentucky a 1 RED 220 0 36",
|
||||
"monop\tmerp (1) (cash $496) on Community Chest ii",
|
||||
"monop\t-- Command: ",
|
||||
])
|
||||
|
||||
# Electric Co (12), New York ave (19), Kentucky ave (21) should be owned by merp (1)
|
||||
self.assertEqual(g.property_owner.get(12), 1)
|
||||
self.assertEqual(g.property_owner.get(19), 1)
|
||||
self.assertEqual(g.property_owner.get(21), 1)
|
||||
|
||||
def test_holdings_syncs_mortgage_status(self):
|
||||
"""Holdings display should set/clear mortgage flags."""
|
||||
p = setup_3player_game()
|
||||
g = p.game
|
||||
|
||||
feed(p, [
|
||||
"monop\tmerp's (1) holdings (Total worth: $1806):",
|
||||
"monop\t Name Own Price Mg # Rent",
|
||||
"monop\t Indiana av 1 RED 220 * 0 36",
|
||||
"monop\t Illinois a 1 RED 240 0 40",
|
||||
"monop\tmerp (1) (cash $496) on Community Chest ii",
|
||||
"monop\t-- Command: ",
|
||||
])
|
||||
|
||||
self.assertTrue(g.property_mortgaged.get(23)) # Indiana mortgaged
|
||||
self.assertNotIn(24, g.property_mortgaged) # Illinois not mortgaged
|
||||
|
||||
def test_holdings_syncs_house_counts(self):
|
||||
"""Holdings display should update house counts."""
|
||||
p = setup_3player_game()
|
||||
g = p.game
|
||||
|
||||
feed(p, [
|
||||
"monop\tfbs's (3) holdings (Total worth: $5000):",
|
||||
"monop\t Name Own Price Mg # Rent",
|
||||
"monop\t Kentucky a 3 RED 220 3 180",
|
||||
"monop\t Indiana av 3 RED 220 3 180",
|
||||
"monop\t Illinois a 3 RED 240 4 220",
|
||||
"monop\tfbs (3) (cash $2000) on === GO ===",
|
||||
"monop\t-- Command: ",
|
||||
])
|
||||
|
||||
self.assertEqual(g.property_houses.get(21), 3) # Kentucky 3 houses
|
||||
self.assertEqual(g.property_houses.get(23), 3) # Indiana 3 houses
|
||||
self.assertEqual(g.property_houses.get(24), 4) # Illinois 4 houses
|
||||
|
||||
def test_holdings_clears_stale_houses(self):
|
||||
"""If holdings shows 0 houses, clear any stale house count."""
|
||||
p = setup_3player_game()
|
||||
g = p.game
|
||||
g.property_houses[21] = 3 # stale: Kentucky had 3 houses
|
||||
|
||||
feed(p, [
|
||||
"monop\tmerp's (1) holdings (Total worth: $1000):",
|
||||
"monop\t Name Own Price Mg # Rent",
|
||||
"monop\t Kentucky a 1 RED 220 0 36",
|
||||
"monop\tmerp (1) (cash $500) on === GO ===",
|
||||
"monop\t-- Command: ",
|
||||
])
|
||||
|
||||
self.assertNotIn(21, g.property_houses) # houses cleared
|
||||
|
||||
def test_real_log_holdings_line_26(self):
|
||||
"""Replay real holdings display from line 26 of test log."""
|
||||
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 >= 35:
|
||||
break
|
||||
|
||||
g = p.game
|
||||
self.assertIsNotNone(g)
|
||||
# merp (1) should own Electric Co (12) and others from the holdings dump
|
||||
self.assertEqual(g.property_owner.get(12), 1) # Electric Co
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Real log: unmortgage at line 36-41
|
||||
# =====================================================================
|
||||
class TestRealLogUnmortgage(unittest.TestCase):
|
||||
|
||||
def test_real_unmortgage(self):
|
||||
"""Real log: merp unmortgages Indiana ave around line 36-41."""
|
||||
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 >= 42:
|
||||
break
|
||||
|
||||
g = p.game
|
||||
# Indiana ave (23) should NOT be mortgaged after unmortgage
|
||||
self.assertFalse(g.property_mortgaged.get(23, False),
|
||||
"Indiana ave should be unmortgaged after .unm at line 36")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
Loading…
Reference in a new issue