From 887423af18e03b78a589b8bbf098dcfabf1cdb3d Mon Sep 17 00:00:00 2001 From: Juho Kim Date: Sat, 7 Oct 2023 08:05:00 -0400 Subject: [PATCH] Write unit tests and fix bugs --- cardroom/__init__.py | 3 +- cardroom/table.py | 141 +++--- cardroom/tests/__init__.py | 3 + cardroom/tests/test_table.py | 709 ++++++++++++++++++++++++++----- cardroom/tests/test_utilities.py | 36 +- cardroom/tournament.py | 6 - cardroom/utilities.py | 55 ++- requirements.txt | 3 +- setup.py | 3 +- 9 files changed, 768 insertions(+), 191 deletions(-) delete mode 100644 cardroom/tournament.py diff --git a/cardroom/__init__.py b/cardroom/__init__.py index 106124e..f6d1468 100644 --- a/cardroom/__init__.py +++ b/cardroom/__init__.py @@ -3,8 +3,7 @@ All cardroom tools are imported here. """ -__all__ = 'Scheduler', 'Table', 'Tournament', +__all__ = 'Scheduler', 'Table' from cardroom.table import Table -from cardroom.tournament import Tournament from cardroom.utilities import Scheduler diff --git a/cardroom/table.py b/cardroom/table.py index 6fe4f62..bab2a90 100644 --- a/cardroom/table.py +++ b/cardroom/table.py @@ -53,6 +53,8 @@ class Table: """The raw blinds or straddles.""" bring_in: int """The bring-in.""" + buy_in_range: range + """The buy-in range.""" timebank: float """The timebank.""" timebank_increment: float @@ -160,6 +162,14 @@ def turn_index(self) -> int | None: return turn_index + def is_occupied(self, seat_index: int) -> bool: + """Return the occupation status of the seat. + + :param seat_index: The seat index. + :return: The occupation status of the seat. + """ + return self.player_names[seat_index] is not None + def is_active(self, seat_index: int) -> bool: """Return the active status of the player at the seat. @@ -167,10 +177,28 @@ def is_active(self, seat_index: int) -> bool: :return: The active status of the player at the seat. """ return ( - self.player_names[seat_index] is not None + self.is_occupied(seat_index) and self.inactive_timestamps[seat_index] is None ) + def is_kickable(self, seat_index: int) -> bool: + """Return the kickable status of the player at the seat. + + :param seat_index: The seat index. + :return: The kickable status of the player at the seat. + """ + inactive_timestamp = self.inactive_timestamps[seat_index] + + return ( + self.is_occupied(seat_index) + and inactive_timestamp is not None + and ( + inactive_timestamp + + timedelta(seconds=self.idle_timeout) + <= datetime.now() + ) + ) + def get_seat_index(self, player_name_or_player_index: str | int) -> int: """Return the seat index of the player. @@ -206,7 +234,9 @@ def _connect(self, player_name: str, seat_index: int, amount: int) -> None: raise ValueError('seat index out of bounds') elif amount < 0: raise ValueError('negative amount') - elif self.player_names[seat_index] is not None: + elif amount not in self.buy_in_range: + raise ValueError('invalid buy-in amount') + elif self.is_occupied(seat_index): raise ValueError('occupied seat') self.player_names[seat_index] = player_name @@ -251,7 +281,9 @@ def deactivate(self, player_name: str) -> None: def _deactivate(self, player_name: str) -> None: seat_index = self.get_seat_index(player_name) - self.inactive_timestamps[seat_index] = datetime.now() + + if self.inactive_timestamps[seat_index] is None: + self.inactive_timestamps[seat_index] = datetime.now() def buy_in_rebuy_top_off_or_rat_hole( self, @@ -278,6 +310,8 @@ def _buy_in_rebuy_top_off_or_rat_hole( ) -> None: if amount < 0: raise ValueError('negative amount') + elif amount not in self.buy_in_range: + raise ValueError('invalid buy-in amount') self._activate(player_name) @@ -358,7 +392,7 @@ def show_or_muck_hole_cards( ) def _play(self) -> None: - if self.state: + if self.state is not None: if self.state.status: raise ValueError('state active') @@ -392,7 +426,6 @@ def _play(self) -> None: self.buy_in_rebuy_top_off_or_rat_holing_amounts[i] = None - for i in self.seat_indices: if self.starting_stacks[i] == 0: player_name = self.player_names[i] @@ -402,24 +435,6 @@ def _play(self) -> None: self.starting_stacks[i] = None - inactive_timestamp = self.inactive_timestamps[i] - - if ( - inactive_timestamp is not None - and ( - inactive_timestamp - + timedelta(seconds=self.idle_timeout) - <= datetime.now() - ) - ): - self.player_names[i] = None - self.player_indices[i] = None - self.starting_stacks[i] = None - self.buy_in_rebuy_top_off_or_rat_holing_amounts[i] = None - self.active_timestamps[i] = None - self.inactive_timestamps[i] = None - self.timebanks[i] = None - active_seat_indices = deque(filter(self.is_active, self.seat_indices)) if len(active_seat_indices) >= 2: @@ -453,25 +468,28 @@ def _play(self) -> None: starting_stacks.append(starting_stack) - self.state = State( - self.deck, - self.hand_types, - self.streets, - self.betting_structure, - (), - self.ante_trimming_status, - self.raw_antes, - self.raw_blinds_or_straddles, - self.bring_in, - starting_stacks, - len(starting_stacks), - ) + self.state = self._create_state(starting_stacks) else: for i in self.seat_indices: self.player_indices[i] = None self.button_index = None + def _create_state(self, starting_stacks: list[int]) -> State: + return State( + (), + self.deck, + self.hand_types, + self.streets, + self.betting_structure, + self.ante_trimming_status, + self.raw_antes, + self.raw_blinds_or_straddles, + self.bring_in, + starting_stacks, + len(starting_stacks), + ) + _scheduler: Scheduler = field(init=False, default_factory=Scheduler) def run(self) -> None: @@ -582,6 +600,16 @@ def nested_function(count: int) -> Operation | None: def _update(self) -> None: if self.state is None or not self.state.status: + for i in self.seat_indices: + if self.is_kickable(i): + self.player_names[i] = None + self.player_indices[i] = None + self.starting_stacks[i] = None + self.buy_in_rebuy_top_off_or_rat_holing_amounts[i] = None + self.active_timestamps[i] = None + self.inactive_timestamps[i] = None + self.timebanks[i] = None + self._call(self.play_timeout, self._play) else: if self.state.can_post_ante(): @@ -652,48 +680,35 @@ def _update(self) -> None: if self.state.can_stand_pat_or_discard(): if timeout is None: - timeout = self.standing_pat_timeout + timeout = self.standing_pat_timeout + timebank - self._call_state( - None, - timeout + timebank, - self.state.stand_pat_or_discard, - ) + self._call_state(None, timeout, State.stand_pat_or_discard) elif self.state.can_fold(): if timeout is None: - timeout = self.folding_timeout + timeout = self.folding_timeout + timebank - self._call_state( - None, - timeout + timebank, - self.state.fold, - ) + self._call_state(None, timeout, State.fold) elif self.state.can_check_or_call(): if timeout is None: - timeout = self.checking_timeout + timeout = self.checking_timeout + timebank - self._call_state( - None, - timeout + timebank, - self.state.check_or_call, - ) + self._call_state(None, timeout, State.check_or_call) elif self.state.can_post_bring_in(): if timeout is None: - timeout = self.bring_in_posting_timeout + timeout = self.bring_in_posting_timeout + timebank - self._call_state( - None, - timeout + timebank, - self.state.post_bring_in, - ) + self._call_state(None, timeout, State.post_bring_in) elif self.state.can_show_or_muck_hole_cards(): if timeout is None: - timeout = self.hole_cards_showing_or_mucking_timeout + timeout = ( + self.hole_cards_showing_or_mucking_timeout + + timebank + ) self._call_state( None, - timeout + timebank, - self.state.show_or_muck_hole_cards, + timeout, + State.show_or_muck_hole_cards, ) else: raise AssertionError diff --git a/cardroom/tests/__init__.py b/cardroom/tests/__init__.py index e69de29..de41d31 100644 --- a/cardroom/tests/__init__.py +++ b/cardroom/tests/__init__.py @@ -0,0 +1,3 @@ +""":mod:`cardroom.tests` is the package for the unit tests in the +Cardroom library. +""" diff --git a/cardroom/tests/test_table.py b/cardroom/tests/test_table.py index e1625e1..7d271dc 100644 --- a/cardroom/tests/test_table.py +++ b/cardroom/tests/test_table.py @@ -1,96 +1,613 @@ -# from collections.abc import Callable -# from datetime import datetime, timedelta -# from threading import Thread -# from time import sleep -# from typing import Any, ClassVar -# from unittest import TestCase -# from unittest.mock import MagicMock -# -# from pokerkit import ( -# BettingStructure, -# Deck, -# Opening, -# Operation, -# StandardHighHand, -# Street, -# ) -# -# from cardroom.table import Table -# -# -# class TableTestCase(TestCase): -# SLEEP_MULTIPLIER: ClassVar[int] = 10 -# -# def create_table( -# self, -# callback: Callable[[Table, Operation | None], Any], -# ) -> Table: -# return Table( -# 6, -# True, -# Deck.STANDARD, -# (StandardHighHand,), -# ( -# Street( -# False, -# (False,) * 2, -# 0, -# False, -# Opening.POSITION, -# 1, -# None, -# ), -# Street( -# True, -# (), -# 3, -# False, -# Opening.POSITION, -# 1, -# None, -# ), -# Street( -# True, -# (), -# 1, -# False, -# Opening.POSITION, -# 1, -# None, -# ), -# Street( -# True, -# (), -# 1, -# False, -# Opening.POSITION, -# 1, -# None, -# ), -# ), -# BettingStructure.NO_LIMIT, -# True, -# 0, -# (1, 2), -# 0, -# 180, -# 10, -# 3, -# 300, -# 0.1, -# 0.5, -# 0.25, -# 0.25, -# 0.25, -# 0.5, -# 20, -# 20, -# 20, -# 10, -# 3, -# 1, -# 0.5, -# 0.5, -# 1, -# callback, -# ) +from collections.abc import Callable +from threading import Thread +from time import sleep +from typing import Any, ClassVar +from unittest import main, TestCase +from unittest.mock import MagicMock +from warnings import resetwarnings, simplefilter + +from pokerkit import ( + BettingStructure, + Deck, + Hand, + NoLimitDeuceToSevenLowballSingleDraw, + Opening, + Operation, + StandardHighHand, + StandardLowHand, + Street, + ValuesLike, +) + +from cardroom.table import Table + + +class TableTestCase(TestCase): + SEAT_COUNT: ClassVar[int] = 6 + BUTTON_STATUS_A: ClassVar[bool] = True + DECK_A: ClassVar[Deck] = Deck.STANDARD + HAND_TYPES_A: ClassVar[tuple[type[Hand], ...]] = (StandardHighHand,) + STREETS_A: ClassVar[tuple[Street, ...]] = ( + Street( + False, + (False,) * 2, + 0, + False, + Opening.POSITION, + 1, + None, + ), + Street( + True, + (), + 3, + False, + Opening.POSITION, + 1, + None, + ), + Street( + True, + (), + 1, + False, + Opening.POSITION, + 1, + None, + ), + Street( + True, + (), + 1, + False, + Opening.POSITION, + 1, + None, + ), + ) + BETTING_STRUCTURE_A: ClassVar[BettingStructure] = BettingStructure.NO_LIMIT + ANTE_TRIMMING_STATUS_A: ClassVar[bool] = True + RAW_ANTES_A: ClassVar[ValuesLike] = None + RAW_BLINDS_OR_STRADDLES_A: ClassVar[ValuesLike] = 1, 2 + BRING_IN_A: ClassVar[int] = 0 + BUTTON_STATUS_B: ClassVar[bool] = False + DECK_B: ClassVar[Deck] = Deck.STANDARD + HAND_TYPES_B: ClassVar[tuple[type[Hand], ...]] = (StandardHighHand,) + STREETS_B: ClassVar[tuple[Street, ...]] = ( + Street( + False, + (False, False, True), + 0, + False, + Opening.LOW_CARD, + 2, + 4, + ), + Street( + True, (True,), + 0, + False, + Opening.HIGH_HAND, + 2, + 4, + ), + Street( + True, + (True,), + 0, + False, + Opening.HIGH_HAND, + 4, + 4, + ), + Street( + True, + (True,), + 0, + False, + Opening.HIGH_HAND, + 4, + 4, + ), + Street( + True, + (False,), + 0, + False, + Opening.HIGH_HAND, + 4, + 4, + ), + ) + BETTING_STRUCTURE_B: ClassVar[BettingStructure] = ( + BettingStructure.FIXED_LIMIT + ) + ANTE_TRIMMING_STATUS_B: ClassVar[bool] = True + RAW_ANTES_B: ClassVar[ValuesLike] = 1 + RAW_BLINDS_OR_STRADDLES_B: ClassVar[ValuesLike] = None + BRING_IN_B: ClassVar[int] = 1 + BUTTON_STATUS_C: ClassVar[bool] = True + DECK_C: ClassVar[Deck] = Deck.STANDARD + HAND_TYPES_C: ClassVar[tuple[type[Hand], ...]] = (StandardLowHand,) + STREETS_C: ClassVar[tuple[Street, ...]] = ( + Street( + False, + (False,) * 5, + 0, + False, + Opening.POSITION, + 2, + None, + ), + Street( + True, + (), + 0, + True, + Opening.POSITION, + 2, + None, + ), + ) + BETTING_STRUCTURE_C: ClassVar[BettingStructure] = BettingStructure.NO_LIMIT + ANTE_TRIMMING_STATUS_C: ClassVar[bool] = False + RAW_ANTES_C: ClassVar[ValuesLike] = {1: 2} + RAW_BLINDS_OR_STRADDLES_C: ClassVar[ValuesLike] = 1, 2 + BRING_IN_C: ClassVar[int] = 0 + BUY_IN_RANGE: ClassVar[range] = range(80, 201) + TIMEBANK: ClassVar[float] = 30 + TIMEBANK_INCREMENT: ClassVar[float] = 1 + PLAY_TIMEOUT: ClassVar[float] = 1 + IDLE_TIMEOUT: ClassVar[float] = 100 + ANTE_POSTING_TIMEOUT: ClassVar[float] = 0.05 + BET_COLLECTION_TIMEOUT: ClassVar[float] = 0.1 + BLIND_OR_STRADDLE_POSTING_TIMEOUT: ClassVar[float] = 0.05 + CARD_BURNING_TIMEOUT: ClassVar[float] = 0.1 + HOLE_DEALING_TIMEOUT: ClassVar[float] = 0.1 + BOARD_DEALING_TIMEOUT: ClassVar[float] = 0.1 + STANDING_PAT_TIMEOUT: ClassVar[float] = 10 + FOLDING_TIMEOUT: ClassVar[float] = 10 + CHECKING_TIMEOUT: ClassVar[float] = 10 + BRING_IN_POSTING_TIMEOUT: ClassVar[float] = 10 + HOLE_CARDS_SHOWING_OR_MUCKING_TIMEOUT: ClassVar[float] = 3 + HAND_KILLING_TIMEOUT: ClassVar[float] = 0.5 + CHIPS_PUSHING_TIMEOUT: ClassVar[float] = 0.5 + CHIPS_PULLING_TIMEOUT: ClassVar[float] = 0.5 + SKIP_TIMEOUT: ClassVar[float] = 1 + MIN_SLEEP_TIMEOUT: ClassVar[float] = 1 + SLEEP_TIMEOUT_MULTIPLIER: ClassVar[float] = 10 + EPOCH_TIMEOUT: ClassVar[float] = 60 + + @classmethod + def setUpClass(cls) -> None: + simplefilter('ignore') + + @classmethod + def tearDownClass(cls) -> None: + resetwarnings() + + @classmethod + def create_table_a( + cls, + callback: Callable[[Table, Operation | None], Any], + ) -> Table: + return Table( + cls.SEAT_COUNT, + cls.BUTTON_STATUS_A, + cls.DECK_A, + cls.HAND_TYPES_A, + cls.STREETS_A, + cls.BETTING_STRUCTURE_A, + cls.ANTE_TRIMMING_STATUS_A, + cls.RAW_ANTES_A, + cls.RAW_BLINDS_OR_STRADDLES_A, + cls.BRING_IN_A, + cls.BUY_IN_RANGE, + cls.TIMEBANK, + cls.TIMEBANK_INCREMENT, + cls.PLAY_TIMEOUT, + cls.IDLE_TIMEOUT, + cls.ANTE_POSTING_TIMEOUT, + cls.BET_COLLECTION_TIMEOUT, + cls.BLIND_OR_STRADDLE_POSTING_TIMEOUT, + cls.CARD_BURNING_TIMEOUT, + cls.HOLE_DEALING_TIMEOUT, + cls.BOARD_DEALING_TIMEOUT, + cls.STANDING_PAT_TIMEOUT, + cls.FOLDING_TIMEOUT, + cls.CHECKING_TIMEOUT, + cls.BRING_IN_POSTING_TIMEOUT, + cls.HOLE_CARDS_SHOWING_OR_MUCKING_TIMEOUT, + cls.HAND_KILLING_TIMEOUT, + cls.CHIPS_PUSHING_TIMEOUT, + cls.CHIPS_PULLING_TIMEOUT, + cls.SKIP_TIMEOUT, + callback, + ) + + @classmethod + def create_table_b( + cls, + callback: Callable[[Table, Operation | None], Any], + ) -> Table: + return Table( + cls.SEAT_COUNT, + cls.BUTTON_STATUS_B, + cls.DECK_B, + cls.HAND_TYPES_B, + cls.STREETS_B, + cls.BETTING_STRUCTURE_B, + cls.ANTE_TRIMMING_STATUS_B, + cls.RAW_ANTES_B, + cls.RAW_BLINDS_OR_STRADDLES_B, + cls.BRING_IN_B, + cls.BUY_IN_RANGE, + cls.TIMEBANK, + cls.TIMEBANK_INCREMENT, + cls.PLAY_TIMEOUT, + cls.IDLE_TIMEOUT, + cls.ANTE_POSTING_TIMEOUT, + cls.BET_COLLECTION_TIMEOUT, + cls.BLIND_OR_STRADDLE_POSTING_TIMEOUT, + cls.CARD_BURNING_TIMEOUT, + cls.HOLE_DEALING_TIMEOUT, + cls.BOARD_DEALING_TIMEOUT, + cls.STANDING_PAT_TIMEOUT, + cls.FOLDING_TIMEOUT, + cls.CHECKING_TIMEOUT, + cls.BRING_IN_POSTING_TIMEOUT, + cls.HOLE_CARDS_SHOWING_OR_MUCKING_TIMEOUT, + cls.HAND_KILLING_TIMEOUT, + cls.CHIPS_PUSHING_TIMEOUT, + cls.CHIPS_PULLING_TIMEOUT, + cls.SKIP_TIMEOUT, + callback, + ) + + @classmethod + def create_table_c( + cls, + callback: Callable[[Table, Operation | None], Any], + ) -> Table: + return Table( + cls.SEAT_COUNT, + cls.BUTTON_STATUS_C, + cls.DECK_C, + cls.HAND_TYPES_C, + cls.STREETS_C, + cls.BETTING_STRUCTURE_C, + cls.ANTE_TRIMMING_STATUS_C, + cls.RAW_ANTES_C, + cls.RAW_BLINDS_OR_STRADDLES_C, + cls.BRING_IN_C, + cls.BUY_IN_RANGE, + cls.TIMEBANK, + cls.TIMEBANK_INCREMENT, + cls.PLAY_TIMEOUT, + cls.IDLE_TIMEOUT, + cls.ANTE_POSTING_TIMEOUT, + cls.BET_COLLECTION_TIMEOUT, + cls.BLIND_OR_STRADDLE_POSTING_TIMEOUT, + cls.CARD_BURNING_TIMEOUT, + cls.HOLE_DEALING_TIMEOUT, + cls.BOARD_DEALING_TIMEOUT, + cls.STANDING_PAT_TIMEOUT, + cls.FOLDING_TIMEOUT, + cls.CHECKING_TIMEOUT, + cls.BRING_IN_POSTING_TIMEOUT, + cls.HOLE_CARDS_SHOWING_OR_MUCKING_TIMEOUT, + cls.HAND_KILLING_TIMEOUT, + cls.CHIPS_PUSHING_TIMEOUT, + cls.CHIPS_PULLING_TIMEOUT, + cls.SKIP_TIMEOUT, + callback, + ) + + def test_seat_indices(self) -> None: + callback = MagicMock() + table = self.create_table_a(callback) + + self.assertEqual(table.seat_indices, range(self.SEAT_COUNT)) + + def test_turn_index(self) -> None: + callback = MagicMock() + table = self.create_table_a(callback) + + self.assertIsNone(table.turn_index) + + table.state = NoLimitDeuceToSevenLowballSingleDraw.create_state( + (), + False, + 0, + (1, 2), + 2, + (200, 200), + 2, + ) + + while table.state.can_post_blind_or_straddle(): + self.assertIsNone(table.turn_index) + table.state.post_blind_or_straddle() + + while table.state.can_deal_hole(): + self.assertIsNone(table.turn_index) + table.state.deal_hole() + + self.assertEqual(table.turn_index, 1) + table.state.check_or_call() + self.assertEqual(table.turn_index, 0) + table.state.check_or_call() + self.assertEqual(table.turn_index, None) + table.state.collect_bets() + table.state.stand_pat_or_discard() + self.assertEqual(table.turn_index, 1) + table.state.stand_pat_or_discard() + self.assertEqual(table.turn_index, None) + table.state.burn_card() + self.assertEqual(table.turn_index, 0) + table.state.check_or_call() + self.assertEqual(table.turn_index, 1) + table.state.check_or_call() + self.assertEqual(table.turn_index, 0) + table.state.show_or_muck_hole_cards() + self.assertEqual(table.turn_index, 1) + table.state.show_or_muck_hole_cards() + self.assertEqual(table.turn_index, None) + + def test_is_active(self) -> None: + callback = MagicMock() + table = self.create_table_a(callback) + thread = Thread(target=table.run) + + thread.start() + + for i in table.seat_indices: + self.assertFalse(table.is_active(i)) + + table.connect('0', 0, self.BUY_IN_RANGE.start) + sleep(self.MIN_SLEEP_TIMEOUT) + + for i in table.seat_indices: + if i: + self.assertFalse(table.is_active(i)) + else: + self.assertTrue(table.is_active(i)) + + table.deactivate('0') + sleep(self.MIN_SLEEP_TIMEOUT) + + for i in table.seat_indices: + self.assertFalse(table.is_active(i)) + + if i: + self.assertFalse(table.is_occupied(i)) + else: + self.assertTrue(table.is_occupied(i)) + + table.stop() + thread.join() + + def test_get_seat_index(self) -> None: + callback = MagicMock() + table = self.create_table_a(callback) + thread = Thread(target=table.run) + + thread.start() + table.connect('0', 0, self.BUY_IN_RANGE.start) + table.connect('1', 1, self.BUY_IN_RANGE.start) + sleep(self.MIN_SLEEP_TIMEOUT) + self.assertRaises(ValueError, table.get_seat_index, None) + self.assertEqual(table.get_seat_index('0'), 0) + self.assertEqual(table.get_seat_index('1'), 1) + sleep(self.SLEEP_TIMEOUT_MULTIPLIER * self.PLAY_TIMEOUT) + self.assertEqual(table.get_seat_index(0), 1) + self.assertEqual(table.get_seat_index(1), 0) + table.stop() + thread.join() + + def test_connect_and_disconnect(self) -> None: + callback = MagicMock() + table = self.create_table_a(callback) + thread = Thread(target=table.run) + + thread.start() + table.connect('0', 0, self.BUY_IN_RANGE.start) + table.connect('1', -1, self.BUY_IN_RANGE.start) + table.connect('2', 2, -1) + table.connect('3', 3, self.BUY_IN_RANGE.stop) + table.connect('4', 4, self.BUY_IN_RANGE.start - 1) + table.connect('5', 0, self.BUY_IN_RANGE.start) + sleep(self.MIN_SLEEP_TIMEOUT) + self.assertTrue(table.is_occupied(0)) + self.assertFalse(table.is_occupied(1)) + self.assertFalse(table.is_occupied(2)) + self.assertFalse(table.is_occupied(3)) + self.assertFalse(table.is_occupied(4)) + self.assertFalse(table.is_occupied(5)) + table.disconnect('0') + sleep(self.MIN_SLEEP_TIMEOUT) + self.assertFalse(table.is_occupied(0)) + table.stop() + thread.join() + + def test_activate_and_deactivate(self) -> None: + callback = MagicMock() + table = self.create_table_a(callback) + thread = Thread(target=table.run) + + thread.start() + table.connect('0', 0, self.BUY_IN_RANGE.start) + table.connect('1', 1, self.BUY_IN_RANGE.start) + table.connect('2', 2, self.BUY_IN_RANGE.start) + sleep(self.SLEEP_TIMEOUT_MULTIPLIER * self.PLAY_TIMEOUT) + table.deactivate('0') + table.deactivate('1') + table.deactivate('2') + sleep(self.EPOCH_TIMEOUT) + table.activate('0') + sleep(self.SLEEP_TIMEOUT_MULTIPLIER * self.PLAY_TIMEOUT) + self.assertIsNone(table.state) + self.assertTrue(table.is_active(0)) + self.assertFalse(table.is_active(1)) + self.assertFalse(table.is_active(2)) + self.assertFalse(table.is_active(3)) + self.assertFalse(table.is_active(4)) + self.assertFalse(table.is_active(5)) + self.assertTrue(table.is_occupied(0)) + self.assertTrue(table.is_occupied(1)) + self.assertTrue(table.is_occupied(2)) + self.assertFalse(table.is_occupied(3)) + self.assertFalse(table.is_occupied(4)) + self.assertFalse(table.is_occupied(5)) + table.stop() + thread.join() + + def test_fold(self) -> None: + + def callback(table: Table, operation: Operation | None) -> None: + if table.state is not None and table.state.can_fold(): + turn_index = table.turn_index + + assert turn_index is not None + + seat_index = table.get_seat_index(turn_index) + player_name = table.player_names[seat_index] + + assert player_name is not None + + table.fold(player_name) + + button_indices.add(table.button_index) + + for i in table.seat_indices: + starting_stacks[i].add(table.starting_stacks[i]) + + table = self.create_table_a(callback) + button_indices = set[int | None]() + starting_stacks = [set[int | None]() for _ in table.seat_indices] + thread = Thread(target=table.run) + + thread.start() + table.connect('1', 1, 200) + table.connect('2', 2, 80) + table.connect('5', 5, 100) + sleep(self.EPOCH_TIMEOUT) + self.assertEqual(button_indices, {None, 1, 2, 5}) + self.assertEqual( + starting_stacks, + [ + {None}, + {None, 200, 201}, + {None, 79, 80}, + {None}, + {None}, + {None, 100, 101}, + ], + ) + table.buy_in_rebuy_top_off_or_rat_hole('1', -1) + table.buy_in_rebuy_top_off_or_rat_hole('2', 200) + table.buy_in_rebuy_top_off_or_rat_hole('5', self.BUY_IN_RANGE.stop) + sleep(self.EPOCH_TIMEOUT) + self.assertEqual(button_indices, {None, 1, 2, 5}) + self.assertTrue( + starting_stacks[2] == {None, 79, 80, 200, 201} + or starting_stacks[2] == {None, 79, 80, 199, 200} + ) + self.assertEqual( + starting_stacks, + [ + {None}, + {None, 200, 201}, + starting_stacks[2], + {None}, + {None}, + {None, 100, 101}, + ], + ) + table.stop() + thread.join() + + def test_check_or_call(self) -> None: + + def callback(table: Table, operation: Operation | None) -> None: + if table.state is not None: + turn_index = table.turn_index + + if turn_index is not None: + seat_index = table.get_seat_index(turn_index) + player_name = table.player_names[seat_index] + + assert player_name is not None + + if table.state.can_post_bring_in(): + table.post_bring_in(player_name) + elif table.state.can_check_or_call(): + table.check_or_call(player_name) + elif table.state.can_show_or_muck_hole_cards(): + table.show_or_muck_hole_cards(player_name) + + table = self.create_table_b(callback) + thread = Thread(target=table.run) + + thread.start() + table.connect('0', 0, 200) + table.connect('3', 3, 200) + table.connect('4', 4, 200) + sleep(self.EPOCH_TIMEOUT) + table.stop() + thread.join() + + def test_all_in(self) -> None: + + def callback(table: Table, operation: Operation | None) -> None: + if table.state is not None: + turn_index = table.turn_index + + if turn_index is not None: + seat_index = table.get_seat_index(turn_index) + player_name = table.player_names[seat_index] + + assert player_name is not None + + if table.state.can_stand_pat_or_discard(): + table.stand_pat_or_discard(player_name) + elif table.state.can_complete_bet_or_raise_to(): + table.complete_bet_or_raise_to( + player_name, + ( + table + .state + .max_completion_betting_or_raising_to_amount + ), + ) + elif table.state.can_check_or_call(): + table.check_or_call(player_name) + + for i in table.seat_indices: + if ( + table.is_occupied(i) + and ( + table.buy_in_rebuy_top_off_or_rat_holing_amounts[i] + is None + ) + ): + player_name = table.player_names[i] + + assert player_name is not None + + table.buy_in_rebuy_top_off_or_rat_hole(player_name, 200) + + table = self.create_table_c(callback) + thread = Thread(target=table.run) + + thread.start() + table.connect('0', 0, 200) + table.connect('1', 1, 200) + table.connect('2', 2, 200) + sleep(self.EPOCH_TIMEOUT) + table.stop() + thread.join() + + +if __name__ == '__main__': + main() diff --git a/cardroom/tests/test_utilities.py b/cardroom/tests/test_utilities.py index d3a8138..68ec598 100644 --- a/cardroom/tests/test_utilities.py +++ b/cardroom/tests/test_utilities.py @@ -4,9 +4,10 @@ from queue import Queue from random import shuffle -from threading import Thread +from threading import Lock, Thread +from time import sleep from typing import ClassVar -from unittest import TestCase +from unittest import main, TestCase from cardroom.utilities import Scheduler @@ -37,3 +38,34 @@ def test_sleep_sort(self) -> None: values.append(queue.get()) self.assertSequenceEqual(values, range(self.STOP_VALUE)) + + TIMEOUT: ClassVar[int] = 1 + SLEEP_TIMEOUT: ClassVar[int] = 10 + + def test_cancel(self) -> None: + lock = Lock() + counter = 0 + + def count() -> None: + nonlocal counter + + with lock: + counter += 1 + + scheduler = Scheduler() + thread = Thread(target=scheduler.run) + + thread.start() + scheduler.schedule(self.TIMEOUT, count) + event = scheduler.schedule(self.TIMEOUT, count) + scheduler.cancel(event) + sleep(self.SLEEP_TIMEOUT) + scheduler.stop() + thread.join() + + with lock: + self.assertEqual(counter, 1) + + +if __name__ == '__main__': + main() diff --git a/cardroom/tournament.py b/cardroom/tournament.py deleted file mode 100644 index a6a80d9..0000000 --- a/cardroom/tournament.py +++ /dev/null @@ -1,6 +0,0 @@ -from dataclasses import dataclass - - -@dataclass -class Tournament: - pass diff --git a/cardroom/utilities.py b/cardroom/utilities.py index 204d61c..2c53aa3 100644 --- a/cardroom/utilities.py +++ b/cardroom/utilities.py @@ -4,26 +4,20 @@ from collections.abc import Callable from dataclasses import dataclass, field +from functools import partial from queue import Queue -from threading import Timer -from typing import Any +from threading import Lock, Timer +from typing import Any, ClassVar, TypeAlias @dataclass class Scheduler: """The class for schedulers.""" - @dataclass - class Event: - """The class for events.""" - function: Callable[..., Any] - """The funciton.""" - args: tuple[Any, ...] - """The arguments.""" - kwargs: dict[str, Any] - """The keyword arguments.""" - + Event: ClassVar[TypeAlias] = Any _events: Queue[Event | None] = field(init=False, default_factory=Queue) + _timers: dict[Event, Timer] = field(init=False, default_factory=dict) + _lock: Lock = field(init=False, default_factory=Lock) def run(self) -> None: """Run the scheduler. @@ -31,7 +25,16 @@ def run(self) -> None: :return: ``None``. """ while (event := self._events.get()) is not None: - event.function(*event.args, **event.kwargs) + event() + self.cancel(event) + + with self._lock: + timers = tuple(self._timers.values()) + + self._timers.clear() + + for timer in timers: + timer.cancel() def stop(self) -> None: """Stop the scheduler. @@ -55,13 +58,27 @@ def schedule( :param kwargs: The keyword arguments. :return: The scheduled event. """ - event = self.Event(function, args, kwargs) - timer = Timer( - timeout, - self._events.put, - args=[event], - ) + event = partial(self._events.put, partial(function, *args, *kwargs)) + timer = Timer(timeout, event) + + with self._lock: + self._timers[event] = timer timer.start() return event + + def cancel(self, event: Event) -> None: + """Cancel an event. + + :param event: The event. + :return: ``None``. + """ + with self._lock: + try: + timer = self._timers.pop(event) + except KeyError: + timer = None + + if timer is not None: + timer.cancel() diff --git a/requirements.txt b/requirements.txt index a743508..3f03d01 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,7 @@ -APScheduler~=3.10.4 build~=1.0.3 coverage~=7.3.1 flake8~=6.1.0 mypy~=1.5.1 -pokerkit~=0.2.1 +pokerkit~=0.3.0 Sphinx~=7.2.6 twine~=4.0.2 diff --git a/setup.py b/setup.py index 4b1dfbd..af55660 100644 --- a/setup.py +++ b/setup.py @@ -50,7 +50,8 @@ 'Source': 'https://github.com/uoftcprg/cardroom', 'Tracker': 'https://github.com/uoftcprg/cardroom/issues', }, - package_data={'cardroom': ['py.typed']}, packages=find_packages(), + install_requires=['pokerkit~=0.3.0'], python_requires='>=3.11', + package_data={'cardroom': ['py.typed']}, )