2026-02-21 09:53:09 +00:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""Unit tests for monop_players.py — test bot responses to monop messages."""
|
|
|
|
|
|
|
|
|
|
import sys
|
|
|
|
|
import unittest
|
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
|
import time
|
|
|
|
|
|
|
|
|
|
# Import PlayerBot directly
|
|
|
|
|
sys.path.insert(0, "/tmp/monop-state")
|
|
|
|
|
from monop_players import PlayerBot
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FakePlayerBot(PlayerBot):
|
|
|
|
|
"""PlayerBot that captures messages instead of sending to IRC."""
|
|
|
|
|
|
|
|
|
|
def __init__(self, nick, player_names, player_index):
|
|
|
|
|
# Don't call super().__init__ — skip socket stuff
|
|
|
|
|
self.nick = nick
|
|
|
|
|
self.channel = "#monop"
|
|
|
|
|
self.host = "127.0.0.1"
|
|
|
|
|
self.port = 6667
|
|
|
|
|
self.player_names = player_names
|
|
|
|
|
self.player_index = player_index
|
|
|
|
|
self.num_players = len(player_names)
|
|
|
|
|
|
|
|
|
|
self.sock = None
|
|
|
|
|
self.buffer = ""
|
|
|
|
|
self.lock = __import__("threading").Lock()
|
|
|
|
|
|
|
|
|
|
# Game state
|
|
|
|
|
self.setup_phase = True
|
|
|
|
|
self.setup_registrations_seen = 0
|
|
|
|
|
self.current_player = None
|
|
|
|
|
self.my_money = 1500
|
|
|
|
|
self.in_jail = False
|
|
|
|
|
self.jail_turns = 0
|
|
|
|
|
self.in_debt = False
|
|
|
|
|
self.in_auction = False
|
|
|
|
|
self.auction_bid = 0
|
|
|
|
|
self.awaiting_prompt = None
|
|
|
|
|
self.game_started = False
|
|
|
|
|
self.game_over = False
|
|
|
|
|
self.rolled_this_turn = False
|
|
|
|
|
self.my_properties = []
|
|
|
|
|
self.mortgaged = set()
|
|
|
|
|
self._prompt_answered = False
|
|
|
|
|
self._first_player_announced = False
|
|
|
|
|
self.in_trade = False
|
|
|
|
|
self.trade_props_offered = 0
|
|
|
|
|
self.turns_played = 0
|
|
|
|
|
|
|
|
|
|
# Capture sent messages instead of IRC
|
|
|
|
|
self.sent_messages = []
|
|
|
|
|
|
|
|
|
|
def say(self, msg):
|
|
|
|
|
self.sent_messages.append(msg)
|
|
|
|
|
|
|
|
|
|
def say_delayed(self, msg, delay=None, force=False):
|
|
|
|
|
"""Immediate version — no threading, no delay."""
|
|
|
|
|
if force or self.is_my_turn():
|
|
|
|
|
self.sent_messages.append(msg)
|
|
|
|
|
|
|
|
|
|
def feed(self, msg):
|
|
|
|
|
"""Simulate receiving a message from the monop bot."""
|
|
|
|
|
self.sent_messages.clear()
|
|
|
|
|
self._handle_bot_msg(msg)
|
|
|
|
|
return list(self.sent_messages)
|
|
|
|
|
|
|
|
|
|
def feed_setup(self, msg):
|
|
|
|
|
"""Feed a setup-phase message."""
|
|
|
|
|
self.sent_messages.clear()
|
|
|
|
|
result = self._handle_setup(msg)
|
|
|
|
|
return list(self.sent_messages), result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestSetup(unittest.TestCase):
|
|
|
|
|
def test_player_count(self):
|
|
|
|
|
bot = FakePlayerBot("alice", ["alice", "bob"], 0)
|
|
|
|
|
msgs, handled = bot.feed_setup("How many players?")
|
|
|
|
|
self.assertTrue(handled)
|
|
|
|
|
self.assertEqual(msgs, ["2"])
|
|
|
|
|
|
|
|
|
|
def test_player_count_only_first(self):
|
|
|
|
|
bot = FakePlayerBot("bob", ["alice", "bob"], 1)
|
|
|
|
|
msgs, handled = bot.feed_setup("How many players?")
|
|
|
|
|
self.assertTrue(handled)
|
|
|
|
|
self.assertEqual(msgs, []) # bob doesn't send count
|
|
|
|
|
|
|
|
|
|
def test_register_correct_player(self):
|
|
|
|
|
bot = FakePlayerBot("alice", ["alice", "bob"], 0)
|
|
|
|
|
msgs, handled = bot.feed_setup("Player 1, say ''me'' please.")
|
|
|
|
|
self.assertTrue(handled)
|
|
|
|
|
self.assertEqual(msgs, ["alice"])
|
|
|
|
|
|
|
|
|
|
def test_register_wrong_player(self):
|
|
|
|
|
bot = FakePlayerBot("alice", ["alice", "bob"], 0)
|
|
|
|
|
msgs, handled = bot.feed_setup("Player 2, say ''me'' please.")
|
|
|
|
|
self.assertTrue(handled)
|
|
|
|
|
self.assertEqual(msgs, []) # alice doesn't respond to player 2
|
|
|
|
|
|
|
|
|
|
def test_goes_first(self):
|
|
|
|
|
bot = FakePlayerBot("alice", ["alice", "bob"], 0)
|
|
|
|
|
msgs, handled = bot.feed_setup("alice (1) goes first")
|
|
|
|
|
self.assertTrue(handled)
|
|
|
|
|
self.assertFalse(bot.setup_phase)
|
|
|
|
|
self.assertEqual(bot.current_player, "alice")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTurns(unittest.TestCase):
|
|
|
|
|
def _make_bot(self, nick="alice", current_player=None):
|
|
|
|
|
bot = FakePlayerBot(nick, ["alice", "bob"], 0 if nick == "alice" else 1)
|
|
|
|
|
bot.setup_phase = False
|
|
|
|
|
bot.current_player = current_player
|
|
|
|
|
return bot
|
|
|
|
|
|
|
|
|
|
def test_checkpoint_triggers_roll(self):
|
|
|
|
|
bot = self._make_bot("alice")
|
|
|
|
|
msgs = bot.feed("alice (1) (cash $1500) on === GO ===")
|
|
|
|
|
self.assertIn("roll", msgs)
|
|
|
|
|
self.assertTrue(bot.rolled_this_turn)
|
|
|
|
|
|
|
|
|
|
def test_checkpoint_other_player_no_roll(self):
|
|
|
|
|
bot = self._make_bot("alice")
|
|
|
|
|
msgs = bot.feed("bob (2) (cash $1500) on === GO ===")
|
|
|
|
|
self.assertEqual(msgs, [])
|
|
|
|
|
self.assertEqual(bot.current_player, "bob")
|
|
|
|
|
|
|
|
|
|
def test_doubles_roll_again(self):
|
|
|
|
|
bot = self._make_bot("alice", "alice")
|
|
|
|
|
msgs = bot.feed("alice rolled doubles. Goes again")
|
|
|
|
|
self.assertIn("roll", msgs)
|
|
|
|
|
|
|
|
|
|
def test_buy_prompt(self):
|
|
|
|
|
bot = self._make_bot("alice", "alice")
|
|
|
|
|
msgs = bot.feed("Do you want to buy?")
|
|
|
|
|
self.assertIn("yes", msgs)
|
|
|
|
|
|
|
|
|
|
def test_buy_prompt_not_my_turn(self):
|
|
|
|
|
bot = self._make_bot("alice", "bob")
|
|
|
|
|
msgs = bot.feed("Do you want to buy?")
|
|
|
|
|
self.assertEqual(msgs, [])
|
|
|
|
|
|
|
|
|
|
def test_command_prompt_rolls_if_needed(self):
|
|
|
|
|
bot = self._make_bot("alice", "alice")
|
|
|
|
|
bot.rolled_this_turn = False
|
|
|
|
|
msgs = bot.feed("-- Command:")
|
|
|
|
|
self.assertIn("roll", msgs)
|
|
|
|
|
|
|
|
|
|
def test_command_prompt_no_double_roll(self):
|
|
|
|
|
bot = self._make_bot("alice", "alice")
|
|
|
|
|
bot.rolled_this_turn = True
|
|
|
|
|
msgs = bot.feed("-- Command:")
|
|
|
|
|
self.assertEqual(msgs, [])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestJail(unittest.TestCase):
|
|
|
|
|
def test_jail_turn_rolls(self):
|
|
|
|
|
bot = FakePlayerBot("alice", ["alice", "bob"], 0)
|
|
|
|
|
bot.setup_phase = False
|
|
|
|
|
bot.current_player = "alice"
|
|
|
|
|
msgs = bot.feed("(This is your 1st turn in JAIL)")
|
|
|
|
|
self.assertIn("roll", msgs)
|
|
|
|
|
self.assertTrue(bot.in_jail)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestDebt(unittest.TestCase):
|
|
|
|
|
def test_debt_mortgages(self):
|
|
|
|
|
bot = FakePlayerBot("alice", ["alice", "bob"], 0)
|
|
|
|
|
bot.setup_phase = False
|
|
|
|
|
bot.current_player = "alice"
|
|
|
|
|
msgs = bot.feed("How are you going to fix it up?")
|
|
|
|
|
self.assertIn("mortgage", msgs)
|
|
|
|
|
self.assertTrue(bot.in_debt)
|
|
|
|
|
|
|
|
|
|
def test_mortgage_prompt_sends_question(self):
|
|
|
|
|
bot = FakePlayerBot("alice", ["alice", "bob"], 0)
|
|
|
|
|
bot.setup_phase = False
|
|
|
|
|
bot.current_player = "alice"
|
|
|
|
|
bot.in_debt = True
|
|
|
|
|
msgs = bot.feed("Which property do you want to mortgage?")
|
|
|
|
|
self.assertIn("?", msgs)
|
|
|
|
|
|
|
|
|
|
def test_mortgage_prompt_done_when_solvent(self):
|
|
|
|
|
bot = FakePlayerBot("alice", ["alice", "bob"], 0)
|
|
|
|
|
bot.setup_phase = False
|
|
|
|
|
bot.current_player = "alice"
|
|
|
|
|
bot.in_debt = False
|
|
|
|
|
msgs = bot.feed("Which property do you want to mortgage?")
|
|
|
|
|
self.assertIn("done", msgs)
|
|
|
|
|
|
|
|
|
|
def test_no_property_sells_houses(self):
|
|
|
|
|
bot = FakePlayerBot("alice", ["alice", "bob"], 0)
|
|
|
|
|
bot.setup_phase = False
|
|
|
|
|
bot.current_player = "alice"
|
|
|
|
|
bot.in_debt = True
|
|
|
|
|
msgs = bot.feed("You don't have any un-mortgaged property.")
|
|
|
|
|
self.assertIn("sell houses", msgs)
|
|
|
|
|
|
|
|
|
|
def test_no_houses_resigns(self):
|
|
|
|
|
bot = FakePlayerBot("alice", ["alice", "bob"], 0)
|
|
|
|
|
bot.setup_phase = False
|
|
|
|
|
bot.current_player = "alice"
|
|
|
|
|
bot.in_debt = True
|
|
|
|
|
msgs = bot.feed("You don't have any houses to sell!!")
|
|
|
|
|
self.assertIn("resign", msgs)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTax(unittest.TestCase):
|
|
|
|
|
def test_tax_choice(self):
|
|
|
|
|
bot = FakePlayerBot("alice", ["alice", "bob"], 0)
|
|
|
|
|
bot.setup_phase = False
|
|
|
|
|
bot.current_player = "alice"
|
|
|
|
|
msgs = bot.feed("Do you wish to lose 10%% of your total worth or $200? ")
|
|
|
|
|
self.assertIn("10%", msgs)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTrading(unittest.TestCase):
|
|
|
|
|
def _make_bot(self, nick="alice"):
|
|
|
|
|
bot = FakePlayerBot(nick, ["alice", "bob"], 0 if nick == "alice" else 1)
|
|
|
|
|
bot.setup_phase = False
|
|
|
|
|
bot.current_player = "alice"
|
|
|
|
|
return bot
|
|
|
|
|
|
|
|
|
|
@patch("monop_players.random")
|
|
|
|
|
def test_trade_initiated(self, mock_random):
|
|
|
|
|
"""With random < 0.10, bot initiates trade instead of rolling."""
|
|
|
|
|
mock_random.random.return_value = 0.05 # < 0.10
|
|
|
|
|
bot = self._make_bot("alice")
|
|
|
|
|
bot.turns_played = 10 # past turn 5
|
|
|
|
|
msgs = bot.feed("alice (1) (cash $1500) on === GO ===")
|
|
|
|
|
self.assertIn("trade", msgs)
|
|
|
|
|
self.assertTrue(bot.in_trade)
|
|
|
|
|
self.assertNotIn("roll", msgs)
|
|
|
|
|
|
|
|
|
|
@patch("monop_players.random")
|
|
|
|
|
def test_trade_not_initiated_early(self, mock_random):
|
|
|
|
|
"""No trades before turn 5."""
|
|
|
|
|
mock_random.random.return_value = 0.05
|
|
|
|
|
bot = self._make_bot("alice")
|
|
|
|
|
bot.turns_played = 2
|
|
|
|
|
msgs = bot.feed("alice (1) (cash $1500) on === GO ===")
|
|
|
|
|
self.assertIn("roll", msgs)
|
|
|
|
|
self.assertFalse(bot.in_trade)
|
|
|
|
|
|
|
|
|
|
def test_trade_property_prompt_sends_done(self):
|
|
|
|
|
bot = self._make_bot("alice")
|
|
|
|
|
bot.in_trade = True
|
|
|
|
|
bot.trade_props_offered = 1 # already offered one
|
|
|
|
|
msgs = bot.feed("Which property do you wish to trade?")
|
|
|
|
|
self.assertIn("done", msgs)
|
|
|
|
|
|
|
|
|
|
def test_trade_cash_prompt(self):
|
|
|
|
|
bot = self._make_bot("alice")
|
|
|
|
|
bot.in_trade = True
|
|
|
|
|
msgs = bot.feed("You have $1500. How much are you trading?")
|
|
|
|
|
self.assertEqual(len(msgs), 1)
|
|
|
|
|
amount = int(msgs[0])
|
|
|
|
|
self.assertGreaterEqual(amount, 0)
|
|
|
|
|
self.assertLessEqual(amount, 375) # max 25% of 1500
|
|
|
|
|
|
|
|
|
|
def test_trade_gojf_prompt(self):
|
|
|
|
|
bot = self._make_bot("alice")
|
|
|
|
|
bot.in_trade = True
|
|
|
|
|
msgs = bot.feed("You have 2 get-out-of-jail-free cards. How many are you trading?")
|
|
|
|
|
self.assertEqual(msgs, ["0"])
|
|
|
|
|
|
|
|
|
|
@patch("monop_players.random")
|
|
|
|
|
def test_trade_accepted(self, mock_random):
|
|
|
|
|
mock_random.random.return_value = 0.3 # < 0.5 → accept
|
|
|
|
|
bot = self._make_bot("alice")
|
|
|
|
|
bot.in_trade = True
|
|
|
|
|
# alice is asked to confirm (she's the tradee)
|
|
|
|
|
bot.current_player = "bob" # bob initiated
|
|
|
|
|
msgs = bot.feed("alice, is the trade ok?")
|
|
|
|
|
self.assertIn("yes", msgs)
|
|
|
|
|
|
|
|
|
|
@patch("monop_players.random")
|
|
|
|
|
def test_trade_rejected(self, mock_random):
|
|
|
|
|
mock_random.random.return_value = 0.7 # >= 0.5 → reject
|
|
|
|
|
bot = self._make_bot("alice")
|
|
|
|
|
bot.in_trade = True
|
|
|
|
|
bot.current_player = "bob"
|
|
|
|
|
msgs = bot.feed("alice, is the trade ok?")
|
|
|
|
|
self.assertIn("no", msgs)
|
|
|
|
|
|
|
|
|
|
def test_trade_done_resets(self):
|
|
|
|
|
bot = self._make_bot("alice")
|
|
|
|
|
bot.in_trade = True
|
|
|
|
|
msgs = bot.feed("Trade is done!")
|
|
|
|
|
self.assertFalse(bot.in_trade)
|
|
|
|
|
|
|
|
|
|
def test_trade_nobody_around(self):
|
|
|
|
|
bot = self._make_bot("alice")
|
|
|
|
|
bot.in_trade = True
|
|
|
|
|
msgs = bot.feed("There ain't no-one around to trade WITH!!")
|
|
|
|
|
self.assertFalse(bot.in_trade)
|
|
|
|
|
self.assertIn("roll", msgs)
|
|
|
|
|
|
|
|
|
|
def test_command_prompt_resets_trade(self):
|
|
|
|
|
bot = self._make_bot("alice")
|
|
|
|
|
bot.in_trade = True
|
|
|
|
|
bot.rolled_this_turn = False
|
|
|
|
|
msgs = bot.feed("-- Command:")
|
|
|
|
|
self.assertFalse(bot.in_trade)
|
|
|
|
|
self.assertIn("roll", msgs)
|
|
|
|
|
|
|
|
|
|
|
2026-02-21 10:45:15 +00:00
|
|
|
class TestTradeInJail(unittest.TestCase):
|
|
|
|
|
"""Regression tests for trade-while-in-jail bug.
|
|
|
|
|
|
|
|
|
|
Sequence that caused the hang:
|
|
|
|
|
1. Checkpoint "charlie (3) (cash $985) on JAIL" → trade initiated
|
|
|
|
|
2. "(This is your 2nd turn in JAIL)" → roll queued (ignoring in_trade)
|
|
|
|
|
3. ".trade" sent, then ".roll" sent into trade prompt → hang
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def _make_bot(self, nick="charlie"):
|
|
|
|
|
bot = FakePlayerBot(nick, ["alice", "bob", "charlie"], 2)
|
|
|
|
|
bot.setup_phase = False
|
|
|
|
|
bot.game_started = True
|
|
|
|
|
bot.turns_played = 10 # past trade threshold
|
|
|
|
|
return bot
|
|
|
|
|
|
|
|
|
|
def test_jail_handler_no_roll_during_trade(self):
|
|
|
|
|
"""Jail turn handler must not queue a roll when in_trade is True."""
|
|
|
|
|
bot = self._make_bot()
|
|
|
|
|
bot.current_player = "charlie"
|
|
|
|
|
bot.in_trade = True
|
|
|
|
|
bot.in_jail = True
|
|
|
|
|
msgs = bot.feed("(This is your 2nd turn in JAIL)")
|
|
|
|
|
self.assertNotIn("roll", msgs,
|
|
|
|
|
"Should not roll while in_trade is True")
|
|
|
|
|
|
|
|
|
|
def test_trade_then_jail_turn_sequence(self):
|
|
|
|
|
"""Simulate the exact sequence: checkpoint initiates trade, then jail prompt arrives."""
|
|
|
|
|
bot = self._make_bot()
|
|
|
|
|
# Force trade to trigger (patch random)
|
|
|
|
|
with patch("random.random", return_value=0.01): # < 0.10 → triggers trade
|
|
|
|
|
msgs = bot.feed("charlie (3) (cash $985) on JAIL")
|
|
|
|
|
self.assertTrue(bot.in_trade, "Trade should be initiated")
|
|
|
|
|
self.assertIn("trade", msgs, "Should send .trade")
|
|
|
|
|
self.assertNotIn("roll", msgs, "Should not also roll")
|
|
|
|
|
|
|
|
|
|
# Now jail turn prompt arrives
|
|
|
|
|
msgs = bot.feed("(This is your 2nd turn in JAIL)")
|
|
|
|
|
self.assertNotIn("roll", msgs,
|
|
|
|
|
"Jail handler must not roll during active trade")
|
|
|
|
|
self.assertTrue(bot.in_trade, "Trade should still be active")
|
|
|
|
|
|
|
|
|
|
def test_trade_which_player_prompt(self):
|
|
|
|
|
"""Bot should pick a trade partner when asked."""
|
|
|
|
|
bot = self._make_bot()
|
|
|
|
|
bot.current_player = "charlie"
|
|
|
|
|
bot.in_trade = True
|
|
|
|
|
msgs = bot.feed("Which player do you wish to trade with?")
|
|
|
|
|
self.assertTrue(len(msgs) > 0, "Should respond to trade partner prompt")
|
|
|
|
|
self.assertNotIn("roll", msgs)
|
|
|
|
|
# Should pick one of the other players
|
|
|
|
|
self.assertTrue(
|
|
|
|
|
msgs[0] in ["alice", "bob"],
|
|
|
|
|
f"Expected a player name, got: {msgs[0]}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_no_trade_in_jail_still_rolls(self):
|
|
|
|
|
"""When NOT in a trade, jail handler should still queue a roll."""
|
|
|
|
|
bot = self._make_bot()
|
|
|
|
|
bot.current_player = "charlie"
|
|
|
|
|
bot.in_trade = False
|
|
|
|
|
bot.in_jail = True
|
|
|
|
|
msgs = bot.feed("(This is your 2nd turn in JAIL)")
|
|
|
|
|
self.assertIn("roll", msgs, "Should roll when not trading")
|
|
|
|
|
|
|
|
|
|
def test_command_prompt_after_trade_allows_roll(self):
|
|
|
|
|
"""After trade ends (-- Command:), bot should roll normally."""
|
|
|
|
|
bot = self._make_bot()
|
|
|
|
|
bot.current_player = "charlie"
|
|
|
|
|
bot.in_trade = True
|
|
|
|
|
bot.rolled_this_turn = False
|
|
|
|
|
msgs = bot.feed("-- Command:")
|
|
|
|
|
self.assertFalse(bot.in_trade)
|
|
|
|
|
self.assertIn("roll", msgs)
|
|
|
|
|
|
|
|
|
|
|
2026-02-21 09:53:09 +00:00
|
|
|
class TestValidInputs(unittest.TestCase):
|
|
|
|
|
def test_picks_first_option(self):
|
|
|
|
|
bot = FakePlayerBot("alice", ["alice", "bob"], 0)
|
|
|
|
|
bot.setup_phase = False
|
|
|
|
|
bot.current_player = "alice"
|
|
|
|
|
msgs = bot.feed("Valid inputs are: Mediterranean ave. (P), Baltic ave. (P), done")
|
|
|
|
|
self.assertIn("Mediterranean ave. (P)", msgs)
|
|
|
|
|
|
|
|
|
|
def test_picks_done(self):
|
|
|
|
|
bot = FakePlayerBot("alice", ["alice", "bob"], 0)
|
|
|
|
|
bot.setup_phase = False
|
|
|
|
|
bot.current_player = "alice"
|
|
|
|
|
msgs = bot.feed("Valid inputs are: done")
|
|
|
|
|
self.assertIn("done", msgs)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestBadPlayer(unittest.TestCase):
|
|
|
|
|
def test_turn_correction(self):
|
|
|
|
|
bot = FakePlayerBot("alice", ["alice", "bob"], 0)
|
|
|
|
|
bot.setup_phase = False
|
|
|
|
|
bot.current_player = "bob"
|
|
|
|
|
msgs = bot.feed("Illegal action: bad player (alice's turn, not bob)")
|
|
|
|
|
self.assertEqual(bot.current_player, "alice")
|
|
|
|
|
# alice should roll
|
|
|
|
|
self.assertIn("roll", msgs)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
unittest.main(verbosity=2)
|