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
364 lines
13 KiB
Python
364 lines
13 KiB
Python
#!/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 fbs→merp→hiro.
|
|
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)
|