./blackjack/__init__.py
from blackjack.base import Card as Card
from blackjack.dealer import BlackjackDealer as Dealer
from blackjack.judger import BlackjackJudger as Judger
from blackjack.player import BlackjackPlayer as Player
from blackjack.game import BlackjackGame as Game

./blackjack/base.py
''' Game-related base classes
'''
class Card:
    '''
    Card stores the suit and rank of a single card

    Note:
        The suit variable in a standard card game should be one of [S, H, D, C, BJ, RJ] meaning [Spades, Hearts, Diamonds, Clubs, Black Joker, Red Joker]
        Similarly the rank variable should be one of [A, 2, 3, 4, 5, 6, 7, 8, 9, T, J, Q, K]
    '''
    suit = None
    rank = None
    valid_suit = ['S', 'H', 'D', 'C', 'BJ', 'RJ']
    valid_rank = ['A', '2', '3', '4', '5', '6', '7', '8', '9', 'T', 'J', 'Q', 'K']

    def __init__(self, suit, rank):
        ''' Initialize the suit and rank of a card

        Args:
            suit: string, suit of the card, should be one of valid_suit
            rank: string, rank of the card, should be one of valid_rank
        '''
        self.suit = suit
        self.rank = rank

    def __eq__(self, other):
        if isinstance(other, Card):
            return self.rank == other.rank and self.suit == other.suit
        else:
            # don't attempt to compare against unrelated types
            return NotImplemented

    def __hash__(self):
        suit_index = Card.valid_suit.index(self.suit)
        rank_index = Card.valid_rank.index(self.rank)
        return rank_index + 100 * suit_index

    def __str__(self):
        ''' Get string representation of a card.

        Returns:
            string: the combination of rank and suit of a card. Eg: AS, 5H, JD, 3C, ...
        '''
        return self.rank + self.suit

    def get_index(self):
        ''' Get index of a card.

        Returns:
            string: the combination of suit and rank of a card. Eg: 1S, 2H, AD, BJ, RJ...
        '''
        return self.suit+self.rank

./blackjack/dealer.py
import numpy as np
from blackjack import Card

def init_standard_deck():
    ''' Initialize a standard deck of 52 cards

    Returns:
        (list): A list of Card object
    '''
    suit_list = ['S', 'H', 'D', 'C']
    rank_list = ['A', '2', '3', '4', '5', '6', '7', '8', '9', 'T', 'J', 'Q', 'K']
    res = [Card(suit, rank) for suit in suit_list for rank in rank_list]
    return res

class BlackjackDealer:

    def __init__(self, np_random, num_decks=1):
        ''' Initialize a Blackjack dealer class
        '''
        self.np_random = np_random
        self.num_decks = num_decks
        self.deck = init_standard_deck()
        if self.num_decks not in [0, 1]:  # 0 indicates infinite decks of cards
            self.deck = self.deck * self.num_decks  # copy m standard decks of cards
        self.shuffle()
        self.hand = []
        self.status = 'alive'
        self.score = 0

    def shuffle(self):
        ''' Shuffle the deck
        '''
        shuffle_deck = np.array(self.deck)
        self.np_random.shuffle(shuffle_deck)
        self.deck = list(shuffle_deck)

    def deal_card(self, player):
        ''' Distribute one card to the player

        Args:
            player_id (int): the target player's id
        '''
        idx = self.np_random.choice(len(self.deck))
        card = self.deck[idx]
        if self.num_decks != 0:  # If infinite decks, do not pop card from deck
            self.deck.pop(idx)
        # card = self.deck.pop()
        player.hand.append(card)

./blackjack/game.py
from copy import deepcopy
import numpy as np

from blackjack import Dealer
from blackjack import Player
from blackjack import Judger

class BlackjackGame:

    def __init__(self, allow_step_back=False):
        ''' Initialize the class Blackjack Game
        '''
        self.allow_step_back = allow_step_back
        self.np_random = np.random.RandomState()

    def configure(self, game_config):
        ''' Specifiy some game specific parameters, such as number of players
        '''
        self.num_players = game_config['game_num_players']
        self.num_decks = game_config['game_num_decks']

    def init_game(self):
        ''' Initialilze the game

        Returns:
            state (dict): the first state of the game
            player_id (int): current player's id
        '''
        self.dealer = Dealer(self.np_random, self.num_decks)

        self.players = []
        for i in range(self.num_players):
            self.players.append(Player(i, self.np_random))

        self.judger = Judger(self.np_random)

        for i in range(2):
            for j in range(self.num_players):
                self.dealer.deal_card(self.players[j])
            self.dealer.deal_card(self.dealer)

        for i in range(self.num_players):
            self.players[i].status, self.players[i].score = self.judger.judge_round(self.players[i])

        self.dealer.status, self.dealer.score = self.judger.judge_round(self.dealer)

        self.winner = {'dealer': 0}
        for i in range(self.num_players):
            self.winner['player' + str(i)] = 0

        self.history = []
        self.game_pointer = 0

        return self.get_state(self.game_pointer), self.game_pointer

    def step(self, action):
        ''' Get the next state

        Args:
            action (str): a specific action of blackjack. (Hit or Stand)

        Returns:/
            dict: next player's state
            int: next plater's id
        '''
        if self.allow_step_back:
            p = deepcopy(self.players[self.game_pointer])
            d = deepcopy(self.dealer)
            w = deepcopy(self.winner)
            self.history.append((d, p, w))

        next_state = {}
        # Play hit
        if action != "stand":
            self.dealer.deal_card(self.players[self.game_pointer])
            self.players[self.game_pointer].status, self.players[self.game_pointer].score = self.judger.judge_round(
                self.players[self.game_pointer])
            if self.players[self.game_pointer].status == 'bust':
                # game over, set up the winner, print out dealer's hand # If bust, pass the game pointer
                if self.game_pointer >= self.num_players - 1:
                    while self.judger.judge_score(self.dealer.hand) < 17:
                        self.dealer.deal_card(self.dealer)
                    self.dealer.status, self.dealer.score = self.judger.judge_round(self.dealer)
                    for i in range(self.num_players):
                        self.judger.judge_game(self, i)
                    self.game_pointer = 0
                else:
                    self.game_pointer += 1

        elif action == "stand": # If stand, first try to pass the pointer, if it's the last player, dealer deal for himself, then judge game for everyone using a loop
            self.players[self.game_pointer].status, self.players[self.game_pointer].score = self.judger.judge_round(
                self.players[self.game_pointer])
            if self.game_pointer >= self.num_players - 1:
                while self.judger.judge_score(self.dealer.hand) < 17:
                    self.dealer.deal_card(self.dealer)
                self.dealer.status, self.dealer.score = self.judger.judge_round(self.dealer)
                for i in range(self.num_players):
                    self.judger.judge_game(self, i)
                self.game_pointer = 0
            else:
                self.game_pointer += 1

        hand = [card.get_index() for card in self.players[self.game_pointer].hand]

        if self.is_over():
            dealer_hand = [card.get_index() for card in self.dealer.hand]
        else:
            dealer_hand = [card.get_index() for card in self.dealer.hand[1:]]

        for i in range(self.num_players):
            next_state['player' + str(i) + ' hand'] = [card.get_index() for card in self.players[i].hand]
        next_state['dealer hand'] = dealer_hand
        next_state['actions'] = ('hit', 'stand')
        next_state['state'] = (hand, dealer_hand)

        return next_state, self.game_pointer

    def step_back(self):
        ''' Return to the previous state of the game

        Returns:
            Status (bool): check if the step back is success or not
        '''
        #while len(self.history) > 0:
        if len(self.history) > 0:
            self.dealer, self.players[self.game_pointer], self.winner = self.history.pop()
            return True
        return False

    def get_num_players(self):
        ''' Return the number of players in blackjack

        Returns:
            number_of_player (int): blackjack only have 1 player
        '''
        return self.num_players

    @staticmethod
    def get_num_actions():
        ''' Return the number of applicable actions

        Returns:
            number_of_actions (int): there are only two actions (hit and stand)
        '''
        return 2

    def get_player_id(self):
        ''' Return the current player's id

        Returns:
            player_id (int): current player's id
        '''
        return self.game_pointer

    def get_state(self, player_id):
        ''' Return player's state

        Args:
            player_id (int): player id

        Returns:
            state (dict): corresponding player's state
        '''
        '''
                before change state only have two keys (action, state)
                but now have more than 4 keys (action, state, player0 hand, player1 hand, ... , dealer hand)
                Although key 'state' have duplicated information with key 'player hand' and 'dealer hand', I couldn't remove it because of other codes
                To remove it, we need to change dqn agent too in my opinion
                '''
        state = {}
        state['actions'] = ('hit', 'stand')
        hand = [card.get_index() for card in self.players[player_id].hand]
        if self.is_over():
            dealer_hand = [card.get_index() for card in self.dealer.hand]
        else:
            dealer_hand = [card.get_index() for card in self.dealer.hand[1:]]

        for i in range(self.num_players):
            state['player' + str(i) + ' hand'] = [card.get_index() for card in self.players[i].hand]
        state['dealer hand'] = dealer_hand
        state['state'] = (hand, dealer_hand)

        return state

    def is_over(self):
        ''' Check if the game is over

        Returns:
            status (bool): True/False
        '''
        '''
                I should change here because judger and self.winner is changed too
                '''
        for i in range(self.num_players):
            if self.winner['player' + str(i)] == 0:
                return False

        return True

./blackjack/judger.py
class BlackjackJudger:
    def __init__(self, np_random):
        ''' Initialize a BlackJack judger class
        '''
        self.np_random = np_random
        self.rank2score = {"A":11, "2":2, "3":3, "4":4, "5":5, "6":6, "7":7, "8":8, "9":9, "T":10, "J":10, "Q":10, "K":10}

    def judge_round(self, player):
        ''' Judge the target player's status

        Args:
            player (int): target player's id

        Returns:
            status (str): the status of the target player
            score (int): the current score of the player
        '''
        score = self.judge_score(player.hand)
        if score <= 21:
            return "alive", score
        else:
            return "bust", score

    def judge_game(self, game, game_pointer):
        ''' Judge the winner of the game

        Args:
            game (class): target game class
        '''
        '''
                game.winner['dealer'] doesn't need anymore if we change code like this

                player bust (whether dealer bust or not) => game.winner[playerX] = -1
                player and dealer tie => game.winner[playerX] = 1
                dealer bust and player not bust => game.winner[playerX] = 2
                player get higher score than dealer => game.winner[playerX] = 2
                dealer get higher score than player => game.winner[playerX] = -1
                game.winner[playerX] = 0 => the game is still ongoing
                '''

        if game.players[game_pointer].status == 'bust':
            game.winner['player' + str(game_pointer)] = -1
        elif game.dealer.status == 'bust':
            game.winner['player' + str(game_pointer)] = 2
        else:
            if game.players[game_pointer].score > game.dealer.score:
                game.winner['player' + str(game_pointer)] = 2
            elif game.players[game_pointer].score < game.dealer.score:
                game.winner['player' + str(game_pointer)] = -1
            else:
                game.winner['player' + str(game_pointer)] = 1

    def judge_score(self, cards):
        ''' Judge the score of a given cards set

        Args:
            cards (list): a list of cards

        Returns:
            score (int): the score of the given cards set
        '''
        score = 0
        count_a = 0
        for card in cards:
            card_score = self.rank2score[card.rank]
            score += card_score
            if card.rank == 'A':
                count_a += 1
        while score > 21 and count_a > 0:
            count_a -= 1
            score -= 10
        return score

./blackjack/player.py
class BlackjackPlayer:

    def __init__(self, player_id, np_random):
        ''' Initialize a Blackjack player class

        Args:
            player_id (int): id for the player
        '''
        self.np_random = np_random
        self.player_id = player_id
        self.hand = []
        self.status = 'alive'
        self.score = 0

    def get_player_id(self):
        ''' Return player's id
        '''
        return self.player_id


./bridge/__init__.py
from bridge.base import Card as Card
from bridge.player import BridgePlayer as Player
from bridge.dealer import BridgeDealer as Dealer
from bridge.judger import BridgeJudger as Judger
from bridge.game import BridgeGame as Game

./bridge/action_event.py
from bridge_card import BridgeCard

# ====================================
# Action_ids:
#       0 -> no_bid_action_id
#       1 to 35 -> bid_action_id (bid amount by suit or NT)
#       36 -> pass_action_id
#       37 -> dbl_action_id
#       38 -> rdbl_action_id
#       39 to 90 -> play_card_action_id
# ====================================

class ActionEvent(object):  # Interface

    no_bid_action_id = 0
    first_bid_action_id = 1
    pass_action_id = 36
    dbl_action_id = 37
    rdbl_action_id = 38
    first_play_card_action_id = 39

    def __init__(self, action_id: int):
        self.action_id = action_id

    def __eq__(self, other):
        result = False
        if isinstance(other, ActionEvent):
            result = self.action_id == other.action_id
        return result

    @staticmethod
    def from_action_id(action_id: int):
        if action_id == ActionEvent.pass_action_id:
            return PassAction()
        elif ActionEvent.first_bid_action_id <= action_id <= 35:
            bid_amount = 1 + (action_id - ActionEvent.first_bid_action_id) // 5
            bid_suit_id = (action_id - ActionEvent.first_bid_action_id) % 5
            bid_suit = BridgeCard.suits[bid_suit_id] if bid_suit_id < 4 else None
            return BidAction(bid_amount, bid_suit)
        elif action_id == ActionEvent.dbl_action_id:
            return DblAction()
        elif action_id == ActionEvent.rdbl_action_id:
            return RdblAction()
        elif ActionEvent.first_play_card_action_id <= action_id < ActionEvent.first_play_card_action_id + 52:
            card_id = action_id - ActionEvent.first_play_card_action_id
            card = BridgeCard.card(card_id=card_id)
            return PlayCardAction(card=card)
        else:
            raise Exception(f'ActionEvent from_action_id: invalid action_id={action_id}')

    @staticmethod
    def get_num_actions():
        ''' Return the number of possible actions in the game
        '''
        return 1 + 35 + 3 + 52  # no_bid, 35 bids, pass, dbl, rdl, 52 play_card

class CallActionEvent(ActionEvent):  # Interface
    pass

class PassAction(CallActionEvent):

    def __init__(self):
        super().__init__(action_id=ActionEvent.pass_action_id)

    def __str__(self):
        return "pass"

    def __repr__(self):
        return "pass"

class BidAction(CallActionEvent):

    def __init__(self, bid_amount: int, bid_suit: str or None):
        suits = BridgeCard.suits
        if bid_suit and bid_suit not in suits:
            raise Exception(f'BidAction has invalid suit: {bid_suit}')
        if bid_suit in suits:
            bid_suit_id = suits.index(bid_suit)
        else:
            bid_suit_id = 4
        bid_action_id = bid_suit_id + 5 * (bid_amount - 1) + ActionEvent.first_bid_action_id
        super().__init__(action_id=bid_action_id)
        self.bid_amount = bid_amount
        self.bid_suit = bid_suit

    def __str__(self):
        bid_suit = self.bid_suit
        if not bid_suit:
            bid_suit = 'NT'
        return f'{self.bid_amount}{bid_suit}'

    def __repr__(self):
        return self.__str__()

class DblAction(CallActionEvent):

    def __init__(self):
        super().__init__(action_id=ActionEvent.dbl_action_id)

    def __str__(self):
        return "dbl"

    def __repr__(self):
        return "dbl"

class RdblAction(CallActionEvent):

    def __init__(self):
        super().__init__(action_id=ActionEvent.rdbl_action_id)

    def __str__(self):
        return "rdbl"

    def __repr__(self):
        return "rdbl"

class PlayCardAction(ActionEvent):

    def __init__(self, card: BridgeCard):
        play_card_action_id = ActionEvent.first_play_card_action_id + card.card_id
        super().__init__(action_id=play_card_action_id)
        self.card: BridgeCard = card

    def __str__(self):
        return f"{self.card}"

    def __repr__(self):
        return f"{self.card}"

./bridge/base.py
''' Game-related base classes
'''
class Card:
    '''
    Card stores the suit and rank of a single card

    Note:
        The suit variable in a standard card game should be one of [S, H, D, C, BJ, RJ] meaning [Spades, Hearts, Diamonds, Clubs, Black Joker, Red Joker]
        Similarly the rank variable should be one of [A, 2, 3, 4, 5, 6, 7, 8, 9, T, J, Q, K]
    '''
    suit = None
    rank = None
    valid_suit = ['S', 'H', 'D', 'C', 'BJ', 'RJ']
    valid_rank = ['A', '2', '3', '4', '5', '6', '7', '8', '9', 'T', 'J', 'Q', 'K']

    def __init__(self, suit, rank):
        ''' Initialize the suit and rank of a card

        Args:
            suit: string, suit of the card, should be one of valid_suit
            rank: string, rank of the card, should be one of valid_rank
        '''
        self.suit = suit
        self.rank = rank

    def __eq__(self, other):
        if isinstance(other, Card):
            return self.rank == other.rank and self.suit == other.suit
        else:
            # don't attempt to compare against unrelated types
            return NotImplemented

    def __hash__(self):
        suit_index = Card.valid_suit.index(self.suit)
        rank_index = Card.valid_rank.index(self.rank)
        return rank_index + 100 * suit_index

    def __str__(self):
        ''' Get string representation of a card.

        Returns:
            string: the combination of rank and suit of a card. Eg: AS, 5H, JD, 3C, ...
        '''
        return self.rank + self.suit

    def get_index(self):
        ''' Get index of a card.

        Returns:
            string: the combination of suit and rank of a card. Eg: 1S, 2H, AD, BJ, RJ...
        '''
        return self.suit+self.rank

./bridge/bridge_card.py
from bridge import Card

class BridgeCard(Card):

    suits = ['C', 'D', 'H', 'S']
    ranks = ['2', '3', '4', '5', '6', '7', '8', '9', 'T', 'J', 'Q', 'K', 'A']

    @staticmethod
    def card(card_id: int):
        return _deck[card_id]

    @staticmethod
    def get_deck() -> [Card]:
        return _deck.copy()

    def __init__(self, suit: str, rank: str):
        super().__init__(suit=suit, rank=rank)
        suit_index = BridgeCard.suits.index(self.suit)
        rank_index = BridgeCard.ranks.index(self.rank)
        self.card_id = 13 * suit_index + rank_index

    def __str__(self):
        return f'{self.rank}{self.suit}'

    def __repr__(self):
        return f'{self.rank}{self.suit}'

# deck is always in order from 2C, ... KC, AC, 2D, ... KD, AD, 2H, ... KH, AH, 2S, ... KS, AS
_deck = [BridgeCard(suit=suit, rank=rank) for suit in BridgeCard.suits for rank in BridgeCard.ranks]  # want this to be read-only

./bridge/dealer.py
from typing import List

from bridge import Player
from bridge_card import BridgeCard

class BridgeDealer:
    ''' Initialize a BridgeDealer dealer class
    '''
    def __init__(self, np_random):
        ''' set shuffled_deck, set stock_pile
        '''
        self.np_random = np_random
        self.shuffled_deck: List[BridgeCard] = BridgeCard.get_deck()  # keep a copy of the shuffled cards at start of new hand
        self.np_random.shuffle(self.shuffled_deck)
        self.stock_pile: List[BridgeCard] = self.shuffled_deck.copy()

    def deal_cards(self, player: Player, num: int):
        ''' Deal some cards from stock_pile to one player

        Args:
            player (BridgePlayer): The BridgePlayer object
            num (int): The number of cards to be dealt
        '''
        for _ in range(num):
            player.hand.append(self.stock_pile.pop())

./bridge/game.py
from typing import List

import numpy as np

from bridge import Judger
from round import BridgeRound
from action_event import ActionEvent, CallActionEvent, PlayCardAction

class BridgeGame:
    ''' Game class. This class will interact with outer environment.
    '''

    def __init__(self, allow_step_back=False):
        '''Initialize the class BridgeGame
        '''
        self.allow_step_back: bool = allow_step_back
        self.np_random = np.random.RandomState()
        self.judger: Judger = Judger(game=self)
        self.actions: [ActionEvent] = []  # must reset in init_game
        self.round: BridgeRound or None = None  # must reset in init_game
        self.num_players: int = 4

    def init_game(self):
        ''' Initialize all characters in the game and start round 1
        '''
        board_id = self.np_random.choice([1, 2, 3, 4])
        self.actions: List[ActionEvent] = []
        self.round = BridgeRound(num_players=self.num_players, board_id=board_id, np_random=self.np_random)
        for player_id in range(4):
            player = self.round.players[player_id]
            self.round.dealer.deal_cards(player=player, num=13)
        current_player_id = self.round.current_player_id
        state = self.get_state(player_id=current_player_id)
        return state, current_player_id

    def step(self, action: ActionEvent):
        ''' Perform game action and return next player number, and the state for next player
        '''
        if isinstance(action, CallActionEvent):
            self.round.make_call(action=action)
        elif isinstance(action, PlayCardAction):
            self.round.play_card(action=action)
        else:
            raise Exception(f'Unknown step action={action}')
        self.actions.append(action)
        next_player_id = self.round.current_player_id
        next_state = self.get_state(player_id=next_player_id)
        return next_state, next_player_id

    def get_num_players(self) -> int:
        ''' Return the number of players in the game
        '''
        return self.num_players

    @staticmethod
    def get_num_actions() -> int:
        ''' Return the number of possible actions in the game
        '''
        return ActionEvent.get_num_actions()

    def get_player_id(self):
        ''' Return the current player that will take actions soon
        '''
        return self.round.current_player_id

    def is_over(self) -> bool:
        ''' Return whether the current game is over
        '''
        return self.round.is_over()

    def get_state(self, player_id: int):  # wch: not really used
        ''' Get player's state

        Return:
            state (dict): The information of the state
        '''
        state = {}
        if not self.is_over():
            state['player_id'] = player_id
            state['current_player_id'] = self.round.current_player_id
            state['hand'] = self.round.players[player_id].hand
        else:
            state['player_id'] = player_id
            state['current_player_id'] = self.round.current_player_id
            state['hand'] = self.round.players[player_id].hand
        return state

./bridge/judger.py
from typing import List

from typing import TYPE_CHECKING

from action_event import PlayCardAction
from action_event import ActionEvent, BidAction, PassAction, DblAction, RdblAction
from move import MakeBidMove, MakeDblMove, MakeRdblMove
from bridge_card import BridgeCard

class BridgeJudger:

    '''
        Judger decides legal actions for current player
    '''

    def __init__(self, game: 'BridgeGame'):
        ''' Initialize the class BridgeJudger
        :param game: BridgeGame
        '''
        self.game: BridgeGame = game

    def get_legal_actions(self) -> List[ActionEvent]:
        """
        :return: List[ActionEvent] of legal actions
        """
        legal_actions: List[ActionEvent] = []
        if not self.game.is_over():
            current_player = self.game.round.get_current_player()
            if not self.game.round.is_bidding_over():
                legal_actions.append(PassAction())
                last_make_bid_move: MakeBidMove or None = None
                last_dbl_move: MakeDblMove or None = None
                last_rdbl_move: MakeRdblMove or None = None
                for move in reversed(self.game.round.move_sheet):
                    if isinstance(move, MakeBidMove):
                        last_make_bid_move = move
                        break
                    elif isinstance(move, MakeRdblMove):
                        last_rdbl_move = move
                    elif isinstance(move, MakeDblMove) and not last_rdbl_move:
                        last_dbl_move = move
                first_bid_action_id = ActionEvent.first_bid_action_id
                next_bid_action_id = last_make_bid_move.action.action_id + 1 if last_make_bid_move else first_bid_action_id
                for bid_action_id in range(next_bid_action_id, first_bid_action_id + 35):
                    action = BidAction.from_action_id(action_id=bid_action_id)
                    legal_actions.append(action)
                if last_make_bid_move and last_make_bid_move.player.player_id % 2 != current_player.player_id % 2 and not last_dbl_move and not last_rdbl_move:
                    legal_actions.append(DblAction())
                if last_dbl_move and last_dbl_move.player.player_id % 2 != current_player.player_id % 2:
                    legal_actions.append(RdblAction())
            else:
                trick_moves = self.game.round.get_trick_moves()
                hand = self.game.round.players[current_player.player_id].hand
                legal_cards = hand
                if trick_moves and len(trick_moves) < 4:
                    led_card: BridgeCard = trick_moves[0].card
                    cards_of_led_suit = [card for card in hand if card.suit == led_card.suit]
                    if cards_of_led_suit:
                        legal_cards = cards_of_led_suit
                for card in legal_cards:
                    action = PlayCardAction(card=card)
                    legal_actions.append(action)
        return legal_actions

./bridge/move.py
#
#   These classes are used to keep a move_sheet history of the moves in a round.
#

from action_event import ActionEvent, BidAction, PassAction, DblAction, RdblAction, PlayCardAction
from bridge_card import BridgeCard

from bridge import Player

class BridgeMove(object):  # Interface
    pass

class PlayerMove(BridgeMove):  # Interface

    def __init__(self, player: Player, action: ActionEvent):
        super().__init__()
        self.player = player
        self.action = action

class CallMove(PlayerMove):  # Interface

    def __init__(self, player: Player, action: ActionEvent):
        super().__init__(player=player, action=action)

class DealHandMove(BridgeMove):

    def __init__(self, dealer: Player, shuffled_deck: [BridgeCard]):
        super().__init__()
        self.dealer = dealer
        self.shuffled_deck = shuffled_deck

    def __str__(self):
        shuffled_deck_text = " ".join([str(card) for card in self.shuffled_deck])
        return f'{self.dealer} deal shuffled_deck=[{shuffled_deck_text}]'

class MakePassMove(CallMove):

    def __init__(self, player: Player):
        super().__init__(player=player, action=PassAction())

    def __str__(self):
        return f'{self.player} {self.action}'

class MakeDblMove(CallMove):

    def __init__(self, player: Player):
        super().__init__(player=player, action=DblAction())

    def __str__(self):
        return f'{self.player} {self.action}'

class MakeRdblMove(CallMove):

    def __init__(self, player: Player):
        super().__init__(player=player, action=RdblAction())

    def __str__(self):
        return f'{self.player} {self.action}'

class MakeBidMove(CallMove):

    def __init__(self, player: Player, bid_action: BidAction):
        super().__init__(player=player, action=bid_action)
        self.action = bid_action  # Note: keep type as BidAction rather than ActionEvent

    def __str__(self):
        return f'{self.player} bids {self.action}'

class PlayCardMove(PlayerMove):

    def __init__(self, player: Player, action: PlayCardAction):
        super().__init__(player=player, action=action)
        self.action = action  # Note: keep type as PlayCardAction rather than ActionEvent

    @property
    def card(self):
        return self.action.card

    def __str__(self):
        return f'{self.player} plays {self.action}'

./bridge/player.py
from typing import List

from bridge_card import BridgeCard

class BridgePlayer:

    def __init__(self, player_id: int, np_random):
        ''' Initialize a BridgePlayer player class

        Args:
            player_id (int): id for the player
        '''
        if player_id < 0 or player_id > 3:
            raise Exception(f'BridgePlayer has invalid player_id: {player_id}')
        self.np_random = np_random
        self.player_id: int = player_id
        self.hand: List[BridgeCard] = []

    def remove_card_from_hand(self, card: BridgeCard):
        self.hand.remove(card)

    def __str__(self):
        return ['N', 'E', 'S', 'W'][self.player_id]

./bridge/round.py
from typing import List

from bridge import Dealer
from bridge import Player

from action_event import CallActionEvent, PassAction, DblAction, RdblAction, BidAction, PlayCardAction
from move import BridgeMove, DealHandMove, PlayCardMove, MakeBidMove, MakePassMove, MakeDblMove, MakeRdblMove, CallMove
from tray import Tray

class BridgeRound:

    @property
    def dealer_id(self) -> int:
        return self.tray.dealer_id

    @property
    def vul(self):
        return self.tray.vul

    @property
    def board_id(self) -> int:
        return self.tray.board_id

    @property
    def round_phase(self):
        if self.is_over():
            result = 'game over'
        elif self.is_bidding_over():
            result = 'play card'
        else:
            result = 'make bid'
        return result

    def __init__(self, num_players: int, board_id: int, np_random):
        ''' Initialize the round class

            The round class maintains the following instances:
                1) dealer: the dealer of the round; dealer has trick_pile
                2) players: the players in the round; each player has his own hand_pile
                3) current_player_id: the id of the current player who has the move
                4) doubling_cube: 2 if contract is doubled; 4 if contract is redoubled; else 1
                5) play_card_count: count of PlayCardMoves
                5) move_sheet: history of the moves of the players (including the deal_hand_move)

            The round class maintains a list of moves made by the players in self.move_sheet.
            move_sheet is similar to a chess score sheet.
            I didn't want to call it a score_sheet since it is not keeping score.
            I could have called move_sheet just moves, but that might conflict with the name moves used elsewhere.
            I settled on the longer name "move_sheet" to indicate that it is the official list of moves being made.

        Args:
            num_players: int
            board_id: int
            np_random
        '''
        tray = Tray(board_id=board_id)
        dealer_id = tray.dealer_id
        self.tray = tray
        self.np_random = np_random
        self.dealer: Dealer = Dealer(self.np_random)
        self.players: List[Player] = []
        for player_id in range(num_players):
            self.players.append(Player(player_id=player_id, np_random=self.np_random))
        self.current_player_id: int = dealer_id
        self.doubling_cube: int = 1
        self.play_card_count: int = 0
        self.contract_bid_move: MakeBidMove or None = None
        self.won_trick_counts = [0, 0]  # count of won tricks by side
        self.move_sheet: List[BridgeMove] = []
        self.move_sheet.append(DealHandMove(dealer=self.players[dealer_id], shuffled_deck=self.dealer.shuffled_deck))

    def is_bidding_over(self) -> bool:
        ''' Return whether the current bidding is over
        '''
        is_bidding_over = True
        if len(self.move_sheet) < 5:
            is_bidding_over = False
        else:
            last_make_pass_moves: List[MakePassMove] = []
            for move in reversed(self.move_sheet):
                if isinstance(move, MakePassMove):
                    last_make_pass_moves.append(move)
                    if len(last_make_pass_moves) == 3:
                        break
                elif isinstance(move, CallMove):
                    is_bidding_over = False
                    break
                else:
                    break
        return is_bidding_over

    def is_over(self) -> bool:
        ''' Return whether the current game is over
        '''
        is_over = True
        if not self.is_bidding_over():
            is_over = False
        elif self.contract_bid_move:
            for player in self.players:
                if player.hand:
                    is_over = False
                    break
        return is_over

    def get_current_player(self) -> Player or None:
        current_player_id = self.current_player_id
        return None if current_player_id is None else self.players[current_player_id]

    def get_trick_moves(self) -> List[PlayCardMove]:
        trick_moves: List[PlayCardMove] = []
        if self.is_bidding_over():
            if self.play_card_count > 0:
                trick_pile_count = self.play_card_count % 4
                if trick_pile_count == 0:
                    trick_pile_count = 4  # wch: note this
                for move in self.move_sheet[-trick_pile_count:]:
                    if isinstance(move, PlayCardMove):
                        trick_moves.append(move)
                if len(trick_moves) != trick_pile_count:
                    raise Exception(f'get_trick_moves: count of trick_moves={[str(move.card) for move in trick_moves]} does not equal {trick_pile_count}')
        return trick_moves

    def get_trump_suit(self) -> str or None:
        trump_suit = None
        if self.contract_bid_move:
            trump_suit = self.contract_bid_move.action.bid_suit
        return trump_suit

    def make_call(self, action: CallActionEvent):
        # when current_player takes CallActionEvent step, the move is recorded and executed
        current_player = self.players[self.current_player_id]
        if isinstance(action, PassAction):
            self.move_sheet.append(MakePassMove(current_player))
        elif isinstance(action, BidAction):
            self.doubling_cube = 1
            make_bid_move = MakeBidMove(current_player, action)
            self.contract_bid_move = make_bid_move
            self.move_sheet.append(make_bid_move)
        elif isinstance(action, DblAction):
            self.doubling_cube = 2
            self.move_sheet.append(MakeDblMove(current_player))
        elif isinstance(action, RdblAction):
            self.doubling_cube = 4
            self.move_sheet.append(MakeRdblMove(current_player))
        if self.is_bidding_over():
            if not self.is_over():
                self.current_player_id = self.get_left_defender().player_id
        else:
            self.current_player_id = (self.current_player_id + 1) % 4

    def play_card(self, action: PlayCardAction):
        # when current_player takes PlayCardAction step, the move is recorded and executed
        current_player = self.players[self.current_player_id]
        self.move_sheet.append(PlayCardMove(current_player, action))
        card = action.card
        current_player.remove_card_from_hand(card=card)
        self.play_card_count += 1
        # update current_player_id
        trick_moves = self.get_trick_moves()
        if len(trick_moves) == 4:
            trump_suit = self.get_trump_suit()
            winning_card = trick_moves[0].card
            trick_winner = trick_moves[0].player
            for move in trick_moves[1:]:
                trick_card = move.card
                trick_player = move.player
                if trick_card.suit == winning_card.suit:
                    if trick_card.card_id > winning_card.card_id:
                        winning_card = trick_card
                        trick_winner = trick_player
                elif trick_card.suit == trump_suit:
                    winning_card = trick_card
                    trick_winner = trick_player
            self.current_player_id = trick_winner.player_id
            self.won_trick_counts[trick_winner.player_id % 2] += 1
        else:
            self.current_player_id = (self.current_player_id + 1) % 4

    def get_declarer(self) -> Player or None:
        declarer = None
        if self.contract_bid_move:
            trump_suit = self.contract_bid_move.action.bid_suit
            side = self.contract_bid_move.player.player_id % 2
            for move in self.move_sheet:
                if isinstance(move, MakeBidMove) and move.action.bid_suit == trump_suit and move.player.player_id % 2 == side:
                    declarer = move.player
                    break
        return declarer

    def get_dummy(self) -> Player or None:
        dummy = None
        declarer = self.get_declarer()
        if declarer:
            dummy = self.players[(declarer.player_id + 2) % 4]
        return dummy

    def get_left_defender(self) -> Player or None:
        left_defender = None
        declarer = self.get_declarer()
        if declarer:
            left_defender = self.players[(declarer.player_id + 1) % 4]
        return left_defender

    def get_right_defender(self) -> Player or None:
        right_defender = None
        declarer = self.get_declarer()
        if declarer:
            right_defender = self.players[(declarer.player_id + 3) % 4]
        return right_defender

    def get_perfect_information(self):
        state = {}
        last_call_move = None
        if not self.is_bidding_over() or self.play_card_count == 0:
            last_move = self.move_sheet[-1]
            if isinstance(last_move, CallMove):
                last_call_move = last_move
        trick_moves = [None, None, None, None]
        if self.is_bidding_over():
            for trick_move in self.get_trick_moves():
                trick_moves[trick_move.player.player_id] = trick_move.card
        state['move_count'] = len(self.move_sheet)
        state['tray'] = self.tray
        state['current_player_id'] = self.current_player_id
        state['round_phase'] = self.round_phase
        state['last_call_move'] = last_call_move
        state['doubling_cube'] = self.doubling_cube
        state['contact'] = self.contract_bid_move if self.is_bidding_over() and self.contract_bid_move else None
        state['hands'] = [player.hand for player in self.players]
        state['trick_moves'] = trick_moves
        return state

    def print_scene(self):
        print(f'===== Board: {self.tray.board_id} move: {len(self.move_sheet)} player: {self.players[self.current_player_id]} phase: {self.round_phase} =====')
        print(f'dealer={self.players[self.tray.dealer_id]}')
        print(f'vul={self.vul}')
        if not self.is_bidding_over() or self.play_card_count == 0:
            last_move = self.move_sheet[-1]
            last_call_text = f'{last_move}' if isinstance(last_move, CallMove) else 'None'
            print(f'last call: {last_call_text}')
        if self.is_bidding_over() and self.contract_bid_move:
            bid_suit = self.contract_bid_move.action.bid_suit
            doubling_cube = self.doubling_cube
            if not bid_suit:
                bid_suit = 'NT'
            doubling_cube_text = "" if doubling_cube == 1 else "dbl" if doubling_cube == 2 else "rdbl"
            print(f'contract: {self.contract_bid_move.player} {self.contract_bid_move.action.bid_amount}{bid_suit} {doubling_cube_text}')
        for player in self.players:
            print(f'{player}: {[str(card) for card in player.hand]}')
        if self.is_bidding_over():
            trick_pile = ['None', 'None', 'None', 'None']
            for trick_move in self.get_trick_moves():
                trick_pile[trick_move.player.player_id] = trick_move.card
            print(f'trick_pile: {[str(card) for card in trick_pile]}')

./bridge/tray.py
class Tray(object):

    def __init__(self, board_id: int):
        if board_id <= 0:
            raise Exception(f'Tray: invalid board_id={board_id}')
        self.board_id = board_id

    @property
    def dealer_id(self):
        return (self.board_id - 1) % 4

    @property
    def vul(self):
        vul_none = [0, 0, 0, 0]
        vul_n_s = [1, 0, 1, 0]
        vul_e_w = [0, 1, 0, 1]
        vul_all = [1, 1, 1, 1]
        basic_vuls = [vul_none, vul_n_s, vul_e_w, vul_all]
        offset = (self.board_id - 1) // 4
        return basic_vuls[(self.board_id - 1 + offset) % 4]

    def __str__(self):
        return f'{self.board_id}: dealer_id={self.dealer_id} vul={self.vul}'


./doudizhu/__init__.py
from doudizhu.base import Card as Card
from doudizhu.dealer import DoudizhuDealer as Dealer
from doudizhu.judger import DoudizhuJudger as Judger
from doudizhu.player import DoudizhuPlayer as Player
from doudizhu.round import DoudizhuRound as Round
from doudizhu.game import DoudizhuGame as Game

./doudizhu/base.py
''' Game-related base classes
'''
class Card:
    '''
    Card stores the suit and rank of a single card

    Note:
        The suit variable in a standard card game should be one of [S, H, D, C, BJ, RJ] meaning [Spades, Hearts, Diamonds, Clubs, Black Joker, Red Joker]
        Similarly the rank variable should be one of [A, 2, 3, 4, 5, 6, 7, 8, 9, T, J, Q, K]
    '''
    suit = None
    rank = None
    valid_suit = ['S', 'H', 'D', 'C', 'BJ', 'RJ']
    valid_rank = ['A', '2', '3', '4', '5', '6', '7', '8', '9', 'T', 'J', 'Q', 'K']

    def __init__(self, suit, rank):
        ''' Initialize the suit and rank of a card

        Args:
            suit: string, suit of the card, should be one of valid_suit
            rank: string, rank of the card, should be one of valid_rank
        '''
        self.suit = suit
        self.rank = rank

    def __eq__(self, other):
        if isinstance(other, Card):
            return self.rank == other.rank and self.suit == other.suit
        else:
            # don't attempt to compare against unrelated types
            return NotImplemented

    def __hash__(self):
        suit_index = Card.valid_suit.index(self.suit)
        rank_index = Card.valid_rank.index(self.rank)
        return rank_index + 100 * suit_index

    def __str__(self):
        ''' Get string representation of a card.

        Returns:
            string: the combination of rank and suit of a card. Eg: AS, 5H, JD, 3C, ...
        '''
        return self.rank + self.suit

    def get_index(self):
        ''' Get index of a card.

        Returns:
            string: the combination of suit and rank of a card. Eg: 1S, 2H, AD, BJ, RJ...
        '''
        return self.suit+self.rank

./doudizhu/dealer.py
# -*- coding: utf-8 -*-
''' Implement Doudizhu Dealer class
'''
import functools

from doudizhu import Card
from doudizhu.utils import cards2str, doudizhu_sort_card

def init_54_deck():
    ''' Initialize a standard deck of 52 cards, BJ and RJ

    Returns:
        (list): Alist of Card object
    '''
    suit_list = ['S', 'H', 'D', 'C']
    rank_list = ['A', '2', '3', '4', '5', '6', '7', '8', '9', 'T', 'J', 'Q', 'K']
    res = [Card(suit, rank) for suit in suit_list for rank in rank_list]
    res.append(Card('BJ', ''))
    res.append(Card('RJ', ''))
    return res

class DoudizhuDealer:
    ''' Dealer will shuffle, deal cards, and determine players' roles
    '''
    def __init__(self, np_random):
        '''Give dealer the deck

        Notes:
            1. deck with 54 cards including black joker and red joker
        '''
        self.np_random = np_random
        self.deck = init_54_deck()
        self.deck.sort(key=functools.cmp_to_key(doudizhu_sort_card))
        self.landlord = None

    def shuffle(self):
        ''' Randomly shuffle the deck
        '''
        self.np_random.shuffle(self.deck)

    def deal_cards(self, players):
        ''' Deal cards to players

        Args:
            players (list): list of DoudizhuPlayer objects
        '''
        hand_num = (len(self.deck) - 3) // len(players)
        for index, player in enumerate(players):
            current_hand = self.deck[index*hand_num:(index+1)*hand_num]
            current_hand.sort(key=functools.cmp_to_key(doudizhu_sort_card))
            player.set_current_hand(current_hand)
            player.initial_hand = cards2str(player.current_hand)

    def determine_role(self, players):
        ''' Determine landlord and peasants according to players' hand

        Args:
            players (list): list of DoudizhuPlayer objects

        Returns:
            int: landlord's player_id
        '''
        # deal cards
        self.shuffle()
        self.deal_cards(players)
        players[0].role = 'landlord'
        self.landlord = players[0]
        players[1].role = 'peasant'
        players[2].role = 'peasant'
        #players[0].role = 'peasant'
        #self.landlord = players[0]

        ## determine 'landlord'
        #max_score = get_landlord_score(
        #    cards2str(self.landlord.current_hand))
        #for player in players[1:]:
        #    player.role = 'peasant'
        #    score = get_landlord_score(
        #        cards2str(player.current_hand))
        #    if score > max_score:
        #        max_score = score
        #        self.landlord = player
        #self.landlord.role = 'landlord'

        # give the 'landlord' the  three cards
        self.landlord.current_hand.extend(self.deck[-3:])
        self.landlord.current_hand.sort(key=functools.cmp_to_key(doudizhu_sort_card))
        self.landlord.initial_hand = cards2str(self.landlord.current_hand)
        return self.landlord.player_id

./doudizhu/game.py
# -*- coding: utf-8 -*-
''' Implement Doudizhu Game class
'''
import functools
from heapq import merge
import numpy as np

from doudizhu.utils import cards2str, doudizhu_sort_card, CARD_RANK_STR
from doudizhu import Player
from doudizhu import Round
from doudizhu import Judger

class DoudizhuGame:
    ''' Provide game APIs for env to run doudizhu and get corresponding state
    information.
    '''
    def __init__(self, allow_step_back=False):
        self.allow_step_back = allow_step_back
        self.np_random = np.random.RandomState()
        self.num_players = 3

    def init_game(self):
        ''' Initialize players and state.

        Returns:
            dict: first state in one game
            int: current player's id
        '''
        # initialize public variables
        self.winner_id = None
        self.history = []

        # initialize players
        self.players = [Player(num, self.np_random)
                        for num in range(self.num_players)]

        # initialize round to deal cards and determine landlord
        self.played_cards = [np.zeros((len(CARD_RANK_STR), ), dtype=np.int32)
                                for _ in range(self.num_players)]
        self.round = Round(self.np_random, self.played_cards)
        self.round.initiate(self.players)

        # initialize judger
        self.judger = Judger(self.players, self.np_random)

        # get state of first player
        player_id = self.round.current_player
        self.state = self.get_state(player_id)

        return self.state, player_id

    def step(self, action):
        ''' Perform one draw of the game

        Args:
            action (str): specific action of doudizhu. Eg: '33344'

        Returns:
            dict: next player's state
            int: next player's id
        '''
        if self.allow_step_back:
            # TODO: don't record game.round, game.players, game.judger if allow_step_back not set
            pass

        # perfrom action
        player = self.players[self.round.current_player]
        self.round.proceed_round(player, action)
        if (action != 'pass'):
            self.judger.calc_playable_cards(player)
        if self.judger.judge_game(self.players, self.round.current_player):
            self.winner_id = self.round.current_player
        next_id = (player.player_id+1) % len(self.players)
        self.round.current_player = next_id

        # get next state
        state = self.get_state(next_id)
        self.state = state

        return state, next_id

    def step_back(self):
        ''' Return to the previous state of the game

        Returns:
            (bool): True if the game steps back successfully
        '''
        if not self.round.trace:
            return False

        #winner_id will be always None no matter step_back from any case
        self.winner_id = None

        #reverse round
        player_id, cards = self.round.step_back(self.players)

        #reverse player
        if (cards != 'pass'):
            self.players[player_id].played_cards = self.round.find_last_played_cards_in_trace(player_id)
        self.players[player_id].play_back()

        #reverse judger.played_cards if needed
        if (cards != 'pass'):
            self.judger.restore_playable_cards(player_id)

        self.state = self.get_state(self.round.current_player)
        return True

    def get_state(self, player_id):
        ''' Return player's state

        Args:
            player_id (int): player id

        Returns:
            (dict): The state of the player
        '''
        player = self.players[player_id]
        others_hands = self._get_others_current_hand(player)
        num_cards_left = [len(self.players[i].current_hand) for i in range(self.num_players)]
        if self.is_over():
            actions = []
        else:
            actions = list(player.available_actions(self.round.greater_player, self.judger))
        state = player.get_state(self.round.public, others_hands, num_cards_left, actions)

        return state

    @staticmethod
    def get_num_actions():
        ''' Return the total number of abstract acitons

        Returns:
            int: the total number of abstract actions of doudizhu
        '''
        return 27472

    def get_player_id(self):
        ''' Return current player's id

        Returns:
            int: current player's id
        '''
        return self.round.current_player

    def get_num_players(self):
        ''' Return the number of players in doudizhu

        Returns:
            int: the number of players in doudizhu
        '''
        return self.num_players

    def is_over(self):
        ''' Judge whether a game is over

        Returns:
            Bool: True(over) / False(not over)
        '''
        if self.winner_id is None:
            return False
        return True

    def _get_others_current_hand(self, player):
        player_up = self.players[(player.player_id+1) % len(self.players)]
        player_down = self.players[(player.player_id-1) % len(self.players)]
        others_hand = merge(player_up.current_hand, player_down.current_hand, key=functools.cmp_to_key(doudizhu_sort_card))
        return cards2str(others_hand)

./doudizhu/judger.py
# -*- coding: utf-8 -*-
''' Implement Doudizhu Judger class
'''
import numpy as np
import collections
from itertools import combinations
from bisect import bisect_left

from doudizhu.utils import CARD_RANK_STR, CARD_RANK_STR_INDEX
from doudizhu.utils import cards2str, contains_cards

class DoudizhuJudger:
    ''' Determine what cards a player can play
    '''
    @staticmethod
    def chain_indexes(indexes_list):
        ''' Find chains for solos, pairs and trios by using indexes_list

        Args:
            indexes_list: the indexes of cards those have the same count, the count could be 1, 2, or 3.

        Returns:
            list of tuples: [(start_index1, length1), (start_index1, length1), ...]

        '''
        chains = []
        prev_index = -100
        count = 0
        start = None
        for i in indexes_list:
            if (i[0] >= 12): #no chains for '2BR'
                break
            if (i[0] == prev_index + 1):
                count += 1
            else:
                if (count > 1):
                    chains.append((start, count))
                count = 1
                start = i[0]
            prev_index = i[0]
        if (count > 1):
            chains.append((start, count))
        return chains

    @classmethod
    def solo_attachments(cls, hands, chain_start, chain_length, size):
        ''' Find solo attachments for trio_chain_solo_x and four_two_solo

        Args:
            hands:
            chain_start: the index of start card of the trio_chain or trio or four
            chain_length: the size of the sequence of the chain, 1 for trio_solo or four_two_solo
            size: count of solos for the attachments

        Returns:
            list of tuples: [attachment1, attachment2, ...]
                            Each attachment has two elemnts,
                            the first one contains indexes of attached cards smaller than the index of chain_start,
                            the first one contains indexes of attached cards larger than the index of chain_start
        '''
        attachments = set()
        candidates = []
        prev_card = None
        same_card_count = 0
        for card in hands:
            #dont count those cards in the chain
            if (CARD_RANK_STR_INDEX[card] >= chain_start and CARD_RANK_STR_INDEX[card] < chain_start + chain_length):
                continue
            if (card == prev_card):
                #attachments can not have bomb
                if (same_card_count == 3):
                    continue
                #attachments can not have 3 same cards consecutive with the trio (except 3 cards of '222')
                elif (same_card_count == 2 and (CARD_RANK_STR_INDEX[card] == chain_start - 1 or CARD_RANK_STR_INDEX[card] == chain_start + chain_length) and card != '2'):
                    continue
                else:
                    same_card_count += 1
            else:
                prev_card = card
                same_card_count = 1
            candidates.append(CARD_RANK_STR_INDEX[card])
        for attachment in combinations(candidates, size):
            if (attachment[-1] == 14 and attachment[-2] == 13):
                continue
            i = bisect_left(attachment, chain_start)
            attachments.add((attachment[:i], attachment[i:]))
        return list(attachments)

    @classmethod
    def pair_attachments(cls, cards_count, chain_start, chain_length, size):
        ''' Find pair attachments for trio_chain_pair_x and four_two_pair

        Args:
            cards_count:
            chain_start: the index of start card of the trio_chain or trio or four
            chain_length: the size of the sequence of the chain, 1 for trio_pair or four_two_pair
            size: count of pairs for the attachments

        Returns:
            list of tuples: [attachment1, attachment2, ...]
                            Each attachment has two elemnts,
                            the first one contains indexes of attached cards smaller than the index of chain_start,
                            the first one contains indexes of attached cards larger than the index of chain_start
        '''
        attachments = set()
        candidates = []
        for i, _ in enumerate(cards_count):
            if (i >= chain_start and i < chain_start + chain_length):
                continue
            if (cards_count[i] == 2 or cards_count[i] == 3):
                candidates.append(i)
            elif (cards_count[i] == 4):
                candidates.append(i)
        for attachment in combinations(candidates, size):
            if (attachment[-1] == 14 and attachment[-2] == 13):
                continue
            i = bisect_left(attachment, chain_start)
            attachments.add((attachment[:i], attachment[i:]))
        return list(attachments)

    @staticmethod
    def playable_cards_from_hand(current_hand):
        ''' Get playable cards from hand

        Returns:
            set: set of string of playable cards
        '''
        cards_dict = collections.defaultdict(int)
        for card in current_hand:
            cards_dict[card] += 1
        cards_count = np.array([cards_dict[k] for k in CARD_RANK_STR])
        playable_cards = set()

        non_zero_indexes = np.argwhere(cards_count > 0)
        more_than_1_indexes = np.argwhere(cards_count > 1)
        more_than_2_indexes = np.argwhere(cards_count > 2)
        more_than_3_indexes = np.argwhere(cards_count > 3)
        #solo
        for i in non_zero_indexes:
            playable_cards.add(CARD_RANK_STR[i[0]])
        #pair
        for i in more_than_1_indexes:
            playable_cards.add(CARD_RANK_STR[i[0]] * 2)
        #bomb, four_two_solo, four_two_pair
        for i in more_than_3_indexes:
            cards = CARD_RANK_STR[i[0]] * 4
            playable_cards.add(cards)
            for left, right in DoudizhuJudger.solo_attachments(current_hand, i[0], 1, 2):
                pre_attached = ''
                for j in left:
                    pre_attached += CARD_RANK_STR[j]
                post_attached = ''
                for j in right:
                    post_attached += CARD_RANK_STR[j]
                playable_cards.add(pre_attached + cards + post_attached)
            for left, right in DoudizhuJudger.pair_attachments(cards_count, i[0], 1, 2):
                pre_attached = ''
                for j in left:
                    pre_attached += CARD_RANK_STR[j] * 2
                post_attached = ''
                for j in right:
                    post_attached += CARD_RANK_STR[j] * 2
                playable_cards.add(pre_attached + cards + post_attached)

        #solo_chain_5 -- #solo_chain_12
        solo_chain_indexes = DoudizhuJudger.chain_indexes(non_zero_indexes)
        for (start_index, length) in solo_chain_indexes:
            s, l = start_index, length
            while(l >= 5):
                cards = ''
                curr_index = s - 1
                curr_length = 0
                while (curr_length < l and curr_length < 12):
                    curr_index += 1
                    curr_length += 1
                    cards += CARD_RANK_STR[curr_index]
                    if (curr_length >= 5):
                        playable_cards.add(cards)
                l -= 1
                s += 1

        #pair_chain_3 -- #pair_chain_10
        pair_chain_indexes = DoudizhuJudger.chain_indexes(more_than_1_indexes)
        for (start_index, length) in pair_chain_indexes:
            s, l = start_index, length
            while(l >= 3):
                cards = ''
                curr_index = s - 1
                curr_length = 0
                while (curr_length < l and curr_length < 10):
                    curr_index += 1
                    curr_length += 1
                    cards += CARD_RANK_STR[curr_index] * 2
                    if (curr_length >= 3):
                        playable_cards.add(cards)
                l -= 1
                s += 1

        #trio, trio_solo and trio_pair
        for i in more_than_2_indexes:
            playable_cards.add(CARD_RANK_STR[i[0]] * 3)
            for j in non_zero_indexes:
                if (j < i):
                    playable_cards.add(CARD_RANK_STR[j[0]] + CARD_RANK_STR[i[0]] * 3)
                elif (j > i):
                    playable_cards.add(CARD_RANK_STR[i[0]] * 3 + CARD_RANK_STR[j[0]])
            for j in more_than_1_indexes:
                if (j < i):
                    playable_cards.add(CARD_RANK_STR[j[0]] * 2 + CARD_RANK_STR[i[0]] * 3)
                elif (j > i):
                    playable_cards.add(CARD_RANK_STR[i[0]] * 3 + CARD_RANK_STR[j[0]] * 2)

        #trio_solo, trio_pair, #trio -- trio_chain_2 -- trio_chain_6; trio_solo_chain_2 -- trio_solo_chain_5; trio_pair_chain_2 -- trio_pair_chain_4
        trio_chain_indexes = DoudizhuJudger.chain_indexes(more_than_2_indexes)
        for (start_index, length) in trio_chain_indexes:
            s, l = start_index, length
            while(l >= 2):
                cards = ''
                curr_index = s - 1
                curr_length = 0
                while (curr_length < l and curr_length < 6):
                    curr_index += 1
                    curr_length += 1
                    cards += CARD_RANK_STR[curr_index] * 3

                    #trio_chain_2 to trio_chain_6
                    if (curr_length >= 2 and curr_length <= 6):
                        playable_cards.add(cards)

                    #trio_solo_chain_2 to trio_solo_chain_5
                    if (curr_length >= 2 and curr_length <= 5):
                        for left, right in DoudizhuJudger.solo_attachments(current_hand, s, curr_length, curr_length):
                            pre_attached = ''
                            for j in left:
                                pre_attached += CARD_RANK_STR[j]
                            post_attached = ''
                            for j in right:
                                post_attached += CARD_RANK_STR[j]
                            playable_cards.add(pre_attached + cards + post_attached)

                    #trio_pair_chain2 -- trio_pair_chain_4
                    if (curr_length >= 2 and curr_length <= 4):
                        for left, right in DoudizhuJudger.pair_attachments(cards_count, s, curr_length, curr_length):
                            pre_attached = ''
                            for j in left:
                                pre_attached += CARD_RANK_STR[j] * 2
                            post_attached = ''
                            for j in right:
                                post_attached += CARD_RANK_STR[j] * 2
                            playable_cards.add(pre_attached + cards + post_attached)
                l -= 1
                s += 1
        #rocket
        if (cards_count[13] and cards_count[14]):
            playable_cards.add(CARD_RANK_STR[13] + CARD_RANK_STR[14])
        return playable_cards

    def __init__(self, players, np_random):
        ''' Initilize the Judger class for Dou Dizhu
        '''
        self.playable_cards = [set() for _ in range(3)]
        self._recorded_removed_playable_cards = [[] for _ in range(3)]
        for player in players:
            player_id = player.player_id
            current_hand = cards2str(player.current_hand)
            self.playable_cards[player_id] = self.playable_cards_from_hand(current_hand)

    def calc_playable_cards(self, player):
        ''' Recalculate all legal cards the player can play according to his
        current hand.

        Args:
            player (DoudizhuPlayer object): object of DoudizhuPlayer
            init_flag (boolean): For the first time, set it True to accelerate
              the preocess.

        Returns:
            list: list of string of playable cards
        '''
        removed_playable_cards = []

        player_id = player.player_id
        current_hand = cards2str(player.current_hand)
        missed = None
        for single in player.singles:
            if single not in current_hand:
                missed = single
                break

        playable_cards = self.playable_cards[player_id].copy()

        if missed is not None:
            position = player.singles.find(missed)
            player.singles = player.singles[position+1:]
            for cards in playable_cards:
                if missed in cards or (not contains_cards(current_hand, cards)):
                    removed_playable_cards.append(cards)
                    self.playable_cards[player_id].remove(cards)
        else:
            for cards in playable_cards:
                if not contains_cards(current_hand, cards):
                    #del self.playable_cards[player_id][cards]
                    removed_playable_cards.append(cards)
                    self.playable_cards[player_id].remove(cards)
        self._recorded_removed_playable_cards[player_id].append(removed_playable_cards)
        return self.playable_cards[player_id]

    def restore_playable_cards(self, player_id):
        ''' restore playable_cards for judger for game.step_back().

        Args:
            player_id: The id of the player whose playable_cards need to be restored
        '''
        removed_playable_cards = self._recorded_removed_playable_cards[player_id].pop()
        self.playable_cards[player_id].update(removed_playable_cards)

    def get_playable_cards(self, player):
        ''' Provide all legal cards the player can play according to his
        current hand.

        Args:
            player (DoudizhuPlayer object): object of DoudizhuPlayer
            init_flag (boolean): For the first time, set it True to accelerate
              the preocess.

        Returns:
            list: list of string of playable cards
        '''
        return self.playable_cards[player.player_id]

    @staticmethod
    def judge_game(players, player_id):
        ''' Judge whether the game is over

        Args:
            players (list): list of DoudizhuPlayer objects
            player_id (int): integer of player's id

        Returns:
            (bool): True if the game is over
        '''
        player = players[player_id]
        if not player.current_hand:
            return True
        return False

    @staticmethod
    def judge_payoffs(landlord_id, winner_id):
        payoffs = np.array([0, 0, 0])
        if winner_id == landlord_id:
            payoffs[landlord_id] = 1
        else:
            for index, _ in enumerate(payoffs):
                if index != landlord_id:
                    payoffs[index] = 1
        return payoffs

./doudizhu/player.py
# -*- coding: utf-8 -*-
''' Implement Doudizhu Player class
'''
import functools

from doudizhu.utils import get_gt_cards
from doudizhu.utils import cards2str, doudizhu_sort_card

class DoudizhuPlayer:
    ''' Player can store cards in the player's hand and the role,
    determine the actions can be made according to the rules,
    and can perfrom corresponding action
    '''
    def __init__(self, player_id, np_random):
        ''' Give the player an id in one game

        Args:
            player_id (int): the player_id of a player

        Notes:
            1. role: A player's temporary role in one game(landlord or peasant)
            2. played_cards: The cards played in one round
            3. hand: Initial cards
            4. _current_hand: The rest of the cards after playing some of them
        '''
        self.np_random = np_random
        self.player_id = player_id
        self.initial_hand = None
        self._current_hand = []
        self.role = ''
        self.played_cards = None
        self.singles = '3456789TJQKA2BR'

        #record cards removed from self._current_hand for each play()
        # and restore cards back to self._current_hand when play_back()
        self._recorded_played_cards = []

    @property
    def current_hand(self):
        return self._current_hand

    def set_current_hand(self, value):
        self._current_hand = value

    def get_state(self, public, others_hands, num_cards_left, actions):
        state = {}
        state['seen_cards'] = public['seen_cards']
        state['landlord'] = public['landlord']
        state['trace'] = public['trace'].copy()
        state['played_cards'] = public['played_cards']
        state['self'] = self.player_id
        state['current_hand'] = cards2str(self._current_hand)
        state['others_hand'] = others_hands
        state['num_cards_left'] = num_cards_left
        state['actions'] = actions

        return state

    def available_actions(self, greater_player=None, judger=None):
        ''' Get the actions can be made based on the rules

        Args:
            greater_player (DoudizhuPlayer object): player who played
        current biggest cards.
            judger (DoudizhuJudger object): object of DoudizhuJudger

        Returns:
            list: list of string of actions. Eg: ['pass', '8', '9', 'T', 'J']
        '''
        actions = []
        if greater_player is None or greater_player.player_id == self.player_id:
            actions = judger.get_playable_cards(self)
        else:
            actions = get_gt_cards(self, greater_player)
        return actions

    def play(self, action, greater_player=None):
        ''' Perfrom action

        Args:
            action (string): specific action
            greater_player (DoudizhuPlayer object): The player who played current biggest cards.

        Returns:
            object of DoudizhuPlayer: If there is a new greater_player, return it, if not, return None
        '''
        trans = {'B': 'BJ', 'R': 'RJ'}
        if action == 'pass':
            self._recorded_played_cards.append([])
            return greater_player
        else:
            removed_cards = []
            self.played_cards = action
            for play_card in action:
                if play_card in trans:
                    play_card = trans[play_card]
                for _, remain_card in enumerate(self._current_hand):
                    if remain_card.rank != '':
                        remain_card = remain_card.rank
                    else:
                        remain_card = remain_card.suit
                    if play_card == remain_card:
                        removed_cards.append(self.current_hand[_])
                        self._current_hand.remove(self._current_hand[_])
                        break
            self._recorded_played_cards.append(removed_cards)
            return self

    def play_back(self):
        ''' Restore recorded cards back to self._current_hand
        '''
        removed_cards = self._recorded_played_cards.pop()
        self._current_hand.extend(removed_cards)
        self._current_hand.sort(key=functools.cmp_to_key(doudizhu_sort_card))

./doudizhu/round.py
# -*- coding: utf-8 -*-
''' Implement Doudizhu Round class
'''

import functools
import numpy as np

from doudizhu import Dealer
from doudizhu.utils import cards2str, doudizhu_sort_card
from doudizhu.utils import CARD_RANK_STR, CARD_RANK_STR_INDEX

class DoudizhuRound:
    ''' Round can call other Classes' functions to keep the game running
    '''
    def __init__(self, np_random, played_cards):
        self.np_random = np_random
        self.played_cards = played_cards
        self.trace = []

        self.greater_player = None
        self.dealer = Dealer(self.np_random)
        self.deck_str = cards2str(self.dealer.deck)

    def initiate(self, players):
        ''' Call dealer to deal cards and bid landlord.

        Args:
            players (list): list of DoudizhuPlayer objects
        '''
        landlord_id = self.dealer.determine_role(players)
        seen_cards = self.dealer.deck[-3:]
        seen_cards.sort(key=functools.cmp_to_key(doudizhu_sort_card))
        self.seen_cards = cards2str(seen_cards)
        self.landlord_id = landlord_id
        self.current_player = landlord_id
        self.public = {'deck': self.deck_str, 'seen_cards': self.seen_cards,
                       'landlord': self.landlord_id, 'trace': self.trace,
                       'played_cards': ['' for _ in range(len(players))]}

    @staticmethod
    def cards_ndarray_to_str(ndarray_cards):
        result = []
        for cards in ndarray_cards:
            _result = []
            for i, _ in enumerate(cards):
                if cards[i] != 0:
                    _result.extend([CARD_RANK_STR[i]] * cards[i])
            result.append(''.join(_result))
        return result

    def update_public(self, action):
        ''' Update public trace and played cards

        Args:
            action(str): string of legal specific action
        '''
        self.trace.append((self.current_player, action))
        if action != 'pass':
            for c in action:
                self.played_cards[self.current_player][CARD_RANK_STR_INDEX[c]] += 1
                if self.current_player == 0 and c in self.seen_cards:
                    self.seen_cards = self.seen_cards.replace(c, '')
                    self.public['seen_cards'] = self.seen_cards
            self.public['played_cards'] = self.cards_ndarray_to_str(self.played_cards)

    def proceed_round(self, player, action):
        ''' Call other Classes's functions to keep one round running

        Args:
            player (object): object of DoudizhuPlayer
            action (str): string of legal specific action

        Returns:
            object of DoudizhuPlayer: player who played current biggest cards.
        '''
        self.update_public(action)
        self.greater_player = player.play(action, self.greater_player)
        return self.greater_player

    def step_back(self, players):
        ''' Reverse the last action

        Args:
            players (list): list of DoudizhuPlayer objects
        Returns:
            The last player id and the cards played
        '''
        player_id, cards = self.trace.pop()
        self.current_player = player_id
        if (cards != 'pass'):
            for card in cards:
                # self.played_cards.remove(card)
                self.played_cards[player_id][CARD_RANK_STR_INDEX[card]] -= 1
            self.public['played_cards'] = self.cards_ndarray_to_str(self.played_cards)
        greater_player_id = self.find_last_greater_player_id_in_trace()
        if (greater_player_id is not None):
            self.greater_player = players[greater_player_id]
        else:
            self.greater_player = None
        return player_id, cards

    def find_last_greater_player_id_in_trace(self):
        ''' Find the last greater_player's id in trace

        Returns:
            The last greater_player's id in trace
        '''
        for i in range(len(self.trace) - 1, -1, -1):
            _id, action = self.trace[i]
            if (action != 'pass'):
                return _id
        return None

    def find_last_played_cards_in_trace(self, player_id):
        ''' Find the player_id's last played_cards in trace

        Returns:
            The player_id's last played_cards in trace
        '''
        for i in range(len(self.trace) - 1, -1, -1):
            _id, action = self.trace[i]
            if (_id == player_id and action != 'pass'):
                return action
        return None

./doudizhu/utils.py
''' Doudizhu utils
'''
import os
import json
from collections import OrderedDict
import threading
import collections

# import rlcard

# Read required docs
ROOT_PATH = '.'

# if not os.path.isfile(os.path.join(ROOT_PATH, '/jsondata/action_space.txt')) \
#         or not os.path.isfile(os.path.join(ROOT_PATH, '/jsondata/card_type.json')) \
#         or not os.path.isfile(os.path.join(ROOT_PATH, '/jsondata/type_card.json')):
#     import zipfile
#     with zipfile.ZipFile(os.path.join(ROOT_PATH, 'jsondata.zip'),"r") as zip_ref:
#         zip_ref.extractall(os.path.join(ROOT_PATH, '/'))

# Action space
action_space_path = os.path.join(ROOT_PATH, './jsondata/action_space.txt')

with open(action_space_path, 'r') as f:
    ID_2_ACTION = f.readline().strip().split()
    ACTION_2_ID = {}
    for i, action in enumerate(ID_2_ACTION):
        ACTION_2_ID[action] = i

# a map of card to its type. Also return both dict and list to accelerate
card_type_path = os.path.join(ROOT_PATH, './jsondata/card_type.json')
with open(card_type_path, 'r') as f:
    data = json.load(f, object_pairs_hook=OrderedDict)
    CARD_TYPE = (data, list(data), set(data))

# a map of type to its cards
type_card_path = os.path.join(ROOT_PATH, './jsondata/type_card.json')
with open(type_card_path, 'r') as f:
    TYPE_CARD = json.load(f, object_pairs_hook=OrderedDict)

# rank list of solo character of cards
CARD_RANK_STR = ['3', '4', '5', '6', '7', '8', '9', 'T', 'J', 'Q', 'K',
                 'A', '2', 'B', 'R']
CARD_RANK_STR_INDEX = {'3': 0, '4': 1, '5': 2, '6': 3, '7': 4,
            '8': 5, '9': 6, 'T': 7, 'J': 8, 'Q': 9,
            'K': 10, 'A': 11, '2': 12, 'B': 13, 'R': 14}
# rank list
CARD_RANK = ['3', '4', '5', '6', '7', '8', '9', 'T', 'J', 'Q', 'K',
             'A', '2', 'BJ', 'RJ']

INDEX = {'3': 0, '4': 1, '5': 2, '6': 3, '7': 4,
         '8': 5, '9': 6, 'T': 7, 'J': 8, 'Q': 9,
         'K': 10, 'A': 11, '2': 12, 'B': 13, 'R': 14}
INDEX = OrderedDict(sorted(INDEX.items(), key=lambda t: t[1]))

def doudizhu_sort_str(card_1, card_2):
    ''' Compare the rank of two cards of str representation

    Args:
        card_1 (str): str representation of solo card
        card_2 (str): str representation of solo card

    Returns:
        int: 1(card_1 > card_2) / 0(card_1 = card2) / -1(card_1 < card_2)
    '''
    key_1 = CARD_RANK_STR.index(card_1)
    key_2 = CARD_RANK_STR.index(card_2)
    if key_1 > key_2:
        return 1
    if key_1 < key_2:
        return -1
    return 0

def doudizhu_sort_card(card_1, card_2):
    ''' Compare the rank of two cards of Card object

    Args:
        card_1 (object): object of Card
        card_2 (object): object of card
    '''
    key = []
    for card in [card_1, card_2]:
        if card.rank == '':
            key.append(CARD_RANK.index(card.suit))
        else:
            key.append(CARD_RANK.index(card.rank))
    if key[0] > key[1]:
        return 1
    if key[0] < key[1]:
        return -1
    return 0

def get_landlord_score(current_hand):
    ''' Roughly judge the quality of the hand, and provide a score as basis to
    bid landlord.

    Args:
        current_hand (str): string of cards. Eg: '56888TTQKKKAA222R'

    Returns:
        int: score
    '''
    score_map = {'A': 1, '2': 2, 'B': 3, 'R': 4}
    score = 0
    # rocket
    if current_hand[-2:] == 'BR':
        score += 8
        current_hand = current_hand[:-2]
    length = len(current_hand)
    i = 0
    while i < length:
        # bomb
        if i <= (length - 4) and current_hand[i] == current_hand[i+3]:
            score += 6
            i += 4
            continue
        # 2, Black Joker, Red Joker
        if current_hand[i] in score_map:
            score += score_map[current_hand[i]]
        i += 1
    return score

def cards2str_with_suit(cards):
    ''' Get the corresponding string representation of cards with suit

    Args:
        cards (list): list of Card objects

    Returns:
        string: string representation of cards
    '''
    return ' '.join([card.suit+card.rank for card in cards])

def cards2str(cards):
    ''' Get the corresponding string representation of cards

    Args:
        cards (list): list of Card objects

    Returns:
        string: string representation of cards
    '''
    response = ''
    for card in cards:
        if card.rank == '':
            response += card.suit[0]
        else:
            response += card.rank
    return response

class LocalObjs(threading.local):
    def __init__(self):
        self.cached_candidate_cards = None
_local_objs = LocalObjs()

def contains_cards(candidate, target):
    ''' Check if cards of candidate contains cards of target.

    Args:
        candidate (string): A string representing the cards of candidate
        target (string): A string representing the number of cards of target

    Returns:
        boolean
    '''
    # In normal cases, most continuous calls of this function
    #   will test different targets against the same candidate.
    # So the cached counts of each card in candidate can speed up
    #   the comparison for following tests if candidate keeps the same.
    if not _local_objs.cached_candidate_cards or _local_objs.cached_candidate_cards != candidate:
        _local_objs.cached_candidate_cards = candidate
        cards_dict = collections.defaultdict(int)
        for card in candidate:
            cards_dict[card] += 1
        _local_objs.cached_candidate_cards_dict = cards_dict
    cards_dict = _local_objs.cached_candidate_cards_dict
    if (target == ''):
        return True
    curr_card = target[0]
    curr_count = 1
    for card in target[1:]:
        if (card != curr_card):
            if (cards_dict[curr_card] < curr_count):
                return False
            curr_card = card
            curr_count = 1
        else:
            curr_count += 1
    if (cards_dict[curr_card] < curr_count):
        return False
    return True

def encode_cards(plane, cards):
    ''' Encode cards and represerve it into plane.

    Args:
        cards (list or str): list or str of cards, every entry is a
    character of solo representation of card
    '''
    if not cards:
        return None
    layer = 1
    if len(cards) == 1:
        rank = CARD_RANK_STR.index(cards[0])
        plane[layer][rank] = 1
        plane[0][rank] = 0
    else:
        for index, card in enumerate(cards):
            if index == 0:
                continue
            if card == cards[index-1]:
                layer += 1
            else:
                rank = CARD_RANK_STR.index(cards[index-1])
                plane[layer][rank] = 1
                layer = 1
                plane[0][rank] = 0
        rank = CARD_RANK_STR.index(cards[-1])
        plane[layer][rank] = 1
        plane[0][rank] = 0

def get_gt_cards(player, greater_player):
    ''' Provide player's cards which are greater than the ones played by
    previous player in one round

    Args:
        player (DoudizhuPlayer object): the player waiting to play cards
        greater_player (DoudizhuPlayer object): the player who played current biggest cards.

    Returns:
        list: list of string of greater cards

    Note:
        1. return value contains 'pass'
    '''
    # add 'pass' to legal actions
    gt_cards = ['pass']
    current_hand = cards2str(player.current_hand)
    target_cards = greater_player.played_cards
    target_types = CARD_TYPE[0][target_cards]
    type_dict = {}
    for card_type, weight in target_types:
        if card_type not in type_dict:
            type_dict[card_type] = weight
    if 'rocket' in type_dict:
        return gt_cards
    type_dict['rocket'] = -1
    if 'bomb' not in type_dict:
        type_dict['bomb'] = -1
    for card_type, weight in type_dict.items():
        candidate = TYPE_CARD[card_type]
        for can_weight, cards_list in candidate.items():
            if int(can_weight) > int(weight):
                for cards in cards_list:
                    # TODO: improve efficiency
                    if cards not in gt_cards and contains_cards(current_hand, cards):
                        # if self.contains_cards(current_hand, cards):
                        gt_cards.append(cards)
    return gt_cards