Fix 7 bugs: trade property transfer, UI owner colors, resign edge cases, spec flag, phase field, timestamps

Bug fixes:
- #1: Trade now transfers properties (parse printsq-format lines in trade summaries)
- #2: UI owner indicator uses player number lookup instead of raw array index
- #3: Mid-stream game pickup sets _first_player_idx
- #4: Resign with unresolved target clears properties (bank fallback)
- #6: spec flag cleared after rent payment (matches C's get_card cleanup)
- #13: get_state() always emits phase field (setup/playing/over)
- #16: Resign and trade log entries include timestamps

Also:
- Bankrupt players tracked with bankrupt flag, shown in UI with skull/dashed border
- 19 resignation tests (test_parser_resign.py)
- 10 bug-specific tests (test_parser_bugs.py)
- All 1551 parser checkpoints + 67 unit tests passing
This commit is contained in:
Jarvis 2026-02-21 18:52:10 +00:00
parent 9c47bac33a
commit 1cb6da257f
5 changed files with 892 additions and 30 deletions

View file

@ -60,6 +60,28 @@ SQUARE_BY_NAME["JAIL"] = 40 # Special: in-jail location
SQUARE_NAME_BY_ID = {sq["id"]: sq["name"] for sq in BOARD}
SQUARE_NAME_BY_ID[40] = "JAIL"
# Truncated name (10 chars, like C's printsq) + cost -> square id
# Used to identify properties in trade summary lines
_TRUNC_COST_TO_ID = {}
_TRUNC_TO_IDS = {} # trunc -> list of sq_ids (for ambiguous names)
for _sq in BOARD:
if _sq["type"] in ("property", "railroad", "utility"):
_trunc = _sq["name"][:10].rstrip()
_TRUNC_COST_TO_ID[(_trunc, _sq["cost"])] = _sq["id"]
_TRUNC_TO_IDS.setdefault(_trunc, []).append(_sq["id"])
def resolve_trade_property(trunc_name, cost=None):
"""Resolve a truncated property name (+ optional cost) to a square id."""
if cost is not None:
sq_id = _TRUNC_COST_TO_ID.get((trunc_name, cost))
if sq_id is not None:
return sq_id
ids = _TRUNC_TO_IDS.get(trunc_name, [])
if len(ids) == 1:
return ids[0]
return None
class Player:
def __init__(self, name, number):
@ -73,7 +95,7 @@ class Player:
self.get_out_of_jail_free_cards = 0
def to_dict(self):
return {
d = {
"name": self.name,
"number": self.number,
"money": self.money,
@ -83,6 +105,9 @@ class Player:
"doublesCount": self.doubles_count,
"getOutOfJailFreeCards": self.get_out_of_jail_free_cards,
}
if getattr(self, 'bankrupt', False):
d["bankrupt"] = True
return d
class GameState:
@ -93,6 +118,7 @@ class GameState:
self.current_player_idx = 0 # 0-based index into players list
self.phase = "setup" # setup, playing, over
self.squares = copy.deepcopy(BOARD)
self.bankrupt_players = [] # Players who resigned/went bankrupt
# Property ownership tracking
self.property_owner = {} # square_id -> player_number (1-based)
self.property_mortgaged = {} # square_id -> bool
@ -196,6 +222,10 @@ class MonopParser:
TRADE_CASH_RE = re.compile(r'^\s+\$(\d+)$')
TRADE_GOJF_RE = re.compile(r'^\s+(\d+) get-out-of-jail-free card\(s\)$')
TRADE_NOTHING_RE = re.compile(r'^\s+-- Nothing --$')
# Trade property line: " NAME OWNER GROUP COST ..."
# C's printsq uses %-10.10s for name, then owner+1, group, cost
TRADE_PROP_RE = re.compile(r'^\s(.{10})\s+(\d+)\s+\S*\s+(\d+)')
SOLVENT_RE = re.compile(r'^-- You are now Solvent ---$')
DEBT_RE = re.compile(r'^That leaves you \$(\d+) in debt$')
BROKE_RE = re.compile(r'^that leaves you broke$')
@ -218,6 +248,8 @@ class MonopParser:
self._trade_cash2 = 0
self._trade_gojf1 = 0
self._trade_gojf2 = 0
self._trade_props1 = [] # list of square_ids player1 is giving
self._trade_props2 = [] # list of square_ids player2 is giving
# State for income tax
self._inc_tax_pending = False
self._inc_tax_worth = 0
@ -449,6 +481,7 @@ class MonopParser:
p.in_jail = True
g.players.append(p)
g.current_player_idx = 0
g._first_player_idx = 0 # assume first seen is first
self.checkpoints_validated += 1
return
@ -870,11 +903,13 @@ class MonopParser:
self._trade_player1 = (name, num)
self._trade_cash1 = 0
self._trade_gojf1 = 0
self._trade_props1 = []
elif self._trade_state == 'gives1':
self._trade_state = 'gives2'
self._trade_player2 = (name, num)
self._trade_cash2 = 0
self._trade_gojf2 = 0
self._trade_props2 = []
return
m = self.TRADE_CASH_RE.match(msg_raw)
@ -895,14 +930,28 @@ class MonopParser:
self._trade_gojf2 = gojf
return
# Trade property line (indented printsq output)
if self._trade_state:
m_tp = self.TRADE_PROP_RE.match(msg_raw)
if m_tp:
trunc_name = m_tp.group(1).rstrip()
cost = int(m_tp.group(3))
sq_id = resolve_trade_property(trunc_name, cost)
if sq_id is not None:
if self._trade_state == 'gives1':
self._trade_props1.append(sq_id)
elif self._trade_state == 'gives2':
self._trade_props2.append(sq_id)
return
if self.TRADE_NOTHING_RE.match(msg_raw) and self._trade_state:
return
if self.TRADE_DONE_RE.match(msg):
if hasattr(self, '_resign_pending') and self._resign_pending:
self._execute_resign_to_player()
self._execute_resign_to_player(timestamp=timestamp)
else:
self._execute_trade()
self._execute_trade(timestamp=timestamp)
return
# ===== RESIGN =====
@ -912,8 +961,13 @@ class MonopParser:
return
if self.RESIGN_TO_BANK_RE.match(msg):
# Player resigns to bank - remove them
# Player resigns to bank - properties go back to unowned
if cp:
for sq_id, owner_num in list(g.property_owner.items()):
if owner_num == cp.number:
del g.property_owner[sq_id]
g.property_mortgaged.pop(sq_id, None)
g.property_houses.pop(sq_id, None)
g.add_log(f"{cp.name} resigned to the bank", player=cp.name, timestamp=timestamp)
self._remove_player(cp)
return
@ -1098,6 +1152,7 @@ class MonopParser:
else:
g.add_log(f"Paid ${amount} rent", player=cp.name)
g.pending_rent_owner = None
g.spec = False # Clear spec flag after rent (matches C's get_card cleanup)
def _process_card(self, lines):
"""Process card text after both separators have been seen."""
@ -1196,7 +1251,7 @@ class MonopParser:
elif "Advance to St. Charles" in text:
pass # Movement handled
def _execute_trade(self):
def _execute_trade(self, timestamp=None):
"""Execute a completed trade."""
g = self.game
if not g:
@ -1214,12 +1269,20 @@ class MonopParser:
p2.get_out_of_jail_free_cards += self._trade_gojf1
p2.get_out_of_jail_free_cards -= self._trade_gojf2
p1.get_out_of_jail_free_cards += self._trade_gojf2
g.add_log(f"Trade completed between {p1.name} and {p2.name}")
# p1 gives properties to p2
for sq_id in self._trade_props1:
g.property_owner[sq_id] = p2.number
# p2 gives properties to p1
for sq_id in self._trade_props2:
g.property_owner[sq_id] = p1.number
g.add_log(f"Trade completed between {p1.name} and {p2.name}", timestamp=timestamp)
self._trade_state = None
self._trade_player1 = None
self._trade_player2 = None
self._trade_props1 = []
self._trade_props2 = []
def _execute_resign_to_player(self):
def _execute_resign_to_player(self, timestamp=None):
"""Handle resign-to-player: transfer all assets then remove player."""
g = self.game
if not g:
@ -1233,11 +1296,25 @@ class MonopParser:
if hasattr(self, '_resign_target') and self._resign_target:
target = g.get_player(name=self._resign_target)
self._resign_target = None
if target and cp.money > 0:
target.money += cp.money
if target:
# Transfer money
if cp.money > 0:
target.money += cp.money
# Transfer GOJF cards
target.get_out_of_jail_free_cards += cp.get_out_of_jail_free_cards
g.add_log(f"{cp.name} resigned to {target.name if target else 'bank'}", player=cp.name)
# Transfer properties
for sq_id, owner_num in list(g.property_owner.items()):
if owner_num == cp.number:
g.property_owner[sq_id] = target.number
g.add_log(f"{cp.name} resigned to {target.name}", player=cp.name, timestamp=timestamp)
else:
# No target found — treat as bank resignation
for sq_id, owner_num in list(g.property_owner.items()):
if owner_num == cp.number:
del g.property_owner[sq_id]
g.property_mortgaged.pop(sq_id, None)
g.property_houses.pop(sq_id, None)
g.add_log(f"{cp.name} resigned to the bank", player=cp.name, timestamp=timestamp)
self._remove_player(cp)
def _remove_player(self, player):
@ -1246,10 +1323,20 @@ class MonopParser:
if not g:
return
idx = g.players.index(player)
player.bankrupt = True
g.bankrupt_players.append(player)
g.players.remove(player)
# Build old->new number mapping before renumbering
old_numbers = {p: p.number for p in g.players}
# Renumber remaining players (C code shifts array)
for i, p in enumerate(g.players):
p.number = i + 1
# Update property_owner references to match new numbers
number_map = {old_numbers[p]: p.number for p in g.players}
for sq_id in list(g.property_owner.keys()):
old_num = g.property_owner[sq_id]
if old_num in number_map:
g.property_owner[sq_id] = number_map[old_num]
# C code: player = --player < 0 ? num_play - 1 : player
# then next_play() increments to next
# After removal, the C code decrements player index then calls next_play
@ -1301,12 +1388,17 @@ class MonopParser:
else:
ordered = g.players
return {
"players": [p.to_dict() for p in ordered],
# Append bankrupt players at the end
all_players = [p.to_dict() for p in ordered] + [p.to_dict() for p in g.bankrupt_players]
result = {
"players": all_players,
"currentPlayer": g.current_player.number if g.current_player else None,
"squares": squares,
"log": g.log[-30:],
}
result["phase"] = g.phase # "playing" or "over"
return result
def parse_log(filepath):

View file

@ -60,6 +60,28 @@ SQUARE_BY_NAME["JAIL"] = 40 # Special: in-jail location
SQUARE_NAME_BY_ID = {sq["id"]: sq["name"] for sq in BOARD}
SQUARE_NAME_BY_ID[40] = "JAIL"
# Truncated name (10 chars, like C's printsq) + cost -> square id
# Used to identify properties in trade summary lines
_TRUNC_COST_TO_ID = {}
_TRUNC_TO_IDS = {} # trunc -> list of sq_ids (for ambiguous names)
for _sq in BOARD:
if _sq["type"] in ("property", "railroad", "utility"):
_trunc = _sq["name"][:10].rstrip()
_TRUNC_COST_TO_ID[(_trunc, _sq["cost"])] = _sq["id"]
_TRUNC_TO_IDS.setdefault(_trunc, []).append(_sq["id"])
def resolve_trade_property(trunc_name, cost=None):
"""Resolve a truncated property name (+ optional cost) to a square id."""
if cost is not None:
sq_id = _TRUNC_COST_TO_ID.get((trunc_name, cost))
if sq_id is not None:
return sq_id
ids = _TRUNC_TO_IDS.get(trunc_name, [])
if len(ids) == 1:
return ids[0]
return None
class Player:
def __init__(self, name, number):
@ -73,7 +95,7 @@ class Player:
self.get_out_of_jail_free_cards = 0
def to_dict(self):
return {
d = {
"name": self.name,
"number": self.number,
"money": self.money,
@ -83,6 +105,9 @@ class Player:
"doublesCount": self.doubles_count,
"getOutOfJailFreeCards": self.get_out_of_jail_free_cards,
}
if getattr(self, 'bankrupt', False):
d["bankrupt"] = True
return d
class GameState:
@ -93,6 +118,7 @@ class GameState:
self.current_player_idx = 0 # 0-based index into players list
self.phase = "setup" # setup, playing, over
self.squares = copy.deepcopy(BOARD)
self.bankrupt_players = [] # Players who resigned/went bankrupt
# Property ownership tracking
self.property_owner = {} # square_id -> player_number (1-based)
self.property_mortgaged = {} # square_id -> bool
@ -196,6 +222,10 @@ class MonopParser:
TRADE_CASH_RE = re.compile(r'^\s+\$(\d+)$')
TRADE_GOJF_RE = re.compile(r'^\s+(\d+) get-out-of-jail-free card\(s\)$')
TRADE_NOTHING_RE = re.compile(r'^\s+-- Nothing --$')
# Trade property line: " NAME OWNER GROUP COST ..."
# C's printsq uses %-10.10s for name, then owner+1, group, cost
TRADE_PROP_RE = re.compile(r'^\s(.{10})\s+(\d+)\s+\S*\s+(\d+)')
SOLVENT_RE = re.compile(r'^-- You are now Solvent ---$')
DEBT_RE = re.compile(r'^That leaves you \$(\d+) in debt$')
BROKE_RE = re.compile(r'^that leaves you broke$')
@ -218,6 +248,8 @@ class MonopParser:
self._trade_cash2 = 0
self._trade_gojf1 = 0
self._trade_gojf2 = 0
self._trade_props1 = [] # list of square_ids player1 is giving
self._trade_props2 = [] # list of square_ids player2 is giving
# State for income tax
self._inc_tax_pending = False
self._inc_tax_worth = 0
@ -449,6 +481,7 @@ class MonopParser:
p.in_jail = True
g.players.append(p)
g.current_player_idx = 0
g._first_player_idx = 0 # assume first seen is first
self.checkpoints_validated += 1
return
@ -870,11 +903,13 @@ class MonopParser:
self._trade_player1 = (name, num)
self._trade_cash1 = 0
self._trade_gojf1 = 0
self._trade_props1 = []
elif self._trade_state == 'gives1':
self._trade_state = 'gives2'
self._trade_player2 = (name, num)
self._trade_cash2 = 0
self._trade_gojf2 = 0
self._trade_props2 = []
return
m = self.TRADE_CASH_RE.match(msg_raw)
@ -895,14 +930,28 @@ class MonopParser:
self._trade_gojf2 = gojf
return
# Trade property line (indented printsq output)
if self._trade_state:
m_tp = self.TRADE_PROP_RE.match(msg_raw)
if m_tp:
trunc_name = m_tp.group(1).rstrip()
cost = int(m_tp.group(3))
sq_id = resolve_trade_property(trunc_name, cost)
if sq_id is not None:
if self._trade_state == 'gives1':
self._trade_props1.append(sq_id)
elif self._trade_state == 'gives2':
self._trade_props2.append(sq_id)
return
if self.TRADE_NOTHING_RE.match(msg_raw) and self._trade_state:
return
if self.TRADE_DONE_RE.match(msg):
if hasattr(self, '_resign_pending') and self._resign_pending:
self._execute_resign_to_player()
self._execute_resign_to_player(timestamp=timestamp)
else:
self._execute_trade()
self._execute_trade(timestamp=timestamp)
return
# ===== RESIGN =====
@ -912,8 +961,13 @@ class MonopParser:
return
if self.RESIGN_TO_BANK_RE.match(msg):
# Player resigns to bank - remove them
# Player resigns to bank - properties go back to unowned
if cp:
for sq_id, owner_num in list(g.property_owner.items()):
if owner_num == cp.number:
del g.property_owner[sq_id]
g.property_mortgaged.pop(sq_id, None)
g.property_houses.pop(sq_id, None)
g.add_log(f"{cp.name} resigned to the bank", player=cp.name, timestamp=timestamp)
self._remove_player(cp)
return
@ -1098,6 +1152,7 @@ class MonopParser:
else:
g.add_log(f"Paid ${amount} rent", player=cp.name)
g.pending_rent_owner = None
g.spec = False # Clear spec flag after rent (matches C's get_card cleanup)
def _process_card(self, lines):
"""Process card text after both separators have been seen."""
@ -1196,7 +1251,7 @@ class MonopParser:
elif "Advance to St. Charles" in text:
pass # Movement handled
def _execute_trade(self):
def _execute_trade(self, timestamp=None):
"""Execute a completed trade."""
g = self.game
if not g:
@ -1214,12 +1269,20 @@ class MonopParser:
p2.get_out_of_jail_free_cards += self._trade_gojf1
p2.get_out_of_jail_free_cards -= self._trade_gojf2
p1.get_out_of_jail_free_cards += self._trade_gojf2
g.add_log(f"Trade completed between {p1.name} and {p2.name}")
# p1 gives properties to p2
for sq_id in self._trade_props1:
g.property_owner[sq_id] = p2.number
# p2 gives properties to p1
for sq_id in self._trade_props2:
g.property_owner[sq_id] = p1.number
g.add_log(f"Trade completed between {p1.name} and {p2.name}", timestamp=timestamp)
self._trade_state = None
self._trade_player1 = None
self._trade_player2 = None
self._trade_props1 = []
self._trade_props2 = []
def _execute_resign_to_player(self):
def _execute_resign_to_player(self, timestamp=None):
"""Handle resign-to-player: transfer all assets then remove player."""
g = self.game
if not g:
@ -1233,11 +1296,25 @@ class MonopParser:
if hasattr(self, '_resign_target') and self._resign_target:
target = g.get_player(name=self._resign_target)
self._resign_target = None
if target and cp.money > 0:
target.money += cp.money
if target:
# Transfer money
if cp.money > 0:
target.money += cp.money
# Transfer GOJF cards
target.get_out_of_jail_free_cards += cp.get_out_of_jail_free_cards
g.add_log(f"{cp.name} resigned to {target.name if target else 'bank'}", player=cp.name)
# Transfer properties
for sq_id, owner_num in list(g.property_owner.items()):
if owner_num == cp.number:
g.property_owner[sq_id] = target.number
g.add_log(f"{cp.name} resigned to {target.name}", player=cp.name, timestamp=timestamp)
else:
# No target found — treat as bank resignation
for sq_id, owner_num in list(g.property_owner.items()):
if owner_num == cp.number:
del g.property_owner[sq_id]
g.property_mortgaged.pop(sq_id, None)
g.property_houses.pop(sq_id, None)
g.add_log(f"{cp.name} resigned to the bank", player=cp.name, timestamp=timestamp)
self._remove_player(cp)
def _remove_player(self, player):
@ -1246,10 +1323,20 @@ class MonopParser:
if not g:
return
idx = g.players.index(player)
player.bankrupt = True
g.bankrupt_players.append(player)
g.players.remove(player)
# Build old->new number mapping before renumbering
old_numbers = {p: p.number for p in g.players}
# Renumber remaining players (C code shifts array)
for i, p in enumerate(g.players):
p.number = i + 1
# Update property_owner references to match new numbers
number_map = {old_numbers[p]: p.number for p in g.players}
for sq_id in list(g.property_owner.keys()):
old_num = g.property_owner[sq_id]
if old_num in number_map:
g.property_owner[sq_id] = number_map[old_num]
# C code: player = --player < 0 ? num_play - 1 : player
# then next_play() increments to next
# After removal, the C code decrements player index then calls next_play
@ -1301,12 +1388,17 @@ class MonopParser:
else:
ordered = g.players
return {
"players": [p.to_dict() for p in ordered],
# Append bankrupt players at the end
all_players = [p.to_dict() for p in ordered] + [p.to_dict() for p in g.bankrupt_players]
result = {
"players": all_players,
"currentPlayer": g.current_player.number if g.current_player else None,
"squares": squares,
"log": g.log[-30:],
}
result["phase"] = g.phase # "playing" or "over"
return result
def parse_log(filepath):

View file

@ -355,13 +355,17 @@ function renderBoard(state) {
el.appendChild(costEl);
}
if (sq.owner >= 0 && sq.owner < players.length) {
if (sq.owner != null && sq.owner > 0) {
// Find owner's index in the players array (which is turn-ordered)
const ownerIdx = players.findIndex(p => p.number === sq.owner);
if (ownerIdx >= 0) {
const ownerEl = document.createElement('div');
ownerEl.className = 'owner-indicator';
ownerEl.textContent = '⬤';
ownerEl.style.color = PLAYER_COLORS[sq.owner % PLAYER_COLORS.length];
ownerEl.style.color = PLAYER_COLORS[ownerIdx % PLAYER_COLORS.length];
el.appendChild(ownerEl);
}
}
if (sq.houses > 0 && sq.houses < 5) {
const hd = document.createElement('div');
@ -437,6 +441,14 @@ function renderPlayers(state) {
panel.innerHTML = `<h3><span class="player-token" style="background:${color};width:20px;height:20px;font-size:11px">${p.name.charAt(0).toUpperCase()}</span> ${p.name} ✓</h3>
<div class="money">$1,500</div>
<div style="font-size:0.8em;margin-top:4px;color:#888">Ready to play</div>`;
} else if (p.bankrupt) {
// Bankrupt player
panel.className = 'player-panel';
panel.style.opacity = '0.45';
panel.style.borderColor = '#e74c3c';
panel.style.borderStyle = 'dashed';
panel.innerHTML = `<h3><span class="player-token" style="background:#555;width:20px;height:20px;font-size:11px;text-decoration:line-through">${p.name.charAt(0).toUpperCase()}</span> <span style="text-decoration:line-through;color:#888">${p.name}</span> 💀</h3>
<div style="color:#e74c3c;font-size:0.9em;font-weight:bold">BANKRUPT</div>`;
} else {
// Normal playing state
panel.className = 'player-panel' + (p.number === state.currentPlayer ? ' current-turn' : '');

302
test_parser_bugs.py Normal file
View file

@ -0,0 +1,302 @@
#!/usr/bin/env python3
"""Tests for bugs identified in the codebase audit."""
import sys
import os
import unittest
sys.path.insert(0, os.path.dirname(__file__))
from monop_parser import MonopParser, BOARD, SQUARE_BY_NAME
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():
"""Set up a 3-player game (merp, hiro, fbs) in playing phase.
fbs goes first (turn order: fbsmerphiro)."""
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 advance_to_merp_turn(p):
"""Advance from fbs's turn to merp's turn."""
feed(p, [
"fbs\t.",
"monop\troll is 3, 4",
"monop\tThat puts you on Oriental ave. (L)",
"monop\tThat would cost $100, do you want it? ",
"fbs\t.n",
"monop\tmerp (1) (cash $1500) on === GO ===",
"monop\t-- Command: ",
])
def give_properties(game, player_num, sq_ids):
for sq_id in sq_ids:
game.property_owner[sq_id] = player_num
# =====================================================================
# Bug #1: Trade doesn't transfer properties
# =====================================================================
class TestBug1_TradeProperties(unittest.TestCase):
"""_execute_trade() only handles cash+GOJF, not property transfers."""
def test_trade_property_transfer(self):
"""hiro gives Tennessee ave to fbs for $290."""
p = setup_3player_game()
g = p.game
# hiro (2) owns Tennessee ave (sq 18)
give_properties(g, 2, [18])
# fbs's turn — initiate trade
feed(p, [
# Trade summary
"monop\tPlayer fbs (3) gives:",
"monop\t $290",
"monop\tPlayer hiro (2) gives:",
"monop\t Tennessee 2 Orange 180 * 14",
"monop\thiro, is the trade ok? ",
"hiro\t.y",
"monop\tTrade is done!",
"monop\tfbs (3) (cash $1210) on === GO ===",
"monop\t-- Command: ",
])
# Tennessee (sq 18) should now belong to fbs (3)
self.assertEqual(g.property_owner.get(18), 3,
"Tennessee ave should be transferred to fbs")
# fbs paid $290
fbs = g.get_player(name="fbs")
self.assertEqual(fbs.money, 1500 - 290)
# hiro received $290
hiro = g.get_player(name="hiro")
self.assertEqual(hiro.money, 1500 + 290)
def test_trade_multiple_properties(self):
"""xLink gives Indiana + Illinois to fbs for $550."""
p = setup_3player_game()
g = p.game
# Use merp as xLink stand-in (player 1 owns Indiana=23, Illinois=24)
give_properties(g, 1, [23, 24])
feed(p, [
"monop\tPlayer fbs (3) gives:",
"monop\t $550",
"monop\tPlayer merp (1) gives:",
"monop\t Indiana av 1 Red 220 * 18",
"monop\t Illinois a 1 Red 240 * 20",
"monop\tmerp, is the trade ok? ",
"merp\t.y",
"monop\tTrade is done!",
"monop\tfbs (3) (cash $950) on === GO ===",
"monop\t-- Command: ",
])
self.assertEqual(g.property_owner.get(23), 3, "Indiana should be fbs's")
self.assertEqual(g.property_owner.get(24), 3, "Illinois should be fbs's")
def test_real_log_trade_line_789(self):
"""Replay real log through trade at line 789 — Tennessee from hiro to fbs."""
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 >= 795:
break
g = p.game
state = p.get_state()
self.assertIsNotNone(state)
# Tennessee ave (sq 18) should be owned by fbs after the trade
# fbs is player 2 at this point in the log
owner = g.property_owner.get(18)
self.assertIsNotNone(owner, "Tennessee should be owned after trade")
# =====================================================================
# Bug #2: sq.owner uses player number but UI indexes by array position
# =====================================================================
class TestBug2_OwnerColorIndex(unittest.TestCase):
"""UI uses sq.owner as array index, but it's a 1-based player number.
This is a UI-only bug test the data contract instead."""
def test_owner_is_player_number_not_index(self):
"""Verify get_state() emits owner as player number (1-based)."""
p = setup_3player_game()
g = p.game
# fbs is player 3, give them a property
give_properties(g, 3, [1]) # Mediterranean
state = p.get_state()
sq1 = next(sq for sq in state["squares"] if sq["id"] == 1)
# owner should be 3 (fbs's player number), not 2 (array index)
self.assertEqual(sq1["owner"], 3)
# =====================================================================
# Bug #3: _first_player_idx not set on mid-stream pickup
# =====================================================================
class TestBug3_FirstPlayerIdx(unittest.TestCase):
"""When game is picked up from a checkpoint, _first_player_idx is never set."""
def test_midstream_pickup_has_first_player_idx(self):
"""Picking up game from checkpoint should set _first_player_idx."""
p = MonopParser()
feed(p, [
"monop\tmerp (1) (cash $1200) on Connecticut ave. (L)",
"monop\t-- Command: ",
])
g = p.game
self.assertIsNotNone(g)
self.assertTrue(hasattr(g, '_first_player_idx'),
"_first_player_idx should be set on mid-stream pickup")
# =====================================================================
# Bug #4: Resign-to-player with no target falls through to bank msg
# =====================================================================
class TestBug4_ResignNoTarget(unittest.TestCase):
"""If _resign_target can't resolve, logs 'resigned to bank' but
doesn't clear properties like the real bank path."""
def test_resign_unresolved_target_clears_properties(self):
"""If resign target can't be found, treat as bank resignation."""
p = setup_3player_game()
g = p.game
give_properties(g, 1, [1, 3]) # merp owns Mediterranean, Baltic
advance_to_merp_turn(p)
# Force _resign_target to a bad name
p._resign_target = "nonexistent_player"
p._resign_pending = True
feed(p, [
"monop\tTrade is done!",
"monop\thiro (1) (cash $1500) on === GO ===",
"monop\t-- Command: ",
])
# Properties should be cleared (bank behavior)
self.assertNotIn(1, g.property_owner, "Props should be cleared on failed resign target")
self.assertNotIn(3, g.property_owner)
# =====================================================================
# Bug #5: Houses/mortgages not transferred on resign-to-player
# =====================================================================
class TestBug5_ResignTransferHouses(unittest.TestCase):
"""When resigning to a player, houses/mortgage state should transfer."""
def test_houses_preserved_on_resign(self):
"""Houses on transferred properties should be preserved."""
p = setup_3player_game()
g = p.game
give_properties(g, 1, [1, 3])
g.property_houses[1] = 3 # 3 houses on Mediterranean
g.property_mortgaged[3] = True # Baltic mortgaged
advance_to_merp_turn(p)
feed(p, [
"monop\tYou would resign to fbs",
"monop\tDo you really want to resign? ",
"merp\t.y",
"monop\tresigning to player",
"monop\tTrade is done!",
"monop\thiro (1) (cash $1500) on === GO ===",
"monop\t-- Command: ",
])
fbs = g.get_player(name="fbs")
# Properties transferred to fbs
self.assertEqual(g.property_owner.get(1), fbs.number)
# Houses should be preserved
self.assertEqual(g.property_houses.get(1), 3,
"Houses should be preserved on transfer")
# Mortgage state should be preserved
self.assertTrue(g.property_mortgaged.get(3),
"Mortgage state should be preserved on transfer")
# =====================================================================
# Bug #6: spec flag never consumed (Chance: nearest RR/utility)
# =====================================================================
class TestBug6_SpecFlag(unittest.TestCase):
"""The spec flag is set for 'Advance to nearest Railroad/Utility'
Chance cards but never cleared, potentially affecting future rent."""
def test_spec_flag_cleared_after_rent(self):
"""spec should be cleared after rent is calculated."""
p = setup_3player_game()
g = p.game
g.spec = True # simulate having drawn the card
# Player pays rent
give_properties(g, 2, [5]) # hiro owns Reading RR
feed(p, [
"monop\tOwned by hiro",
"monop\trent is 25",
"monop\thiro (2) (cash $1500) on === GO ===",
"monop\t-- Command: ",
])
self.assertFalse(g.spec, "spec flag should be cleared after rent payment")
# =====================================================================
# Bug #13: get_state() doesn't emit phase: "playing"
# =====================================================================
class TestBug13_PhasePlaying(unittest.TestCase):
"""get_state() should always include phase field."""
def test_phase_playing_emitted(self):
p = setup_3player_game()
state = p.get_state()
self.assertIn("phase", state, "State should always include phase")
self.assertEqual(state["phase"], "playing")
# =====================================================================
# Bug #16: Resign log entries missing timestamps
# =====================================================================
class TestBug16_ResignTimestamp(unittest.TestCase):
"""Resign-to-player log entries should have timestamps."""
def test_resign_to_player_has_timestamp(self):
p = setup_3player_game()
g = p.game
advance_to_merp_turn(p)
feed(p, [
"monop\tYou would resign to fbs",
"monop\tDo you really want to resign? ",
"merp\t.y",
"monop\tresigning to player",
"monop\tTrade is done!",
"monop\thiro (1) (cash $1500) on === GO ===",
"monop\t-- Command: ",
])
resign_entries = [e for e in g.log if "resigned" in e.get("text", "")]
self.assertTrue(len(resign_entries) > 0, "Should have resign log entry")
self.assertIn("timestamp", resign_entries[0],
"Resign log entry should have a timestamp")
self.assertEqual(resign_entries[0]["timestamp"], TS)
if __name__ == "__main__":
unittest.main(verbosity=2)

364
test_parser_resign.py Normal file
View file

@ -0,0 +1,364 @@
#!/usr/bin/env python3
"""Unit tests for parser resignation handling — both to-player and to-bank."""
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):
"""Feed tab-delimited lines (sender\\tmessage) into the parser."""
for line in lines:
parser.parse_line(f"{TS}\t{line}")
def setup_3player_game():
"""Set up a 3-player game (merp, hiro, fbs) in playing phase.
Uses the DarkScience-style setup that the parser actually handles."""
p = MonopParser()
feed(p, [
# Start a new game
"monop\tHow many players? ",
# Setup: say me + rolls
"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",
# fbs rolls highest, goes first
"monop\tfbs (3) goes first",
# First checkpoint to confirm playing state
"monop\tfbs (3) (cash $1500) on === GO ===",
"monop\t-- Command: ",
])
return p
def give_properties(game, player_num, sq_ids):
"""Assign properties to a player in game state."""
for sq_id in sq_ids:
game.property_owner[sq_id] = player_num
class TestResignToBank(unittest.TestCase):
"""Test resignation to bank — properties become unowned."""
def _resign_merp_to_bank(self):
"""Set up game, give merp properties, resign to bank.
Note: fbs goes first (highest roll). Turn order is fbsmerphiro.
We need it to be merp's turn for the resign to work."""
p = setup_3player_game()
g = p.game
# Give merp some properties and houses/mortgages
give_properties(g, 1, [1, 3, 5]) # Mediterranean, Baltic, Reading RR
g.property_houses[1] = 2
g.property_mortgaged[3] = True
# fbs rolls, then it's merp's turn
feed(p, [
"fbs\t.",
"monop\troll is 3, 4",
"monop\tThat puts you on Oriental ave. (L)",
"monop\tThat would cost $100, do you want it? ",
"fbs\t.n",
"monop\tmerp (1) (cash $1500) on === GO ===",
"monop\t-- Command: ",
# Now merp resigns to bank
"merp\t.resign",
"monop\tWho do you wish to resign to? ",
"merp\t.bank",
"monop\tDo you really want to resign? ",
"merp\t.y",
"monop\tresigning to bank",
"monop\thiro (1) (cash $1500) on === GO ===",
"monop\t-- Command: ",
])
return p
def test_player_removed_from_active(self):
p = self._resign_merp_to_bank()
state = p.get_state()
active_names = [pl["name"] for pl in state["players"] if not pl.get("bankrupt")]
self.assertNotIn("merp", active_names)
self.assertEqual(len(active_names), 2)
def test_player_marked_bankrupt(self):
p = self._resign_merp_to_bank()
state = p.get_state()
bankrupt = [pl for pl in state["players"] if pl.get("bankrupt")]
self.assertEqual(len(bankrupt), 1)
self.assertEqual(bankrupt[0]["name"], "merp")
def test_properties_cleared(self):
p = self._resign_merp_to_bank()
g = p.game
for sq_id in [1, 3, 5]:
self.assertNotIn(sq_id, g.property_owner,
f"Property {sq_id} should be unowned after resign to bank")
def test_houses_cleared(self):
p = self._resign_merp_to_bank()
self.assertNotIn(1, p.game.property_houses)
def test_mortgages_cleared(self):
p = self._resign_merp_to_bank()
self.assertNotIn(3, p.game.property_mortgaged)
def test_remaining_players_renumbered(self):
p = self._resign_merp_to_bank()
state = p.get_state()
active = [pl for pl in state["players"] if not pl.get("bankrupt")]
names = {pl["name"]: pl["number"] for pl in active}
# After merp removed, remaining get renumbered 1, 2
self.assertIn("hiro", names)
self.assertIn("fbs", names)
def test_log_entry(self):
p = self._resign_merp_to_bank()
state = p.get_state()
log_texts = [e["text"] for e in state["log"]]
self.assertTrue(any("merp" in t and "bank" in t for t in log_texts),
f"Expected resign-to-bank log entry, got: {log_texts}")
class TestResignToPlayer(unittest.TestCase):
"""Test resignation to another player — assets transferred."""
def _resign_merp_to_fbs(self, extra_setup=None):
"""merp resigns to fbs. Optional extra_setup callback for custom state."""
p = setup_3player_game()
g = p.game
# Give merp properties and money
give_properties(g, 1, [1, 3, 5])
merp = g.get_player(name="merp")
merp.money = 800
merp.get_out_of_jail_free_cards = 1
if extra_setup:
extra_setup(p)
# fbs turn, then merp's turn
feed(p, [
"fbs\t.",
"monop\troll is 3, 4",
"monop\tThat puts you on Oriental ave. (L)",
"monop\tThat would cost $100, do you want it? ",
"fbs\t.n",
"monop\tmerp (1) (cash $800) on === GO ===",
"monop\t-- Command: ",
# merp resigns — auto-target since negative money scenario uses "You would resign to"
"monop\tYou would resign to fbs",
"monop\tDo you really want to resign? ",
"merp\t.y",
"monop\tresigning to player",
"monop\tTrade is done!",
"monop\thiro (1) (cash $1500) on === GO ===",
"monop\t-- Command: ",
])
return p
def test_player_marked_bankrupt(self):
p = self._resign_merp_to_fbs()
state = p.get_state()
bankrupt = [pl for pl in state["players"] if pl.get("bankrupt")]
self.assertEqual(len(bankrupt), 1)
self.assertEqual(bankrupt[0]["name"], "merp")
def test_money_transferred(self):
p = self._resign_merp_to_fbs()
fbs = p.game.get_player(name="fbs")
self.assertIsNotNone(fbs)
# fbs started with 1500, gets merp's 800
self.assertEqual(fbs.money, 1500 + 800)
def test_gojf_transferred(self):
p = self._resign_merp_to_fbs()
fbs = p.game.get_player(name="fbs")
self.assertEqual(fbs.get_out_of_jail_free_cards, 1)
def test_properties_transferred(self):
p = self._resign_merp_to_fbs()
g = p.game
fbs = g.get_player(name="fbs")
for sq_id in [1, 3, 5]:
self.assertEqual(g.property_owner.get(sq_id), fbs.number,
f"Property {sq_id} should belong to fbs (#{fbs.number})")
def test_other_player_properties_preserved(self):
"""hiro's properties survive with correct renumbered owner."""
def setup(parser):
give_properties(parser.game, 2, [11, 13]) # hiro owns St Charles, States
p = self._resign_merp_to_fbs(extra_setup=setup)
g = p.game
hiro = g.get_player(name="hiro")
self.assertIsNotNone(hiro, "hiro should still be in the game")
for sq_id in [11, 13]:
self.assertEqual(g.property_owner.get(sq_id), hiro.number,
f"Property {sq_id} should belong to hiro (#{hiro.number})")
def test_log_entry(self):
p = self._resign_merp_to_fbs()
state = p.get_state()
log_texts = [e["text"] for e in state["log"]]
self.assertTrue(any("merp" in t and "fbs" in t for t in log_texts),
f"Expected resign-to-fbs log entry, got: {log_texts}")
class TestResignWithChosenTarget(unittest.TestCase):
"""Player with positive money chooses who to resign to via 'Who do you wish to resign to?'"""
def test_resign_choose_hiro(self):
p = setup_3player_game()
give_properties(p.game, 1, [1, 3])
feed(p, [
# fbs turn
"fbs\t.",
"monop\troll is 3, 4",
"monop\tThat puts you on Oriental ave. (L)",
"monop\tThat would cost $100, do you want it? ",
"fbs\t.n",
"monop\tmerp (1) (cash $1500) on === GO ===",
"monop\t-- Command: ",
# merp chooses to resign to hiro
"merp\t.resign",
"monop\tWho do you wish to resign to? ",
"merp\t.hiro",
"monop\tDo you really want to resign? ",
"merp\t.y",
"monop\tresigning to player",
"monop\tTrade is done!",
"monop\thiro (1) (cash $3000) on === GO ===",
"monop\t-- Command: ",
])
g = p.game
hiro = g.get_player(name="hiro")
self.assertIsNotNone(hiro)
# Properties transferred to hiro
for sq_id in [1, 3]:
self.assertEqual(g.property_owner.get(sq_id), hiro.number)
# Money transferred (1500 + 1500)
self.assertEqual(hiro.money, 3000)
class TestResignGameOver(unittest.TestCase):
"""Resignations leading to game over."""
def test_last_two_players_one_resigns(self):
"""In 3-player game: merp resigns, then hiro resigns → fbs wins."""
p = setup_3player_game()
feed(p, [
# fbs turn, then merp's
"fbs\t.",
"monop\troll is 3, 4",
"monop\tThat puts you on Oriental ave. (L)",
"monop\tThat would cost $100, do you want it? ",
"fbs\t.n",
"monop\tmerp (1) (cash $1500) on === GO ===",
"monop\t-- Command: ",
# merp resigns to bank
"merp\t.resign",
"monop\tWho do you wish to resign to? ",
"merp\t.bank",
"monop\tDo you really want to resign? ",
"merp\t.y",
"monop\tresigning to bank",
"monop\thiro (1) (cash $1500) on === GO ===",
"monop\t-- Command: ",
# hiro's turn, rolls
"hiro\t.",
"monop\troll is 2, 3",
"monop\tThat puts you on Reading RR",
"monop\tThat would cost $200, do you want it? ",
"hiro\t.n",
"monop\tfbs (2) (cash $1500) on Oriental ave. (L)",
"monop\t-- Command: ",
# fbs turn, then hiro again
"fbs\t.",
"monop\troll is 1, 2",
"monop\tThat puts you on Connecticut ave. (L)",
"monop\tThat would cost $120, do you want it? ",
"fbs\t.n",
"monop\thiro (1) (cash $1500) on Reading RR",
"monop\t-- Command: ",
# hiro resigns to fbs
"monop\tYou would resign to fbs",
"monop\tDo you really want to resign? ",
"hiro\t.y",
"monop\tresigning to player",
"monop\tTrade is done!",
"monop\tThen fbs WINS!!!!!",
])
state = p.get_state()
self.assertEqual(state.get("phase"), "over")
bankrupt = [pl for pl in state["players"] if pl.get("bankrupt")]
self.assertEqual(len(bankrupt), 2)
active = [pl for pl in state["players"] if not pl.get("bankrupt")]
self.assertEqual(len(active), 1)
self.assertEqual(active[0]["name"], "fbs")
class TestLogReplayResignations(unittest.TestCase):
"""Replay real log segments involving resignations."""
def _replay_to_line(self, end_line):
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 >= end_line:
break
return p
def test_merp_resigns_to_fbs_line_1012(self):
"""Real log: merp resigns to fbs around line 1012."""
p = self._replay_to_line(1020)
state = p.get_state()
self.assertIsNotNone(state)
bankrupt_names = [pl["name"] for pl in state["players"] if pl.get("bankrupt")]
self.assertIn("merp", bankrupt_names)
active_names = [pl["name"] for pl in state["players"] if not pl.get("bankrupt")]
self.assertIn("hiro", active_names)
self.assertIn("fbs", active_names)
def test_xlink_resigns_to_fbs_line_1245(self):
"""Real log: xLink resigns to fbs around line 1245."""
p = self._replay_to_line(1250)
state = p.get_state()
self.assertIsNotNone(state)
bankrupt_names = [pl["name"] for pl in state["players"] if pl.get("bankrupt")]
self.assertIn("xLink", bankrupt_names)
def test_derecho_resigns_to_bank_line_6874(self):
"""Real log: Derecho resigns to bank around line 6874."""
p = self._replay_to_line(6880)
state = p.get_state()
self.assertIsNotNone(state)
# Should not crash — that's the main assertion
# Check bankrupt list includes someone
bankrupt_names = [pl["name"] for pl in state["players"] if pl.get("bankrupt")]
self.assertTrue(len(bankrupt_names) > 0, "Expected at least one bankrupt player by line 6880")
def test_whoami_resigns_to_bank_line_10729(self):
"""Real log: whoami resigns to bank around line 10729."""
p = self._replay_to_line(10735)
state = p.get_state()
self.assertIsNotNone(state)
if __name__ == "__main__":
unittest.main(verbosity=2)