diff --git a/project/game/ai/open_hand_v2.py b/project/game/ai/open_hand_v2.py new file mode 100644 index 00000000..5d830a36 --- /dev/null +++ b/project/game/ai/open_hand_v2.py @@ -0,0 +1,95 @@ +import utils.decisions_constants as log +from game.ai.strategies_v2.chinitsu import ChinitsuStrategy +from game.ai.strategies_v2.common_open_tempai import CommonOpenTempaiStrategy +from game.ai.strategies_v2.formal_tempai import FormalTempaiStrategy +from game.ai.strategies_v2.honitsu import HonitsuStrategy +from game.ai.strategies_v2.main import BaseStrategy +from game.ai.strategies_v2.tanyao import TanyaoStrategy +from game.ai.strategies_v2.yakuhai import YakuhaiStrategy +from mahjong.shanten import Shanten +from mahjong.tile import TilesConverter + + +class OpenHandHandlerV2: + player = None + current_strategy = None + last_discard_option = None + + def __init__(self, player): + self.player = player + + def determine_strategy(self, tiles_136, meld_tile=None): + # for already opened hand we don't need to give up on selected strategy + if self.player.is_open_hand and self.current_strategy: + return False + + old_strategy = self.current_strategy + self.current_strategy = None + + # order is important, we add strategies with the highest priority first + strategies = [] + + # first priority + if self.player.table.has_open_tanyao: + strategies.append(TanyaoStrategy(BaseStrategy.TANYAO, self.player)) + + # second priority + strategies.append(HonitsuStrategy(BaseStrategy.HONITSU, self.player)) + strategies.append(ChinitsuStrategy(BaseStrategy.CHINITSU, self.player)) + + # third priority + strategies.append(YakuhaiStrategy(BaseStrategy.YAKUHAI, self.player)) + + # fourth priority + strategies.append(FormalTempaiStrategy(BaseStrategy.FORMAL_TEMPAI, self.player)) + strategies.append(CommonOpenTempaiStrategy(BaseStrategy.COMMON_OPEN_TEMPAI, self.player)) + + for strategy in strategies: + if strategy.should_activate_strategy(tiles_136, meld_tile=meld_tile): + self.current_strategy = strategy + break + + if self.current_strategy and (not old_strategy or self.current_strategy.type != old_strategy.type): + self.player.logger.debug( + log.STRATEGY_ACTIVATE, + context=self.current_strategy, + ) + + if not self.current_strategy and old_strategy: + self.player.logger.debug(log.STRATEGY_DROP, context=old_strategy) + + return self.current_strategy and True or False + + def try_to_call_meld(self, tile_136, is_kamicha_discard): + tiles_136_previous = self.player.tiles[:] + closed_hand_136_previous = self.player.closed_hand[:] + tiles_136 = tiles_136_previous + [tile_136] + self.determine_strategy(tiles_136, meld_tile=tile_136) + + if not self.current_strategy: + self.player.logger.debug(log.MELD_DEBUG, "We don't have active strategy. Abort melding.") + return None, None + + closed_hand_34_previous = TilesConverter.to_34_array(closed_hand_136_previous) + previous_shanten, _ = self.player.ai.hand_builder.calculate_shanten_and_decide_hand_structure( + closed_hand_34_previous + ) + + if previous_shanten == Shanten.AGARI_STATE and not self.current_strategy.can_meld_into_agari(): + return None, None + + meld, discard_option = self.current_strategy.try_to_call_meld(tile_136, is_kamicha_discard, tiles_136) + if discard_option: + self.last_discard_option = discard_option + + self.player.logger.debug( + log.MELD_CALL, + "We decided to open hand", + context=[ + f"Hand: {self.player.format_hand_for_print(tile_136)}", + f"Meld: {meld.serialize()}", + f"Discard after meld: {discard_option.serialize()}", + ], + ) + + return meld, discard_option diff --git a/project/game/ai/strategies/tanyao.py b/project/game/ai/strategies/tanyao.py index 619131b5..26c18c35 100644 --- a/project/game/ai/strategies/tanyao.py +++ b/project/game/ai/strategies/tanyao.py @@ -166,7 +166,7 @@ def is_tile_suitable(self, tile): return tile not in self.not_suitable_tiles def validate_meld(self, chosen_meld_dict): - # if we have already opened our hand, let's go by default riles + # if we have already opened our hand, let's go by default rules if self.player.is_open_hand: return True diff --git a/project/game/ai/strategies_v2/__init__.py b/project/game/ai/strategies_v2/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/project/game/ai/strategies_v2/chinitsu.py b/project/game/ai/strategies_v2/chinitsu.py new file mode 100644 index 00000000..5d9a93b4 --- /dev/null +++ b/project/game/ai/strategies_v2/chinitsu.py @@ -0,0 +1,184 @@ +from game.ai.strategies_v2.honitsu import HonitsuStrategy +from game.ai.strategies_v2.main import BaseStrategy +from mahjong.tile import TilesConverter +from mahjong.utils import count_tiles_by_suits, is_honor, is_man, is_pin, is_sou, is_tile_strictly_isolated, plus_dora + + +class ChinitsuStrategy(BaseStrategy): + min_shanten = 4 + + chosen_suit = None + + dora_count_suitable = 0 + dora_count_not_suitable = 0 + + def get_open_hand_han(self): + return 5 + + def should_activate_strategy(self, tiles_136, meld_tile=None): + """ + We can go for chinitsu strategy if we have prevalence of one suit + """ + + result = super(ChinitsuStrategy, self).should_activate_strategy(tiles_136) + if not result: + return False + + # when making decisions about chinitsu, we should consider + # the state of our own hand, + tiles_34 = TilesConverter.to_34_array(self.player.tiles) + suits = count_tiles_by_suits(tiles_34) + + suits = [x for x in suits if x["name"] != "honor"] + suits = sorted(suits, key=lambda x: x["count"], reverse=True) + suit = suits[0] + + count_of_shuntsu_other_suits = 0 + count_of_koutsu_other_suits = 0 + + count_of_shuntsu_other_suits += HonitsuStrategy._count_of_shuntsu(tiles_34, suits[1]["function"]) + count_of_shuntsu_other_suits += HonitsuStrategy._count_of_shuntsu(tiles_34, suits[2]["function"]) + + count_of_koutsu_other_suits += HonitsuStrategy._count_of_koutsu(tiles_34, suits[1]["function"]) + count_of_koutsu_other_suits += HonitsuStrategy._count_of_koutsu(tiles_34, suits[2]["function"]) + + # we need to have at least 9 tiles of one suit to fo for chinitsu + if suit["count"] < 9: + return False + + # here we only check doras in different suits, we will deal + # with honors later + self._initialize_chinitsu_dora_count(tiles_136, suit) + + # 3 non-isolated doras in other suits is too much + # to even try + if self.dora_count_not_suitable >= 3: + return False + + if self.dora_count_not_suitable == 2: + # 2 doras in other suits, no doras in our suit + # let's not consider chinitsu + if self.dora_count_suitable == 0: + return False + + # we have 2 doras in other suits and we + # are 1 shanten, let's not rush chinitsu + if self.player.ai.shanten == 1: + return False + + # too late to get rid of doras in other suits + if self.player.round_step > 8: + return False + + # we are almost tempai, chinitsu is slower + if suit["count"] == 9 and self.player.ai.shanten == 1: + return False + + # only 10 tiles by 9th turn is too slow, considering alternative + if suit["count"] == 10 and self.player.ai.shanten == 1 and self.player.round_step > 8: + return False + + # only 11 tiles or less by 12th turn is too slow, considering alternative + if suit["count"] <= 11 and self.player.round_step > 11: + return False + + # if we have a pon of honors, let's not go for chinitsu + honor_pons = len([x for x in range(0, 34) if is_honor(x) and tiles_34[x] >= 3]) + if honor_pons >= 1: + return False + + # if we have a valued pair, let's not go for chinitsu + valued_pairs = len([x for x in self.player.valued_honors if tiles_34[x] == 2]) + if valued_pairs >= 1: + return False + + # if we have a pair of honor doras, let's not go for chinitsu + honor_doras_pairs = len( + [ + x + for x in range(0, 34) + if is_honor(x) and tiles_34[x] == 2 and plus_dora(x * 4, self.player.table.dora_indicators) + ] + ) + if honor_doras_pairs >= 1: + return False + + # if we have a honor pair, we will only throw them away if it's early in the game + # and if we have lots of tiles in our suit + honor_pairs = len([x for x in range(0, 34) if is_honor(x) and tiles_34[x] == 2]) + if honor_pairs >= 2: + return False + if honor_pairs == 1: + if suit["count"] < 11: + return False + if self.player.round_step > 8: + return False + + # if we have a complete set in other suits, we can only throw it away if it's early in the game + if count_of_shuntsu_other_suits + count_of_koutsu_other_suits >= 1: + # too late to throw away chi after 8 step + if self.player.round_step > 8: + return False + + # already 1 shanten, no need to throw away complete set + if self.player.round_step > 5 and self.player.ai.shanten == 1: + return False + + # dora is not isolated and we have a complete set, let's not go for chinitsu + if self.dora_count_not_suitable >= 1: + return False + + self.chosen_suit = suit["function"] + + return True + + def is_tile_suitable(self, tile): + """ + We can use only tiles of chosen suit and honor tiles + :param tile: 136 tiles format + :return: True + """ + tile //= 4 + return self.chosen_suit(tile) + + def _initialize_chinitsu_dora_count(self, tiles_136, suit): + tiles_34 = TilesConverter.to_34_array(tiles_136) + + dora_count_man = 0 + dora_count_man_not_isolated = 0 + dora_count_pin = 0 + dora_count_pin_not_isolated = 0 + dora_count_sou = 0 + dora_count_sou_not_isolated = 0 + + for tile_136 in tiles_136: + tile_34 = tile_136 // 4 + + dora_count = plus_dora( + tile_136, self.player.table.dora_indicators, add_aka_dora=self.player.table.has_aka_dora + ) + + if is_man(tile_34): + dora_count_man += dora_count + if not is_tile_strictly_isolated(tiles_34, tile_34): + dora_count_man_not_isolated += dora_count + + if is_pin(tile_34): + dora_count_pin += dora_count + if not is_tile_strictly_isolated(tiles_34, tile_34): + dora_count_pin_not_isolated += dora_count + + if is_sou(tile_34): + dora_count_sou += dora_count + if not is_tile_strictly_isolated(tiles_34, tile_34): + dora_count_sou_not_isolated += dora_count + + if suit["name"] == "pin": + self.dora_count_suitable = dora_count_pin + self.dora_count_not_suitable = dora_count_man_not_isolated + dora_count_sou_not_isolated + elif suit["name"] == "sou": + self.dora_count_suitable = dora_count_sou + self.dora_count_not_suitable = dora_count_man_not_isolated + dora_count_pin_not_isolated + elif suit["name"] == "man": + self.dora_count_suitable = dora_count_man + self.dora_count_not_suitable = dora_count_sou_not_isolated + dora_count_pin_not_isolated diff --git a/project/game/ai/strategies_v2/common_open_tempai.py b/project/game/ai/strategies_v2/common_open_tempai.py new file mode 100644 index 00000000..f1d78473 --- /dev/null +++ b/project/game/ai/strategies_v2/common_open_tempai.py @@ -0,0 +1,125 @@ +import utils.decisions_constants as log +from game.ai.strategies_v2.main import BaseStrategy +from mahjong.tile import TilesConverter +from utils.test_helpers import tiles_to_string + + +class CommonOpenTempaiStrategy(BaseStrategy): + min_shanten = 1 + + def should_activate_strategy(self, tiles_136, meld_tile=None): + """ + We activate this strategy only when we have a chance to meld for good tempai. + """ + result = super(CommonOpenTempaiStrategy, self).should_activate_strategy(tiles_136) + if not result: + return False + + # we only use this strategy for meld opportunities, if it's a self draw, just skip it + if meld_tile is None: + assert tiles_136 == self.player.tiles + return False + + # only go from 1-shanten to tempai with this strategy + if self.player.ai.shanten != 1: + return False + + tiles_copy = self.player.closed_hand[:] + [meld_tile] + tiles_34 = TilesConverter.to_34_array(tiles_copy) + # we only open for tempai with that strategy + new_shanten = self.player.ai.calculate_shanten_or_get_from_cache(tiles_34, use_chiitoitsu=False) + + # we always activate this strategy if we have a chance to get tempai + # then we will validate meld to see if it's really a good one + return self.player.ai.shanten == 1 and new_shanten == 0 + + def is_tile_suitable(self, tile): + """ + All tiles are suitable for formal tempai. + :param tile: 136 tiles format + :return: True + """ + return True + + def validate_meld(self, chosen_meld_dict): + # if we have already opened our hand, let's go by default riles + if self.player.is_open_hand: + return True + + # choose if base method requires us to keep hand closed + if not super(CommonOpenTempaiStrategy, self).validate_meld(chosen_meld_dict): + return False + + selected_tile = chosen_meld_dict["discard_tile"] + logger_context = { + "hand": tiles_to_string(self.player.closed_hand), + "meld": chosen_meld_dict, + "new_shanten": selected_tile.shanten, + "new_ukeire": selected_tile.ukeire, + } + + if selected_tile.shanten != 0: + self.player.logger.debug( + log.MELD_DEBUG, + "Common tempai: for whatever reason we didn't choose discard giving us tempai, so abort melding", + logger_context, + ) + return False + + if not selected_tile.tempai_descriptor: + self.player.logger.debug( + log.MELD_DEBUG, "Common tempai: no tempai descriptor found, so abort melding", logger_context + ) + return False + + if selected_tile.ukeire == 0: + self.player.logger.debug(log.MELD_DEBUG, "Common tempai: 0 ukeire, abort melding", logger_context) + return False + + if selected_tile.tempai_descriptor["hand_cost"]: + hand_cost = selected_tile.tempai_descriptor["hand_cost"] + else: + hand_cost = selected_tile.tempai_descriptor["cost_x_ukeire"] / selected_tile.ukeire + + if hand_cost == 0: + self.player.logger.debug(log.MELD_DEBUG, "Common tempai: hand costs nothing, abort melding", logger_context) + return False + + # maybe we need a special handling due to placement + # we have already checked that our meld is enough, now let's check that maybe we don't need to aim + # for higher costs + enough_cost = 32000 + if self.player.ai.placement.is_oorasu: + placement = self.player.ai.placement.get_current_placement() + if placement and placement["place"] == 4: + enough_cost = self.player.ai.placement.get_minimal_cost_needed_considering_west() + + if self.player.round_step <= 6: + if hand_cost >= min(7700, enough_cost): + self.player.logger.debug(log.MELD_DEBUG, "Common tempai: the cost is good, call meld", logger_context) + return True + elif self.player.round_step <= 12: + if self.player.is_dealer: + if hand_cost >= min(5800, enough_cost): + self.player.logger.debug( + log.MELD_DEBUG, + "Common tempai: the cost is ok for dealer and round step, call meld", + logger_context, + ) + return True + else: + if hand_cost >= min(3900, enough_cost): + self.player.logger.debug( + log.MELD_DEBUG, + "Common tempai: the cost is ok for non-dealer and round step, call meld", + logger_context, + ) + return True + else: + self.player.logger.debug( + log.MELD_DEBUG, "Common tempai: taking any tempai in the late round", logger_context + ) + return True + + self.player.logger.debug(log.MELD_DEBUG, "Common tempai: the cost is meh, so abort melding", logger_context) + return False diff --git a/project/game/ai/strategies_v2/formal_tempai.py b/project/game/ai/strategies_v2/formal_tempai.py new file mode 100644 index 00000000..33567d98 --- /dev/null +++ b/project/game/ai/strategies_v2/formal_tempai.py @@ -0,0 +1,72 @@ +from game.ai.strategies_v2.main import BaseStrategy + + +class FormalTempaiStrategy(BaseStrategy): + def should_activate_strategy(self, tiles_136, meld_tile=None): + """ + When we get closer to the end of the round, we start to consider + going for formal tempai. + """ + + result = super(FormalTempaiStrategy, self).should_activate_strategy(tiles_136) + if not result: + return False + + # if we already in tempai, we don't need this strategy + if self.player.in_tempai: + return False + + # it's too early to go for formal tempai before 11th turn + if self.player.round_step < 11: + return False + + # it's 11th turn or later and we still have 3 shanten or more, + # let's try to go for formal tempai at least + if self.player.ai.shanten >= 3: + return True + + if self.player.ai.shanten == 2: + if self.dora_count_total < 2: + # having 0 or 1 dora and 2 shanten, let's go for formal tempai + # starting from 11th turn + return True + # having 2 or more doras and 2 shanten, let's go for formal + # tempai starting from 12th turn + return self.player.round_step >= 12 + + # for 1 shanten we check number of doras and ukeire to determine + # correct time to go for formal tempai + if self.player.ai.shanten == 1: + if self.dora_count_total == 0: + if self.player.ai.ukeire <= 16: + return True + + if self.player.ai.ukeire <= 28: + return self.player.round_step >= 12 + + return self.player.round_step >= 13 + + if self.dora_count_total == 1: + if self.player.ai.ukeire <= 16: + return self.player.round_step >= 12 + + if self.player.ai.ukeire <= 28: + return self.player.round_step >= 13 + + return self.player.round_step >= 14 + + if self.player.ai.ukeire <= 16: + return self.player.round_step >= 13 + + return self.player.round_step >= 14 + + # we actually never reach here + return False + + def is_tile_suitable(self, tile): + """ + All tiles are suitable for formal tempai. + :param tile: 136 tiles format + :return: True + """ + return True diff --git a/project/game/ai/strategies_v2/honitsu.py b/project/game/ai/strategies_v2/honitsu.py new file mode 100644 index 00000000..1fe124c2 --- /dev/null +++ b/project/game/ai/strategies_v2/honitsu.py @@ -0,0 +1,329 @@ +from game.ai.strategies_v2.main import BaseStrategy +from mahjong.tile import TilesConverter +from mahjong.utils import ( + count_tiles_by_suits, + is_honor, + is_man, + is_pin, + is_sou, + is_tile_strictly_isolated, + plus_dora, + simplify, +) + + +class HonitsuStrategy(BaseStrategy): + min_shanten = 4 + + chosen_suit = None + + tiles_count_our_suit = 0 + dora_count_our_suit = 0 + dora_count_other_suits_not_isolated = 0 + tiles_count_other_suits = 0 + tiles_count_other_suits_not_isolated = 0 + + def get_open_hand_han(self): + return 2 + + def should_activate_strategy(self, tiles_136, meld_tile=None): + """ + We can go for honitsu strategy if we have prevalence of one suit and honor tiles + """ + + result = super(HonitsuStrategy, self).should_activate_strategy(tiles_136) + if not result: + return False + + tiles_34 = TilesConverter.to_34_array(tiles_136) + suits = count_tiles_by_suits(tiles_34) + + suits = [x for x in suits if x["name"] != "honor"] + suits = sorted(suits, key=lambda x: x["count"], reverse=True) + + suit = suits[0] + + count_of_shuntsu_other_suits = 0 + count_of_koutsu_other_suits = 0 + count_of_ryanmen_other_suits = 0 + + count_of_shuntsu_other_suits += self._count_of_shuntsu(tiles_34, suits[1]["function"]) + count_of_shuntsu_other_suits += self._count_of_shuntsu(tiles_34, suits[2]["function"]) + + count_of_koutsu_other_suits += self._count_of_koutsu(tiles_34, suits[1]["function"]) + count_of_koutsu_other_suits += self._count_of_koutsu(tiles_34, suits[2]["function"]) + + count_of_ryanmen_other_suits += self._find_ryanmen_waits(tiles_34, suits[1]["function"]) + count_of_ryanmen_other_suits += self._find_ryanmen_waits(tiles_34, suits[2]["function"]) + + self._calculate_suitable_and_not_suitable_tiles_cnt(tiles_34, suit["function"]) + self._initialize_honitsu_dora_count(tiles_136, suit) + + # let's not go for honitsu if we have 5 or more tiles in other suits + if self.tiles_count_other_suits >= 5: + return False + + # 7th turn and still 4 tiles in other suits - meh + if self.tiles_count_other_suits >= 4 and self.player.round_step > 6: + return False + + # 12th turn is too late and we still have too many tiles in other suits + if self.tiles_count_other_suits >= 3 and self.player.round_step > 11: + return False + + # let's not go for honitsu if we have 2 or more non-isolated doras + # in other suits + if self.dora_count_other_suits_not_isolated >= 2: + return False + + # if we have a pon of valued doras, let's not go for honitsu + # we have a mangan anyway, let's go for fastest hand + valued_pons = [x for x in self.player.valued_honors if tiles_34[x] >= 3] + for pon in valued_pons: + dora_count_valued_pons = plus_dora(pon * 4, self.player.table.dora_indicators) + if dora_count_valued_pons > 0: + return False + + valued_pairs = len([x for x in self.player.valued_honors if tiles_34[x] == 2]) + honor_pairs_or_pons = len([x for x in range(0, 34) if is_honor(x) and tiles_34[x] >= 2]) + honor_doras_pairs_or_pons = len( + [ + x + for x in range(0, 34) + if is_honor(x) and tiles_34[x] >= 2 and plus_dora(x * 4, self.player.table.dora_indicators) + ] + ) + unvalued_singles = len( + [x for x in range(0, 34) if is_honor(x) and x not in self.player.valued_honors and tiles_34[x] == 1] + ) + + # what's the point of honitsu if there is not a single honor pair + if honor_pairs_or_pons == 0: + return False + + # if we have twu ryanmens in other suits + if count_of_ryanmen_other_suits >= 2: + return False + + # let's not go for honitsu nomi + if not valued_pairs and not valued_pons: + # this is not honitsu, maybe it will be pinfu one day + if self.tiles_count_our_suit <= 7 and honor_pairs_or_pons < 2: + return False + + # also looks more like pinfu + if self.tiles_count_other_suits >= 4: + return False + + # so-so, let's just not go for honitsu nomi + if self.tiles_count_our_suit <= 9 and honor_pairs_or_pons == 1: + if not self.dora_count_our_suit and not honor_doras_pairs_or_pons: + return False + + # if we have some decent amount of not isolated tiles in other suits + # we may not rush for honitsu considering other conditions + if self.tiles_count_other_suits_not_isolated >= 3: + # if we don't have pair or pon of honored doras + if honor_doras_pairs_or_pons == 0: + # if we have a ryanmen with dora in other suit and no honor doras, so let's not rush honitsu + if count_of_ryanmen_other_suits >= 1 and self.dora_count_other_suits_not_isolated >= 1: + return False + + # we need to either have a valued pair or have at least two honor + # pairs to consider honitsu + if valued_pairs == 0 and honor_pairs_or_pons < 2: + return False + + # doesn't matter valued or not, if we have just one honor pair + # and have some single unvalued tiles, let's throw them away + # first + if honor_pairs_or_pons == 1 and unvalued_singles >= 2: + return False + + # 3 non-isolated unsuitable tiles, 1-shanen and already 8th turn + # let's not consider honitsu here + if self.player.ai.shanten == 1 and self.player.round_step > 8: + return False + else: + # we have a pon of unvalued honor doras, but it looks like + # it's faster to build our hand without honitsu + if self.player.ai.shanten == 1: + return False + + # if we have a complete set in other suits, we can only throw it away if it's early in the game + if count_of_shuntsu_other_suits + count_of_koutsu_other_suits >= 1: + # too late to throw away chi after 5 step + if self.player.round_step > 5: + return False + + # already 1 shanten, no need to throw away complete set + if self.player.ai.shanten == 1: + return False + + # dora is not isolated and we have a complete set, let's not go for honitsu + if self.dora_count_other_suits_not_isolated >= 1: + return False + + self.chosen_suit = suit["function"] + + return True + + def is_tile_suitable(self, tile): + """ + We can use only tiles of chosen suit and honor tiles + :param tile: 136 tiles format + :return: True + """ + tile //= 4 + return self.chosen_suit(tile) or is_honor(tile) + + def meld_had_to_be_called(self, tile): + has_not_suitable_tiles = False + + for hand_tile in self.player.tiles: + if not self.is_tile_suitable(hand_tile): + has_not_suitable_tiles = True + break + + # if we still have unsuitable tiles, let's call honor pons + # even if they don't change number of shanten + if has_not_suitable_tiles and is_honor(tile // 4): + return True + + return False + + def _calculate_suitable_and_not_suitable_tiles_cnt(self, tiles_34, suit): + self.tiles_count_other_suits = 0 + self.tiles_count_other_suits_not_isolated = 0 + + for x in range(0, 34): + tile_count = tiles_34[x] + if not tile_count: + continue + + if suit(x): + self.tiles_count_our_suit += tile_count + elif not is_honor(x): + self.tiles_count_other_suits += tile_count + if not is_tile_strictly_isolated(tiles_34, x): + self.tiles_count_other_suits_not_isolated += tile_count + + def _initialize_honitsu_dora_count(self, tiles_136, suit): + tiles_34 = TilesConverter.to_34_array(tiles_136) + + dora_count_man = 0 + dora_count_pin = 0 + dora_count_sou = 0 + + dora_count_man_not_isolated = 0 + dora_count_pin_not_isolated = 0 + dora_count_sou_not_isolated = 0 + + for tile_136 in tiles_136: + tile_34 = tile_136 // 4 + + dora_count = plus_dora( + tile_136, self.player.table.dora_indicators, add_aka_dora=self.player.table.has_aka_dora + ) + + if is_man(tile_34): + dora_count_man += dora_count + if not is_tile_strictly_isolated(tiles_34, tile_34): + dora_count_man_not_isolated += dora_count + + if is_pin(tile_34): + dora_count_pin += dora_count + if not is_tile_strictly_isolated(tiles_34, tile_34): + dora_count_pin_not_isolated += dora_count + + if is_sou(tile_34): + dora_count_sou += dora_count + if not is_tile_strictly_isolated(tiles_34, tile_34): + dora_count_sou_not_isolated += dora_count + + if suit["name"] == "pin": + self.dora_count_our_suit = dora_count_pin + self.dora_count_other_suits_not_isolated = dora_count_man_not_isolated + dora_count_sou_not_isolated + elif suit["name"] == "sou": + self.dora_count_our_suit = dora_count_sou + self.dora_count_other_suits_not_isolated = dora_count_man_not_isolated + dora_count_pin_not_isolated + elif suit["name"] == "man": + self.dora_count_our_suit = dora_count_man + self.dora_count_other_suits_not_isolated = dora_count_sou_not_isolated + dora_count_pin_not_isolated + + @staticmethod + def _find_ryanmen_waits(tiles, suit): + suit_tiles = [] + for x in range(0, 34): + tile = tiles[x] + if not tile: + continue + + if suit(x): + suit_tiles.append(x) + + count_of_ryanmen_waits = 0 + simple_tiles = [simplify(x) for x in suit_tiles] + for x in range(0, len(simple_tiles)): + tile = simple_tiles[x] + # we cant build ryanmen with 1 and 9 + if tile == 0 or tile == 8: + continue + + # bordered tile + if x + 1 == len(simple_tiles): + continue + + if tile + 1 == simple_tiles[x + 1]: + count_of_ryanmen_waits += 1 + + return count_of_ryanmen_waits + + # we know we have no more that 5 tiles of other suit, + # so this is a simplified version + # be aware, that it will return 2 for 2345 form so use with care + @staticmethod + def _count_of_shuntsu(tiles, suit): + suit_tiles = [] + for x in range(0, 34): + tile = tiles[x] + if not tile: + continue + + if suit(x): + suit_tiles.append(x) + + count_of_left_tiles = 0 + count_of_middle_tiles = 0 + count_of_right_tiles = 0 + + simple_tiles = [simplify(x) for x in suit_tiles] + for x in range(0, len(simple_tiles)): + tile = simple_tiles[x] + + if tile + 1 in simple_tiles and tile + 2 in simple_tiles: + count_of_left_tiles += 1 + + if tile - 1 in simple_tiles and tile + 1 in simple_tiles: + count_of_middle_tiles += 1 + + if tile - 2 in simple_tiles and tile - 1 in simple_tiles: + count_of_right_tiles += 1 + + return (count_of_left_tiles + count_of_middle_tiles + count_of_right_tiles) // 3 + + # we know we have no more that 5 tiles of other suit, + # so this is a simplified version + @staticmethod + def _count_of_koutsu(tiles, suit): + count_of_koutsu = 0 + + for x in range(0, 34): + tile = tiles[x] + if not tile: + continue + + if suit(x) and tile >= 3: + count_of_koutsu += 1 + + return count_of_koutsu diff --git a/project/game/ai/strategies_v2/main.py b/project/game/ai/strategies_v2/main.py new file mode 100644 index 00000000..7bf524bf --- /dev/null +++ b/project/game/ai/strategies_v2/main.py @@ -0,0 +1,519 @@ +import utils.decisions_constants as log +from mahjong.tile import TilesConverter +from mahjong.utils import is_chi, is_honor, is_man, is_pin, is_pon, is_sou, is_terminal, plus_dora, simplify +from utils.decisions_logger import MeldPrint + + +class BaseStrategy: + YAKUHAI = 0 + HONITSU = 1 + TANYAO = 2 + FORMAL_TEMPAI = 3 + CHINITSU = 4 + COMMON_OPEN_TEMPAI = 6 + + TYPES = { + YAKUHAI: "Yakuhai", + HONITSU: "Honitsu", + TANYAO: "Tanyao", + FORMAL_TEMPAI: "Formal Tempai", + CHINITSU: "Chinitsu", + COMMON_OPEN_TEMPAI: "Common Open Tempai", + } + + not_suitable_tiles = [] + player = None + type = None + # number of shanten where we can start to open hand + min_shanten = 7 + go_for_atodzuke = False + + dora_count_total = 0 + dora_count_central = 0 + dora_count_not_central = 0 + aka_dora_count = 0 + dora_count_honor = 0 + + def __init__(self, strategy_type, player): + self.type = strategy_type + self.player = player + self.go_for_atodzuke = False + + def __str__(self): + return self.TYPES[self.type] + + def get_open_hand_han(self): + return 0 + + def should_activate_strategy(self, tiles_136, meld_tile=None) -> bool: + """ + Based on player hand and table situation + we can determine should we use this strategy or not. + """ + self.calculate_dora_count(tiles_136) + return True + + def can_meld_into_agari(self) -> bool: + """ + Is melding into agari allowed with this strategy. + By default, the logic is the following: if we have any + non-suitable tiles, we can meld into agari state, + because we'll throw them away after meld. + Otherwise, there is no point. + """ + for tile in self.player.tiles: + if not self.is_tile_suitable(tile): + return True + return False + + def is_tile_suitable(self, tile): + """ + Can tile be used for open hand strategy or not + :param tile: in 136 tiles format + :return: boolean + """ + raise NotImplementedError() + + def determine_what_to_discard(self, discard_options, hand, open_melds): + first_option = sorted(discard_options, key=lambda x: x.shanten)[0] + shanten = first_option.shanten + + # for riichi we don't need to discard useful tiles + if shanten == 0 and not self.player.is_open_hand: + return discard_options + + # mark all not suitable tiles as ready to discard + # even if they not should be discarded by uke-ire + for x in discard_options: + if not self.is_tile_suitable(x.tile_to_discard_136): + x.had_to_be_discarded = True + + return discard_options + + def try_to_call_meld(self, tile, is_kamicha_discard, new_tiles): + """ + Determine should we call a meld or not. + If yes, it will return MeldPrint object and tile to discard + :param tile: 136 format tile + :param is_kamicha_discard: boolean + :param new_tiles: + :return: MeldPrint and DiscardOption objects + """ + if self.player.in_riichi: + return None, None + + closed_hand = self.player.closed_hand[:] + + # we can't open hand anymore + if len(closed_hand) == 1: + return None, None + + # we can't use this tile for our chosen strategy + if not self.is_tile_suitable(tile): + return None, None + + discarded_tile = tile // 4 + closed_hand_34 = TilesConverter.to_34_array(closed_hand + [tile]) + + combinations = [] + first_index = 0 + second_index = 0 + if is_man(discarded_tile): + first_index = 0 + second_index = 8 + elif is_pin(discarded_tile): + first_index = 9 + second_index = 17 + elif is_sou(discarded_tile): + first_index = 18 + second_index = 26 + + if second_index == 0: + # honor tiles + if closed_hand_34[discarded_tile] == 3: + combinations = [[[discarded_tile] * 3]] + else: + # to avoid not necessary calculations + # we can check only tiles around +-2 discarded tile + first_limit = discarded_tile - 2 + if first_limit < first_index: + first_limit = first_index + + second_limit = discarded_tile + 2 + if second_limit > second_index: + second_limit = second_index + + combinations = self.player.ai.hand_divider.find_valid_combinations( + closed_hand_34, first_limit, second_limit, True + ) + + if combinations: + combinations = combinations[0] + + possible_melds = [] + for best_meld_34 in combinations: + # we can call pon from everyone + if is_pon(best_meld_34) and discarded_tile in best_meld_34: + if best_meld_34 not in possible_melds: + possible_melds.append(best_meld_34) + + # we can call chi only from left player + if is_chi(best_meld_34) and is_kamicha_discard and discarded_tile in best_meld_34: + if best_meld_34 not in possible_melds: + possible_melds.append(best_meld_34) + + # we can call melds only with allowed tiles + validated_melds = [] + for meld in possible_melds: + if ( + self.is_tile_suitable(meld[0] * 4) + and self.is_tile_suitable(meld[1] * 4) + and self.is_tile_suitable(meld[2] * 4) + ): + validated_melds.append(meld) + possible_melds = validated_melds + + if not possible_melds: + return None, None + + chosen_meld_dict = self._find_best_meld_to_open(tile, possible_melds, new_tiles, closed_hand, tile) + # we didn't find a good discard candidate after open meld + if not chosen_meld_dict: + return None, None + + selected_tile = chosen_meld_dict["discard_tile"] + meld = chosen_meld_dict["meld"] + + shanten = selected_tile.shanten + had_to_be_called = self.meld_had_to_be_called(tile) + had_to_be_called = had_to_be_called or selected_tile.had_to_be_discarded + + # each strategy can use their own value to min shanten number + if shanten > self.min_shanten: + self.player.logger.debug( + log.MELD_DEBUG, + "After meld shanten is too high for our strategy. Abort melding.", + ) + return None, None + + # sometimes we had to call tile, even if it will not improve our hand + # otherwise we can call only with improvements of shanten + if not had_to_be_called and shanten >= self.player.ai.shanten: + self.player.logger.debug( + log.MELD_DEBUG, + "Meld is not improving hand shanten. Abort melding.", + ) + return None, None + + if not self.validate_meld(chosen_meld_dict): + self.player.logger.debug( + log.MELD_DEBUG, + "Meld is suitable for strategy logic. Abort melding.", + ) + return None, None + + if not self.should_push_against_threats(chosen_meld_dict): + self.player.logger.debug( + log.MELD_DEBUG, + "Meld is too dangerous to call. Abort melding.", + ) + return None, None + + return meld, selected_tile + + def should_push_against_threats(self, chosen_meld_dict) -> bool: + selected_tile = chosen_meld_dict["discard_tile"] + + if selected_tile.shanten <= 1: + return True + + threats = self.player.ai.defence.get_threatening_players() + if not threats: + return True + + # don't open garbage hand against threats + if selected_tile.shanten >= 3: + return False + + tile_136 = selected_tile.tile_to_discard_136 + if len(threats) == 1: + threat_hand_cost = threats[0].get_assumed_hand_cost(tile_136) + # expensive threat + # and our hand is not good + # let's not open this + if threat_hand_cost >= 7700: + return False + else: + min_threat_hand_cost = min([x.get_assumed_hand_cost(tile_136) for x in threats]) + # 2+ threats + # and they are not cheap + # so, let's skip opening of bad hand + if min_threat_hand_cost >= 5200: + return False + + return True + + def validate_meld(self, chosen_meld_dict): + """ + In some cased we want additionally check that meld is suitable to the strategy + """ + if self.player.is_open_hand: + return True + + if not self.player.ai.placement.is_oorasu: + return True + + # don't care about not enough cost if we are the dealer + if self.player.is_dealer: + return True + + placement = self.player.ai.placement.get_current_placement() + if not placement: + return True + + needed_cost = self.player.ai.placement.get_minimal_cost_needed_considering_west(placement=placement) + if needed_cost <= 1000: + return True + + selected_tile = chosen_meld_dict["discard_tile"] + if selected_tile.ukeire == 0: + self.player.logger.debug( + log.MELD_DEBUG, "We need to get out of 4th place, but this meld leaves us with zero ukeire" + ) + return False + + logger_context = { + "placement": placement, + "meld": chosen_meld_dict, + "needed_cost": needed_cost, + } + + if selected_tile.shanten == 0: + if not selected_tile.tempai_descriptor: + return True + + # tempai has special logger context + logger_context = { + "placement": placement, + "meld": chosen_meld_dict, + "needed_cost": needed_cost, + "tempai_descriptor": selected_tile.tempai_descriptor, + } + + if selected_tile.tempai_descriptor["hand_cost"]: + hand_cost = selected_tile.tempai_descriptor["hand_cost"] + else: + hand_cost = selected_tile.tempai_descriptor["cost_x_ukeire"] / selected_tile.ukeire + + # optimistic condition - direct ron + if hand_cost * 2 < needed_cost: + self.player.logger.debug( + log.MELD_DEBUG, "No chance to comeback from 4th with this meld, so keep hand closed", logger_context + ) + return False + elif selected_tile.shanten == 1: + if selected_tile.average_second_level_cost is None: + return True + + # optimistic condition - direct ron + if selected_tile.average_second_level_cost * 2 < needed_cost: + self.player.logger.debug( + log.MELD_DEBUG, "No chance to comeback from 4th with this meld, so keep hand closed", logger_context + ) + return False + else: + simple_han_scale = [0, 1000, 2000, 3900, 7700, 8000, 12000, 12000] + num_han = self.get_open_hand_han() + self.dora_count_total + if num_han < len(simple_han_scale): + hand_cost = simple_han_scale[num_han] + # optimistic condition - direct ron + if hand_cost * 2 < needed_cost: + self.player.logger.debug( + log.MELD_DEBUG, + "No chance to comeback from 4th with this meld, so keep hand closed", + logger_context, + ) + return False + + self.player.logger.debug(log.MELD_DEBUG, "This meld should allow us to comeback from 4th", logger_context) + return True + + def meld_had_to_be_called(self, tile): + """ + For special cases meld had to be called even if shanten number will not be increased + :param tile: in 136 tiles format + :return: boolean + """ + return False + + def calculate_dora_count(self, tiles_136): + self.dora_count_central = 0 + self.dora_count_not_central = 0 + self.aka_dora_count = 0 + + for tile_136 in tiles_136: + tile_34 = tile_136 // 4 + + dora_count = plus_dora( + tile_136, self.player.table.dora_indicators, add_aka_dora=self.player.table.has_aka_dora + ) + + if not dora_count: + continue + + if is_honor(tile_34): + self.dora_count_not_central += dora_count + self.dora_count_honor += dora_count + elif is_terminal(tile_34): + self.dora_count_not_central += dora_count + else: + self.dora_count_central += dora_count + + self.dora_count_central += self.aka_dora_count + self.dora_count_total = self.dora_count_central + self.dora_count_not_central + + def _find_best_meld_to_open(self, call_tile_136, possible_melds, new_tiles, closed_hand, discarded_tile): + all_tiles_are_suitable = True + for tile_136 in closed_hand: + all_tiles_are_suitable &= self.is_tile_suitable(tile_136) + + final_results = [] + for meld_34 in possible_melds: + # in order to fully emulate the possible hand with meld, we save original melds state, + # modify player's melds and then restore original melds state after everything is done + melds_original = self.player.melds[:] + tiles_original = self.player.tiles[:] + + tiles = self._find_meld_tiles(closed_hand, meld_34, discarded_tile) + meld = MeldPrint() + meld.type = is_chi(meld_34) and MeldPrint.CHI or MeldPrint.PON + meld.tiles = sorted(tiles) + + self.player.logger.debug( + log.MELD_HAND, f"Hand: {self._format_hand_for_print(closed_hand, discarded_tile, self.player.melds)}" + ) + + # update player hand state to emulate new situation and choose what to discard + self.player.tiles = new_tiles[:] + self.player.add_called_meld(meld) + + selected_tile = self.player.ai.hand_builder.choose_tile_to_discard(after_meld=True) + closed_hand_tiles_after_meld = self.player.closed_hand[:] + + # restore original tiles and melds state + self.player.tiles = tiles_original + self.player.melds = melds_original + + # we can't find a good discard candidate, so let's skip this + if not selected_tile: + self.player.logger.debug(log.MELD_DEBUG, "Can't find discard candidate after meld. Abort melding.") + continue + + if not all_tiles_are_suitable and self.is_tile_suitable(selected_tile.tile_to_discard_136): + self.player.logger.debug( + log.MELD_DEBUG, + "We have tiles in our hand that are not suitable to current strategy, " + "but we are going to discard tile that we need. Abort melding.", + ) + continue + + call_tile_34 = call_tile_136 // 4 + # we can't discard the same tile that we called + if selected_tile.tile_to_discard_34 == call_tile_34: + self.player.logger.debug( + log.MELD_DEBUG, "We can't discard same tile that we used for meld. Abort melding." + ) + continue + + # we can't discard tile from the other end of the same ryanmen that we called + if not is_honor(selected_tile.tile_to_discard_34) and meld.type == MeldPrint.CHI: + if is_sou(selected_tile.tile_to_discard_34) and is_sou(call_tile_34): + same_suit = True + elif is_man(selected_tile.tile_to_discard_34) and is_man(call_tile_34): + same_suit = True + elif is_pin(selected_tile.tile_to_discard_34) and is_pin(call_tile_34): + same_suit = True + else: + same_suit = False + + if same_suit: + simplified_meld_0 = simplify(meld.tiles[0] // 4) + simplified_meld_1 = simplify(meld.tiles[1] // 4) + simplified_call = simplify(call_tile_34) + simplified_discard = simplify(selected_tile.tile_to_discard_34) + kuikae = False + if simplified_discard == simplified_call - 3: + kuikae_set = [simplified_call - 1, simplified_call - 2] + if simplified_meld_0 in kuikae_set and simplified_meld_1 in kuikae_set: + kuikae = True + elif simplified_discard == simplified_call + 3: + kuikae_set = [simplified_call + 1, simplified_call + 2] + if simplified_meld_0 in kuikae_set and simplified_meld_1 in kuikae_set: + kuikae = True + + if kuikae: + tile_str = TilesConverter.to_one_line_string( + [selected_tile.tile_to_discard_136], print_aka_dora=self.player.table.has_aka_dora + ) + self.player.logger.debug( + log.MELD_DEBUG, + f"Kuikae discard {tile_str} candidate. Abort melding.", + ) + continue + + final_results.append( + { + "discard_tile": selected_tile, + "meld_print": TilesConverter.to_one_line_string([meld_34[0] * 4, meld_34[1] * 4, meld_34[2] * 4]), + "meld": meld, + "closed_hand_tiles_after_meld": closed_hand_tiles_after_meld, + } + ) + + if not final_results: + self.player.logger.debug(log.MELD_DEBUG, "There are no good discards after melding.") + return None + + final_results = sorted( + final_results, + key=lambda x: (x["discard_tile"].shanten, -x["discard_tile"].ukeire, x["discard_tile"].valuation), + ) + + self.player.logger.debug( + log.MELD_PREPARE, + "Tiles could be used for open meld", + context=final_results, + ) + return final_results[0] + + @staticmethod + def _find_meld_tiles(closed_hand, meld_34, discarded_tile): + discarded_tile_34 = discarded_tile // 4 + meld_34_copy = meld_34[:] + closed_hand_copy = closed_hand[:] + + meld_34_copy.remove(discarded_tile_34) + + first_tile = TilesConverter.find_34_tile_in_136_array(meld_34_copy[0], closed_hand_copy) + closed_hand_copy.remove(first_tile) + + second_tile = TilesConverter.find_34_tile_in_136_array(meld_34_copy[1], closed_hand_copy) + closed_hand_copy.remove(second_tile) + + tiles = [first_tile, second_tile, discarded_tile] + + return tiles + + def _format_hand_for_print(self, tiles, new_tile, melds): + tiles_string = TilesConverter.to_one_line_string(tiles, print_aka_dora=self.player.table.has_aka_dora) + tile_string = TilesConverter.to_one_line_string([new_tile], print_aka_dora=self.player.table.has_aka_dora) + hand_string = f"{tiles_string} + {tile_string}" + hand_string += " [{}]".format( + ", ".join( + [ + TilesConverter.to_one_line_string(x.tiles, print_aka_dora=self.player.table.has_aka_dora) + for x in melds + ] + ) + ) + return hand_string diff --git a/project/game/ai/strategies_v2/tanyao.py b/project/game/ai/strategies_v2/tanyao.py new file mode 100644 index 00000000..d4389a48 --- /dev/null +++ b/project/game/ai/strategies_v2/tanyao.py @@ -0,0 +1,235 @@ +import utils.decisions_constants as log +from game.ai.strategies_v2.main import BaseStrategy +from mahjong.constants import HONOR_INDICES, TERMINAL_INDICES +from mahjong.tile import TilesConverter +from mahjong.utils import is_honor, is_tile_strictly_isolated +from utils.test_helpers import tiles_to_string + + +class TanyaoStrategy(BaseStrategy): + min_shanten = 3 + not_suitable_tiles = TERMINAL_INDICES + HONOR_INDICES + + def get_open_hand_han(self): + return 1 + + def should_activate_strategy(self, tiles_136, meld_tile=None): + """ + Tanyao hand is a hand without terminal and honor tiles, to achieve this + we will use different approaches + :return: boolean + """ + + result = super(TanyaoStrategy, self).should_activate_strategy(tiles_136) + if not result: + return False + + tiles = TilesConverter.to_34_array(self.player.tiles) + + closed_hand_34 = TilesConverter.to_34_array(self.player.closed_hand) + isolated_tiles = [ + x // 4 for x in self.player.tiles if is_tile_strictly_isolated(closed_hand_34, x // 4) or is_honor(x // 4) + ] + + count_of_terminal_pon_sets = 0 + count_of_terminal_pairs = 0 + count_of_valued_pairs = 0 + count_of_not_suitable_tiles = 0 + count_of_not_suitable_not_isolated_tiles = 0 + for x in range(0, 34): + tile = tiles[x] + if not tile: + continue + + if x in self.not_suitable_tiles and tile == 3: + count_of_terminal_pon_sets += 1 + + if x in self.not_suitable_tiles and tile == 2: + count_of_terminal_pairs += 1 + + if x in self.player.valued_honors: + count_of_valued_pairs += 1 + + if x in self.not_suitable_tiles: + count_of_not_suitable_tiles += tile + + if x in self.not_suitable_tiles and x not in isolated_tiles: + count_of_not_suitable_not_isolated_tiles += tile + + # we have too much terminals and honors + if count_of_not_suitable_tiles >= 5: + return False + + # if we already have pon of honor\terminal tiles + # we don't need to open hand for tanyao + if count_of_terminal_pon_sets > 0: + return False + + # with valued pair (yakuhai wind or dragon) + # we don't need to go for tanyao + if count_of_valued_pairs > 0: + return False + + # one pair is ok in tanyao pair + # but 2+ pairs can't be suitable + if count_of_terminal_pairs > 1: + return False + + # 3 or more not suitable tiles that + # are not isolated is too much + if count_of_not_suitable_not_isolated_tiles >= 3: + return False + + # if we are 1 shanten, even 2 tiles + # that are not suitable and not isolated + # is too much + if count_of_not_suitable_not_isolated_tiles >= 2 and self.player.ai.shanten == 1: + return False + + # TODO: don't open from good 1-shanten into tanyao 1-shaten with same ukeire or worse + + # 123 and 789 indices + indices = [[0, 1, 2], [6, 7, 8], [9, 10, 11], [15, 16, 17], [18, 19, 20], [24, 25, 26]] + + for index_set in indices: + first = tiles[index_set[0]] + second = tiles[index_set[1]] + third = tiles[index_set[2]] + if first >= 1 and second >= 1 and third >= 1: + return False + + # if we have 2 or more non-central doras + # we don't want to go for tanyao + if self.dora_count_not_central >= 2: + return False + + # if we have less than two central doras + # let's not consider open tanyao + if self.dora_count_central < 2: + return False + + # if we have only two central doras let's + # wait for 5th turn before opening our hand + if self.dora_count_central == 2 and self.player.round_step < 5: + return False + + return True + + def determine_what_to_discard(self, discard_options, hand, open_melds): + is_open_hand = self.player.is_open_hand + + # our hand is closed, we don't need to discard terminal tiles here + if not is_open_hand: + return discard_options + + first_option = sorted(discard_options, key=lambda x: x.shanten)[0] + shanten = first_option.shanten + + if shanten > 1: + return super(TanyaoStrategy, self).determine_what_to_discard(discard_options, hand, open_melds) + + results = [] + not_suitable_tiles = [] + for item in discard_options: + if not self.is_tile_suitable(item.tile_to_discard_136): + item.had_to_be_discarded = True + not_suitable_tiles.append(item) + continue + + # there is no sense to wait 1-4 if we have open hand + # but let's only avoid atodzuke tiles in tempai, the rest will be dealt with in + # generic logic + if item.shanten == 0: + all_waiting_are_fine = all( + [(self.is_tile_suitable(x * 4) or item.wait_to_ukeire[x] == 0) for x in item.waiting] + ) + if all_waiting_are_fine: + results.append(item) + + if not_suitable_tiles: + return not_suitable_tiles + + # we don't have a choice + # we had to have on bad wait + if not results: + return discard_options + + return results + + def is_tile_suitable(self, tile): + """ + We can use only simples tiles (2-8) in any suit + :param tile: 136 tiles format + :return: True + """ + tile //= 4 + return tile not in self.not_suitable_tiles + + def validate_meld(self, chosen_meld_dict): + # if we have already opened our hand, let's go by default riles + if self.player.is_open_hand: + return True + + # choose if base method requires us to keep hand closed + if not super(TanyaoStrategy, self).validate_meld(chosen_meld_dict): + return False + + # otherwise let's not open hand if that does not improve our ukeire + closed_tiles_34 = TilesConverter.to_34_array(self.player.closed_hand) + waiting, shanten = self.player.ai.hand_builder.calculate_waits( + closed_tiles_34, closed_tiles_34, use_chiitoitsu=False + ) + wait_to_ukeire = dict( + zip(waiting, [self.player.ai.hand_builder.count_tiles([x], closed_tiles_34) for x in waiting]) + ) + old_ukeire = sum(wait_to_ukeire.values()) + selected_tile = chosen_meld_dict["discard_tile"] + + logger_context = { + "hand": tiles_to_string(self.player.closed_hand), + "meld": chosen_meld_dict, + "old_shanten": shanten, + "old_ukeire": old_ukeire, + "new_shanten": selected_tile.shanten, + "new_ukeire": selected_tile.ukeire, + } + + if selected_tile.shanten > shanten: + self.player.logger.debug( + log.MELD_DEBUG, "Opening into tanyao increases number of shanten, let's not do that", logger_context + ) + return False + + if selected_tile.shanten == shanten: + if old_ukeire >= selected_tile.ukeire: + self.player.logger.debug( + log.MELD_DEBUG, + "Opening into tanyao keeps same number of shanten and does not improve ukeire, let's not do that", + logger_context, + ) + return False + + if old_ukeire != 0: + improvement_percent = ((selected_tile.ukeire - old_ukeire) / old_ukeire) * 100 + else: + improvement_percent = selected_tile.ukeire * 100 + + if improvement_percent < 30: + self.player.logger.debug( + log.MELD_DEBUG, + "Opening into tanyao keeps same number of shanten and ukeire improvement is low, don't open", + logger_context, + ) + return False + + self.player.logger.debug( + log.MELD_DEBUG, + "Opening into tanyao keeps same number of shanten and ukeire improvement is good, let's call meld", + logger_context, + ) + return True + + self.player.logger.debug( + log.MELD_DEBUG, "Opening into tanyao improves number of shanten, let's call meld", logger_context + ) + return True diff --git a/project/game/ai/strategies_v2/tests/__init__.py b/project/game/ai/strategies_v2/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/project/game/ai/strategies_v2/tests/test_chiitoitsu.py b/project/game/ai/strategies_v2/tests/test_chiitoitsu.py new file mode 100644 index 00000000..d17df66d --- /dev/null +++ b/project/game/ai/strategies_v2/tests/test_chiitoitsu.py @@ -0,0 +1,91 @@ +from game.ai.open_hand_v2 import OpenHandHandlerV2 +from game.ai.strategies_v2.main import BaseStrategy +from game.table import Table +from utils.test_helpers import string_to_136_array, string_to_136_tile, tiles_to_string + + +def make_open_hand_v2_table() -> Table: + table = Table() + table.player.ai.open_hand_handler = OpenHandHandlerV2(table.player) + return table + + +def test_should_activate_strategy(): + table = make_open_hand_v2_table() + player = table.player + + # obvious chiitoitsu, let's activate + tiles = string_to_136_array(sou="2266", man="3399", pin="289", honors="11") + player.init_hand(tiles) + player.draw_tile(string_to_136_tile(honors="6")) + + # less than 5 pairs, don't activate + tiles = string_to_136_array(sou="2266", man="3389", pin="289", honors="11") + player.draw_tile(string_to_136_tile(honors="6")) + player.init_hand(tiles) + + # 5 pairs, but we are already tempai, let's no consider this hand as chiitoitsu + tiles = string_to_136_array(sou="234", man="223344", pin="5669") + player.init_hand(tiles) + player.draw_tile(string_to_136_tile(pin="5")) + player.discard_tile() + + tiles = string_to_136_array(sou="234", man="22334455669") + player.init_hand(tiles) + + +def test_dont_call_meld(): + table = make_open_hand_v2_table() + player = table.player + + tiles = string_to_136_array(sou="112234", man="2334499") + player.init_hand(tiles) + + tile = string_to_136_tile(man="9") + meld, _ = player.try_to_call_meld(tile, True) + assert meld is None + + +def test_keep_chiitoitsu_tempai(): + table = make_open_hand_v2_table() + player = table.player + + tiles = string_to_136_array(sou="113355", man="22669", pin="99") + player.init_hand(tiles) + + player.draw_tile(string_to_136_tile(man="6")) + + discard, _ = player.discard_tile() + assert tiles_to_string([discard]) == "6m" + + +def test_5_pairs_yakuhai_not_chiitoitsu(): + table = make_open_hand_v2_table() + player = table.player + + table.add_dora_indicator(string_to_136_tile(sou="9")) + table.add_dora_indicator(string_to_136_tile(sou="1")) + + tiles = string_to_136_array(sou="112233", pin="16678", honors="66") + player.init_hand(tiles) + + tile = string_to_136_tile(honors="6") + meld, _ = player.try_to_call_meld(tile, True) + + assert player.ai.open_hand_handler.current_strategy.type == BaseStrategy.YAKUHAI + + assert meld is not None + + +def chiitoitsu_tanyao_tempai(): + table = make_open_hand_v2_table() + player = table.player + + tiles = string_to_136_array(sou="223344", pin="788", man="4577") + player.init_hand(tiles) + + player.draw_tile(string_to_136_tile(man="4")) + + discard = player.discard_tile() + discard_correct = tiles_to_string([discard]) == "7p" or tiles_to_string([discard]) == "5m" + assert discard_correct is True diff --git a/project/game/ai/strategies_v2/tests/test_chinitsu.py b/project/game/ai/strategies_v2/tests/test_chinitsu.py new file mode 100644 index 00000000..1a130ef9 --- /dev/null +++ b/project/game/ai/strategies_v2/tests/test_chinitsu.py @@ -0,0 +1,176 @@ +from game.ai.strategies_v2.chinitsu import ChinitsuStrategy +from game.ai.strategies_v2.main import BaseStrategy +from game.ai.strategies_v2.tests.test_chiitoitsu import make_open_hand_v2_table +from utils.decisions_logger import MeldPrint +from utils.test_helpers import make_meld, string_to_136_array, string_to_136_tile, tiles_to_string + + +def test_should_activate_strategy(): + table = make_open_hand_v2_table() + player = table.player + strategy = ChinitsuStrategy(BaseStrategy.CHINITSU, player) + + table.add_dora_indicator(string_to_136_tile(pin="1")) + table.add_dora_indicator(string_to_136_tile(man="1")) + table.add_dora_indicator(string_to_136_tile(sou="8")) + + tiles = string_to_136_array(sou="12355", man="34589", honors="1234") + player.init_hand(tiles) + assert strategy.should_activate_strategy(player.tiles) is False + + tiles = string_to_136_array(sou="12355", man="458", honors="112345") + player.init_hand(tiles) + assert strategy.should_activate_strategy(player.tiles) is False + + # we shouldn't go for chinitsu if we have a valued pair or pon + tiles = string_to_136_array(sou="111222578", man="8", honors="5556") + player.init_hand(tiles) + assert strategy.should_activate_strategy(player.tiles) is False + + tiles = string_to_136_array(sou="1112227788", man="7", honors="556") + player.init_hand(tiles) + assert strategy.should_activate_strategy(player.tiles) is False + + # if we have a pon of non-valued honors, this is not chinitsu + tiles = string_to_136_array(sou="1112224688", honors="2224") + player.init_hand(tiles) + assert strategy.should_activate_strategy(player.tiles) is False + + # if we have just a pair of non-valued tiles, we can go for chinitsu + # if we have 11 chinitsu tiles and it's early + tiles = string_to_136_array(sou="11122234688", honors="224") + player.init_hand(tiles) + assert strategy.should_activate_strategy(player.tiles) is True + + # if we have a complete set with dora, we shouldn't go for chinitsu + tiles = string_to_136_array(sou="1112223688", pin="1239") + player.init_hand(tiles) + assert strategy.should_activate_strategy(player.tiles) is False + + # even if the set may be interpreted as two forms + tiles = string_to_136_array(sou="111223688", pin="23349") + player.init_hand(tiles) + assert strategy.should_activate_strategy(player.tiles) is False + + # even if the set may be interpreted as two forms v2 + tiles = string_to_136_array(sou="111223688", pin="23459") + player.init_hand(tiles) + assert strategy.should_activate_strategy(player.tiles) is False + + # if we have a long form with dora, we shouldn't go for chinitsu + tiles = string_to_136_array(sou="111223688", pin="23339") + player.init_hand(tiles) + assert strategy.should_activate_strategy(player.tiles) is False + + # buf it it's just a ryanmen - no problem + tiles = string_to_136_array(sou="1112223688", pin="238", man="9") + player.init_hand(tiles) + assert strategy.should_activate_strategy(player.tiles) is True + + # we have three non-isolated doras in other suits, this is not chinitsu + tiles = string_to_136_array(sou="111223688", man="22", pin="239") + player.init_hand(tiles) + assert strategy.should_activate_strategy(player.tiles) is False + + # we have two non-isolated doras in other suits and no doras in our suit + # this is not chinitsu + tiles = string_to_136_array(sou="111223688", man="24", pin="249") + player.init_hand(tiles) + assert strategy.should_activate_strategy(player.tiles) is False + + # we have two non-isolated doras in other suits and 1 shanten, not chinitsu + tiles = string_to_136_array(sou="111222789", man="23", pin="239") + player.init_hand(tiles) + assert strategy.should_activate_strategy(player.tiles) is False + + # we don't want to open on 9th tile into chinitsu, but it's ok to + # switch to chinitsu if we get in from the wall + tiles = string_to_136_array(sou="11223578", man="57", pin="4669") + player.init_hand(tiles) + # plus one tile to open hand + tiles = string_to_136_array(sou="112223578", man="57", pin="466") + assert strategy.should_activate_strategy(tiles) is False + # but now let's init hand with these tiles, we can now slowly move to chinitsu + tiles = string_to_136_array(sou="112223578", man="57", pin="466") + player.init_hand(tiles) + assert strategy.should_activate_strategy(tiles) is True + + +def test_suitable_tiles(): + table = make_open_hand_v2_table() + player = table.player + strategy = ChinitsuStrategy(BaseStrategy.CHINITSU, player) + + tiles = string_to_136_array(sou="111222479", man="78", honors="12") + player.init_hand(tiles) + assert strategy.should_activate_strategy(player.tiles) is True + + tile = string_to_136_tile(man="1") + assert strategy.is_tile_suitable(tile) is False + + tile = string_to_136_tile(pin="1") + assert strategy.is_tile_suitable(tile) is False + + tile = string_to_136_tile(sou="1") + assert strategy.is_tile_suitable(tile) is True + + tile = string_to_136_tile(honors="1") + assert strategy.is_tile_suitable(tile) is False + + +def test_open_suit_same_shanten(): + table = make_open_hand_v2_table() + player = table.player + player.scores = 25000 + table.count_of_remaining_tiles = 100 + + tiles = string_to_136_array(man="1134556999", pin="3", sou="78") + player.init_hand(tiles) + + meld = make_meld(MeldPrint.CHI, man="345") + player.add_called_meld(meld) + + strategy = ChinitsuStrategy(BaseStrategy.CHINITSU, player) + assert strategy.should_activate_strategy(player.tiles) is True + + tile = string_to_136_tile(man="1") + meld, _ = player.try_to_call_meld(tile, True) + assert meld is not None + assert tiles_to_string(meld.tiles) == "111m" + + +def test_correct_discard_agari_no_yaku(): + table = make_open_hand_v2_table() + player = table.player + + tiles = string_to_136_array(man="111234677889", sou="1", pin="") + player.init_hand(tiles) + + meld = make_meld(MeldPrint.CHI, man="789") + player.add_called_meld(meld) + + tile = string_to_136_tile(sou="1") + player.draw_tile(tile) + discard, _ = player.discard_tile() + assert tiles_to_string([discard]) == "1s" + + +def test_open_suit_agari_no_yaku(): + table = make_open_hand_v2_table() + player = table.player + player.scores = 25000 + table.count_of_remaining_tiles = 100 + + tiles = string_to_136_array(man="11123455589", pin="22") + player.init_hand(tiles) + + meld = make_meld(MeldPrint.CHI, man="234") + player.add_called_meld(meld) + + strategy = ChinitsuStrategy(BaseStrategy.CHINITSU, player) + assert strategy.should_activate_strategy(player.tiles) is True + + tile = string_to_136_tile(man="7") + meld, _ = player.try_to_call_meld(tile, True) + assert meld is not None + assert tiles_to_string(meld.tiles) == "789m" diff --git a/project/game/ai/strategies_v2/tests/test_common_open_tempai.py b/project/game/ai/strategies_v2/tests/test_common_open_tempai.py new file mode 100644 index 00000000..5391fa78 --- /dev/null +++ b/project/game/ai/strategies_v2/tests/test_common_open_tempai.py @@ -0,0 +1,84 @@ +from game.ai.strategies_v2.tests.test_chiitoitsu import make_open_hand_v2_table +from utils.test_helpers import string_to_136_array, string_to_136_tile, tiles_to_string + + +def test_get_common_tempai_sanshoku(): + table = make_open_hand_v2_table() + + table.add_dora_indicator(string_to_136_tile(man="8")) + + tiles = string_to_136_array(man="13999", sou="123", pin="12899") + table.player.init_hand(tiles) + + tile = string_to_136_tile(pin="3") + meld, _ = table.player.try_to_call_meld(tile, True) + assert meld is not None + assert tiles_to_string(meld.tiles) == "123p" + + +def test_get_common_tempai_honro(): + table = make_open_hand_v2_table() + + tiles = string_to_136_array(man="11999", sou="112", pin="99", honors="333") + table.player.init_hand(tiles) + + tile = string_to_136_tile(pin="9") + meld, _ = table.player.try_to_call_meld(tile, False) + assert meld is not None + assert tiles_to_string(meld.tiles) == "999p" + + +def test_get_common_tempai_and_0_ukeire_crash(): + """ + Checks that we don't have crash anymore when bot tried to open hand with 0 ukeire + :return: + """ + table = make_open_hand_v2_table() + table.add_discarded_tile(1, string_to_136_tile(sou="1"), True) + table.add_discarded_tile(1, string_to_136_tile(sou="1"), True) + table.add_discarded_tile(1, string_to_136_tile(man="1"), True) + table.add_discarded_tile(1, string_to_136_tile(man="1"), True) + + tiles = string_to_136_array(man="11999", sou="116", pin="99", honors="333") + table.player.init_hand(tiles) + + tile = string_to_136_tile(pin="9") + meld, _ = table.player.try_to_call_meld(tile, False) + # no ukeire, no reason to open hand + assert meld is None + + +def test_get_common_tempai_sandoko(): + table = make_open_hand_v2_table() + + table.add_dora_indicator(string_to_136_tile(man="1")) + + tiles = string_to_136_array(man="222", sou="2278", pin="222899") + table.player.init_hand(tiles) + + tile = string_to_136_tile(sou="2") + meld, _ = table.player.try_to_call_meld(tile, False) + assert meld is not None + assert tiles_to_string(meld.tiles) == "222s" + + +def test_get_common_tempai_bad_atodzuke(): + table = make_open_hand_v2_table() + + tiles = string_to_136_array(man="23789", sou="3", pin="99", honors="33444") + table.player.init_hand(tiles) + + tile = string_to_136_tile(pin="9") + meld, _ = table.player.try_to_call_meld(tile, False) + assert meld is None + + +def test_get_common_tempai_no_yaku(): + table = make_open_hand_v2_table() + + tiles = string_to_136_array(man="234999", sou="112", pin="55", honors="333") + table.player.init_hand(tiles) + + tile = string_to_136_tile(pin="9") + meld, _ = table.player.try_to_call_meld(tile, False) + assert meld is None diff --git a/project/game/ai/strategies_v2/tests/test_formal_tempai.py b/project/game/ai/strategies_v2/tests/test_formal_tempai.py new file mode 100644 index 00000000..f5d92397 --- /dev/null +++ b/project/game/ai/strategies_v2/tests/test_formal_tempai.py @@ -0,0 +1,82 @@ +from game.ai.strategies_v2.formal_tempai import FormalTempaiStrategy +from game.ai.strategies_v2.main import BaseStrategy +from game.ai.strategies_v2.tests.test_chiitoitsu import make_open_hand_v2_table +from mahjong.tile import Tile +from utils.decisions_logger import MeldPrint +from utils.test_helpers import make_meld, string_to_136_array, string_to_136_tile, tiles_to_string + + +def test_should_activate_strategy(): + table = make_open_hand_v2_table() + table.player.dealer_seat = 3 + + strategy = FormalTempaiStrategy(BaseStrategy.FORMAL_TEMPAI, table.player) + + tiles = string_to_136_array(sou="12355689", man="89", pin="339") + table.player.init_hand(tiles) + assert strategy.should_activate_strategy(table.player.tiles) is False + + # Let's move to 10th round step + for _ in range(0, 10): + table.player.add_discarded_tile(Tile(0, False)) + + assert strategy.should_activate_strategy(table.player.tiles) is False + + # Now we move to 11th turn, we have 2 shanten and no doras, + # we should go for formal tempai + table.player.add_discarded_tile(Tile(0, True)) + assert strategy.should_activate_strategy(table.player.tiles) is True + + +def test_get_tempai(): + table = make_open_hand_v2_table() + table.player.dealer_seat = 3 + + tiles = string_to_136_array(man="2379", sou="4568", pin="22299") + table.player.init_hand(tiles) + + # Let's move to 15th round step + for _ in range(0, 15): + table.player.add_discarded_tile(Tile(0, False)) + + tile = string_to_136_tile(man="8") + meld, _ = table.player.try_to_call_meld(tile, True) + assert meld is not None + assert tiles_to_string(meld.tiles) == "789m" + + # reinit hand with meld + tiles = string_to_136_array(man="23789", sou="4568", pin="22299") + table.player.init_hand(tiles) + table.player.add_called_meld(meld) + + tile_to_discard, _ = table.player.discard_tile() + assert tiles_to_string([tile_to_discard]) == "8s" + + +def test_dont_meld_agari(): + """ + We shouldn't open when we are already in tempai expect for some special cases + """ + table = make_open_hand_v2_table() + table.player.dealer_seat = 3 + + strategy = FormalTempaiStrategy(BaseStrategy.FORMAL_TEMPAI, table.player) + + tiles = string_to_136_array(man="2379", sou="4568", pin="22299") + table.player.init_hand(tiles) + + # Let's move to 15th round step + for _ in range(0, 15): + table.player.add_discarded_tile(Tile(0, False)) + + assert strategy.should_activate_strategy(table.player.tiles) is True + + tiles = string_to_136_array(man="23789", sou="456", pin="22299") + table.player.init_hand(tiles) + + meld = make_meld(MeldPrint.CHI, man="789") + table.player.add_called_meld(meld) + + tile = string_to_136_tile(man="4") + meld, _ = table.player.try_to_call_meld(tile, True) + assert meld is None diff --git a/project/game/ai/strategies_v2/tests/test_honitsu.py b/project/game/ai/strategies_v2/tests/test_honitsu.py new file mode 100644 index 00000000..e84007cc --- /dev/null +++ b/project/game/ai/strategies_v2/tests/test_honitsu.py @@ -0,0 +1,249 @@ +from game.ai.strategies_v2.honitsu import HonitsuStrategy +from game.ai.strategies_v2.main import BaseStrategy +from game.ai.strategies_v2.tests.test_chiitoitsu import make_open_hand_v2_table +from utils.decisions_logger import MeldPrint +from utils.test_helpers import make_meld, string_to_136_array, string_to_136_tile, tiles_to_string + + +def test_should_activate_strategy(): + table = make_open_hand_v2_table() + player = table.player + strategy = HonitsuStrategy(BaseStrategy.HONITSU, player) + + table.add_dora_indicator(string_to_136_tile(pin="1")) + table.add_dora_indicator(string_to_136_tile(honors="5")) + + tiles = string_to_136_array(sou="12355", man="12389", honors="123") + player.init_hand(tiles) + assert strategy.should_activate_strategy(player.tiles) is False + + # many tiles in one suit and yakuhai pair, but still many useless winds + tiles = string_to_136_array(sou="12355", man="23", pin="68", honors="2355") + player.init_hand(tiles) + assert strategy.should_activate_strategy(player.tiles) is False + + # many tiles in one suit and yakuhai pair and another honor pair, so + # now this is honitsu + tiles = string_to_136_array(sou="12355", man="238", honors="22355") + player.init_hand(tiles) + assert strategy.should_activate_strategy(player.tiles) is True + + # same conditions, but ready suit with dora in another suit, so no honitsu + tiles = string_to_136_array(sou="12355", pin="234", honors="22355") + player.init_hand(tiles) + assert strategy.should_activate_strategy(player.tiles) is False + + # same conditions, but we have a pon of yakuhai doras, we shouldn't + # force honitsu with this hand + tiles = string_to_136_array(sou="12355", pin="238", honors="22666") + player.init_hand(tiles) + assert strategy.should_activate_strategy(player.tiles) is False + + # if we have a complete set with dora, we shouldn't go for honitsu + tiles = string_to_136_array(sou="11123688", pin="123", honors="55") + player.init_hand(tiles) + assert strategy.should_activate_strategy(player.tiles) is False + + # even if the set may be interpreted as two forms + tiles = string_to_136_array(sou="1223688", pin="2334", honors="55") + player.init_hand(tiles) + assert strategy.should_activate_strategy(player.tiles) is False + + # even if the set may be interpreted as two forms v2 + tiles = string_to_136_array(sou="1223688", pin="2345", honors="55") + player.init_hand(tiles) + assert strategy.should_activate_strategy(player.tiles) is False + + # if we have a long form with dora, we shouldn't go for honitsu + tiles = string_to_136_array(sou="1223688", pin="2333", honors="55") + player.init_hand(tiles) + assert strategy.should_activate_strategy(player.tiles) is False + + +def test_suitable_tiles(): + table = make_open_hand_v2_table() + player = table.player + strategy = HonitsuStrategy(BaseStrategy.HONITSU, player) + + tiles = string_to_136_array(sou="12355", man="238", honors="23455") + player.init_hand(tiles) + assert strategy.should_activate_strategy(player.tiles) is True + + tile = string_to_136_tile(man="1") + assert strategy.is_tile_suitable(tile) is False + + tile = string_to_136_tile(pin="1") + assert strategy.is_tile_suitable(tile) is False + + tile = string_to_136_tile(sou="1") + assert strategy.is_tile_suitable(tile) is True + + tile = string_to_136_tile(honors="1") + assert strategy.is_tile_suitable(tile) is True + + +def test_open_hand_and_discard_tiles_logic(): + table = make_open_hand_v2_table() + player = table.player + + tiles = string_to_136_array(sou="112235589", man="23", honors="66") + player.init_hand(tiles) + + # we don't need to call meld even if it improves our hand, + # because we are aim for honitsu or pinfu + tile = string_to_136_tile(man="1") + meld, _ = player.try_to_call_meld(tile, False) + assert meld is None + + # any honor tile is suitable + tile = string_to_136_tile(honors="6") + meld, discard_option = player.try_to_call_meld(tile, False) + assert meld is not None + assert tiles_to_string([discard_option.tile_to_discard_136]) == "2m" + + tile = string_to_136_tile(man="1") + player.draw_tile(tile) + tile_to_discard, _ = player.discard_tile() + + # we are in honitsu mode, so we should discard man suits + assert tiles_to_string([tile_to_discard]) == "1m" + + +def test_riichi_and_tiles_from_another_suit_in_the_hand(): + table = make_open_hand_v2_table() + player = table.player + player.scores = 25000 + table.count_of_remaining_tiles = 100 + + tiles = string_to_136_array(man="33345678", pin="22", honors="155") + player.init_hand(tiles) + + player.draw_tile(string_to_136_tile(man="9")) + tile_to_discard, _ = player.discard_tile() + + # we don't need to go for honitsu here + # we already in tempai + assert tiles_to_string([tile_to_discard]) == "1z" + + +def test_discard_not_needed_winds(): + table = make_open_hand_v2_table() + player = table.player + player.scores = 25000 + table.count_of_remaining_tiles = 100 + + tiles = string_to_136_array(man="24", pin="4", sou="12344668", honors="36") + player.init_hand(tiles) + player.draw_tile(string_to_136_tile(sou="5")) + + table.add_discarded_tile(1, string_to_136_tile(honors="3"), False) + table.add_discarded_tile(1, string_to_136_tile(honors="3"), False) + table.add_discarded_tile(1, string_to_136_tile(honors="3"), False) + + tile_to_discard, _ = player.discard_tile() + + # west was discarded three times, we don't need it + assert tiles_to_string([tile_to_discard]) == "3z" + + +def test_discard_not_effective_tiles_first(): + table = make_open_hand_v2_table() + player = table.player + player.scores = 25000 + table.count_of_remaining_tiles = 100 + + tiles = string_to_136_array(man="33", pin="12788999", sou="5", honors="77") + player.init_hand(tiles) + player.draw_tile(string_to_136_tile(honors="6")) + tile_to_discard, _ = player.discard_tile() + + assert tiles_to_string([tile_to_discard]) == "5s" + + +def test_discard_not_effective_tiles_first_not_honitsu(): + table = make_open_hand_v2_table() + player = table.player + player.scores = 25000 + table.count_of_remaining_tiles = 100 + + tiles = string_to_136_array(man="33", pin="12788999", sou="5", honors="23") + player.init_hand(tiles) + player.draw_tile(string_to_136_tile(honors="6")) + tile_to_discard, _ = player.discard_tile() + + # this is not really a honitsu + assert tiles_to_string([tile_to_discard]) == "2z" or tiles_to_string([tile_to_discard]) == "3z" + + +def test_open_yakuhai_same_shanten(): + table = make_open_hand_v2_table() + player = table.player + player.scores = 25000 + table.count_of_remaining_tiles = 100 + + tiles = string_to_136_array(man="34556778", pin="3", sou="78", honors="77") + player.init_hand(tiles) + + meld = make_meld(MeldPrint.CHI, man="345") + player.add_called_meld(meld) + + strategy = HonitsuStrategy(BaseStrategy.HONITSU, player) + assert strategy.should_activate_strategy(player.tiles) is True + + tile = string_to_136_tile(honors="7") + meld, _ = player.try_to_call_meld(tile, True) + assert meld is not None + assert tiles_to_string(meld.tiles) == "777z" + + +def test_open_hand_and_not_go_for_chiitoitsu(): + table = make_open_hand_v2_table() + player = table.player + + tiles = string_to_136_array(sou="1122559", honors="134557") + player.init_hand(tiles) + player.draw_tile(string_to_136_tile(pin="4")) + + tile, _ = player.discard_tile() + assert tiles_to_string([tile]) == "4p" + + tile = string_to_136_tile(honors="5") + meld, _ = player.try_to_call_meld(tile, False) + assert meld is not None + assert tiles_to_string(meld.tiles) == "555z" + + +def test_open_hand_and_not_go_for_atodzuke_yakuhai(): + table = make_open_hand_v2_table() + # dora here to activate honitsu strategy + table.add_dora_indicator(string_to_136_tile(sou="9")) + player = table.player + player.seat = 1 + + tiles = string_to_136_array(sou="1112345678", honors="557") + player.init_hand(tiles) + tile = string_to_136_array(sou="1111")[3] + meld, _ = player.try_to_call_meld(tile, False) + assert meld is not None + assert tiles_to_string(meld.tiles) == "111s" + + +def test_open_suit_same_shanten(): + table = make_open_hand_v2_table() + player = table.player + player.scores = 25000 + table.count_of_remaining_tiles = 100 + + tiles = string_to_136_array(man="1134556", pin="3", sou="78", honors="777") + player.init_hand(tiles) + + meld = make_meld(MeldPrint.CHI, man="345") + player.add_called_meld(meld) + + strategy = HonitsuStrategy(BaseStrategy.HONITSU, player) + assert strategy.should_activate_strategy(player.tiles) is True + + tile = string_to_136_tile(man="1") + meld, _ = player.try_to_call_meld(tile, True) + assert meld is not None + assert tiles_to_string(meld.tiles) == "111m" diff --git a/project/game/ai/strategies_v2/tests/test_tanyao.py b/project/game/ai/strategies_v2/tests/test_tanyao.py new file mode 100644 index 00000000..8f9d394e --- /dev/null +++ b/project/game/ai/strategies_v2/tests/test_tanyao.py @@ -0,0 +1,663 @@ +from game.ai.open_hand_v2 import OpenHandHandlerV2 +from game.ai.strategies_v2.main import BaseStrategy +from game.ai.strategies_v2.tanyao import TanyaoStrategy +from game.table import Table +from mahjong.constants import FIVE_RED_PIN, FIVE_RED_SOU +from utils.decisions_logger import MeldPrint +from utils.test_helpers import make_meld, string_to_136_array, string_to_136_tile, tiles_to_string + + +def test_should_activate_strategy_and_terminal_pon_sets(): + table = _make_table() + + strategy = TanyaoStrategy(BaseStrategy.TANYAO, table.player) + + tiles = string_to_136_array(sou="222", man="3459", pin="233", honors="111") + table.player.init_hand(tiles) + assert strategy.should_activate_strategy(table.player.tiles) is False + + tiles = string_to_136_array(sou="222", man="3459", pin="233999") + table.player.init_hand(tiles) + assert strategy.should_activate_strategy(table.player.tiles) is False + + tiles = string_to_136_array(sou="222", man="3459", pin="233444") + table.player.init_hand(tiles) + assert strategy.should_activate_strategy(table.player.tiles) is True + + +def test_should_activate_strategy_and_terminal_pairs(): + table = _make_table() + strategy = TanyaoStrategy(BaseStrategy.TANYAO, table.player) + + tiles = string_to_136_array(sou="222", man="3459", pin="2399", honors="11") + table.player.init_hand(tiles) + assert strategy.should_activate_strategy(table.player.tiles) is False + + tiles = string_to_136_array(sou="22258", man="3566", pin="2399") + table.player.init_hand(tiles) + assert strategy.should_activate_strategy(table.player.tiles) is True + + +def test_should_activate_strategy_and_valued_pair(): + table = _make_table() + strategy = TanyaoStrategy(BaseStrategy.TANYAO, table.player) + + tiles = string_to_136_array(man="23446679", sou="222", honors="55") + table.player.init_hand(tiles) + assert strategy.should_activate_strategy(table.player.tiles) is False + + tiles = string_to_136_array(man="23446679", sou="222", honors="22") + table.player.init_hand(tiles) + assert strategy.should_activate_strategy(table.player.tiles) is True + + +def test_should_activate_strategy_and_chitoitsu_like_hand(): + table = _make_table() + strategy = TanyaoStrategy(BaseStrategy.TANYAO, table.player) + + tiles = string_to_136_array(sou="223388", man="2244", pin="6687") + table.player.init_hand(tiles) + assert strategy.should_activate_strategy(table.player.tiles) is True + + +def test_should_activate_strategy_and_already_completed_sided_set(): + table = _make_table() + strategy = TanyaoStrategy(BaseStrategy.TANYAO, table.player) + + tiles = string_to_136_array(sou="123234", man="2349", pin="234") + table.player.init_hand(tiles) + assert strategy.should_activate_strategy(table.player.tiles) is False + + tiles = string_to_136_array(sou="234789", man="2349", pin="234") + table.player.init_hand(tiles) + assert strategy.should_activate_strategy(table.player.tiles) is False + + tiles = string_to_136_array(sou="234", man="1233459", pin="234") + table.player.init_hand(tiles) + assert strategy.should_activate_strategy(table.player.tiles) is False + + tiles = string_to_136_array(sou="234", man="2227899", pin="234") + table.player.init_hand(tiles) + assert strategy.should_activate_strategy(table.player.tiles) is False + + tiles = string_to_136_array(sou="234", man="2229", pin="122334") + table.player.init_hand(tiles) + assert strategy.should_activate_strategy(table.player.tiles) is False + + tiles = string_to_136_array(sou="234", man="2229", pin="234789") + table.player.init_hand(tiles) + assert strategy.should_activate_strategy(table.player.tiles) is False + + tiles = string_to_136_array(sou="223344", man="2229", pin="234") + table.player.init_hand(tiles) + assert strategy.should_activate_strategy(table.player.tiles) is True + + +def test_suitable_tiles(): + table = _make_table() + strategy = TanyaoStrategy(BaseStrategy.TANYAO, table.player) + + tile = string_to_136_tile(man="1") + assert strategy.is_tile_suitable(tile) is False + + tile = string_to_136_tile(pin="1") + assert strategy.is_tile_suitable(tile) is False + + tile = string_to_136_tile(sou="9") + assert strategy.is_tile_suitable(tile) is False + + tile = string_to_136_tile(honors="1") + assert strategy.is_tile_suitable(tile) is False + + tile = string_to_136_tile(honors="6") + assert strategy.is_tile_suitable(tile) is False + + tile = string_to_136_tile(man="2") + assert strategy.is_tile_suitable(tile) is True + + tile = string_to_136_tile(pin="5") + assert strategy.is_tile_suitable(tile) is True + + tile = string_to_136_tile(sou="8") + assert strategy.is_tile_suitable(tile) is True + + +def test_dont_open_hand_with_high_shanten(): + table = _make_table() + # with 4 shanten we don't need to aim for open tanyao + tiles = string_to_136_array(man="269", pin="247", sou="2488", honors="123") + tile = string_to_136_tile(sou="3") + table.player.init_hand(tiles) + meld, _ = table.player.try_to_call_meld(tile, False) + assert meld is None + + # with 3 shanten we can open a hand + tiles = string_to_136_array(man="236", pin="247", sou="2488", honors="123") + tile = string_to_136_tile(sou="3") + table.player.init_hand(tiles) + meld, _ = table.player.try_to_call_meld(tile, True) + assert meld is not None + + +def test_dont_open_hand_with_not_suitable_melds(): + table = _make_table() + tiles = string_to_136_array(man="22255788", sou="3479", honors="3") + tile = string_to_136_tile(sou="8") + table.player.init_hand(tiles) + meld, _ = table.player.try_to_call_meld(tile, False) + assert meld is None + + +def test_open_hand_and_discard_tiles_logic(): + table = _make_table() + tiles = string_to_136_array(man="22234", sou="238", pin="256", honors="44") + table.player.init_hand(tiles) + + tile = string_to_136_tile(sou="4") + meld, discard_option = table.player.try_to_call_meld(tile, True) + assert meld is not None + assert tiles_to_string([discard_option.tile_to_discard_136]) == "4z" + + tiles = string_to_136_array(man="22234", sou="2348", pin="256", honors="44") + table.player.init_hand(tiles) + table.player.add_called_meld(meld) + table.player.discard_tile(discard_option) + + tile = string_to_136_tile(pin="5") + table.player.draw_tile(tile) + tile_to_discard, _ = table.player.discard_tile() + + assert tiles_to_string([tile_to_discard]) == "4z" + + +def test_open_hand_and_discard_tiles_logic_advanced(): + # we should choose between one of the ryanmens to discard + # in this case - discard the one that leads us to atodzuke and has less tanyao ukeire + # despite the fact that general ukeire is higher + table = _make_table() + table.add_discarded_tile(2, string_to_136_tile(pin="4"), False) + table.add_discarded_tile(2, string_to_136_tile(pin="7"), False) + table.add_discarded_tile(2, string_to_136_tile(sou="4"), False) + table.add_discarded_tile(2, string_to_136_tile(sou="7"), False) + + tiles = string_to_136_array(man="236777", sou="56", pin="22256") + table.player.init_hand(tiles) + meld = make_meld(MeldPrint.PON, pin="222") + table.player.add_called_meld(meld) + tile = string_to_136_tile(man="6") + table.player.draw_tile(tile) + tile_to_discard, _ = table.player.discard_tile() + assert tiles_to_string([tile_to_discard]) == "2m" or tiles_to_string([tile_to_discard]) == "3m" + + # now same situation, but better ryanmen is no atodzuke + table = _make_table() + table.add_discarded_tile(2, string_to_136_tile(pin="4"), False) + table.add_discarded_tile(2, string_to_136_tile(pin="7"), False) + table.add_discarded_tile(2, string_to_136_tile(sou="4"), False) + table.add_discarded_tile(2, string_to_136_tile(sou="7"), False) + + tiles = string_to_136_array(man="346777", sou="56", pin="22256") + table.player.init_hand(tiles) + meld = make_meld(MeldPrint.PON, pin="222") + table.player.add_called_meld(meld) + tile = string_to_136_tile(man="6") + table.player.draw_tile(tile) + tile_to_discard, _ = table.player.discard_tile() + assert ( + tiles_to_string([tile_to_discard]) == "5s" + or tiles_to_string([tile_to_discard]) == "6s" + or tiles_to_string([tile_to_discard]) == "5p" + or tiles_to_string([tile_to_discard]) == "6p" + ) + + # now same situation as the first one, but ryanshanten + table = _make_table() + table.add_discarded_tile(2, string_to_136_tile(pin="4"), False) + table.add_discarded_tile(2, string_to_136_tile(pin="7"), False) + table.add_discarded_tile(2, string_to_136_tile(sou="4"), False) + table.add_discarded_tile(2, string_to_136_tile(sou="7"), False) + table.add_discarded_tile(2, string_to_136_tile(man="5"), False) + table.add_discarded_tile(2, string_to_136_tile(man="8"), False) + + tiles = string_to_136_array(man="2367", sou="2356", pin="22256") + table.player.init_hand(tiles) + meld = make_meld(MeldPrint.PON, pin="222") + table.player.add_called_meld(meld) + tile = string_to_136_tile(man="8") + table.player.draw_tile(tile) + tile_to_discard, _ = table.player.discard_tile() + assert ( + tiles_to_string([tile_to_discard]) == "2m" + or tiles_to_string([tile_to_discard]) == "3m" + or tiles_to_string([tile_to_discard]) == "2s" + or tiles_to_string([tile_to_discard]) == "3s" + ) + + +def test_dont_count_pairs_in_already_opened_hand(): + table = _make_table() + tiles = string_to_136_array(man="33556788", sou="22266") + table.player.init_hand(tiles) + + meld = make_meld(MeldPrint.PON, sou="222") + table.player.add_called_meld(meld) + + tile = string_to_136_tile(sou="6") + meld, _ = table.player.try_to_call_meld(tile, False) + # even if it looks like chitoitsu we can open hand and get tempai here + assert meld is not None + + +def test_we_cant_win_with_this_hand(): + table = _make_table() + tiles = string_to_136_array(man="22277", sou="23", pin="233445") + table.player.init_hand(tiles) + meld = make_meld(MeldPrint.CHI, pin="234") + table.player.add_called_meld(meld) + + table.player.draw_tile(string_to_136_tile(sou="1")) + discard, _ = table.player.discard_tile() + # but for already open hand we cant do tsumo + # because we don't have a yaku here + # so, let's do tsumogiri + assert table.player.ai.shanten == 0 + assert tiles_to_string([discard]) == "1s" + + +def test_choose_correct_waiting(): + table = _make_table() + tiles = string_to_136_array(man="222678", sou="234", pin="3588") + table.player.init_hand(tiles) + table.player.draw_tile(string_to_136_tile(pin="2")) + + _assert_tanyao(table.player) + + # discard 5p and riichi + discard, _ = table.player.discard_tile() + assert tiles_to_string([discard]) == "5p" + + meld = make_meld(MeldPrint.CHI, man="234") + table.player.add_called_meld(meld) + + tiles = string_to_136_array(man="234888", sou="234", pin="3588") + table.player.init_hand(tiles) + table.player.draw_tile(string_to_136_tile(pin="2")) + + # it is not a good idea to wait on 1-4, since we can't win on 1 with open hand + # so let's continue to wait on 4 only + discard, _ = table.player.discard_tile() + assert tiles_to_string([discard]) == "2p" + + table = _make_table() + player = table.player + + meld = make_meld(MeldPrint.CHI, man="678") + player.add_called_meld(meld) + + tiles = string_to_136_array(man="222678", sou="234", pin="2388") + player.init_hand(tiles) + player.draw_tile(string_to_136_tile(sou="7")) + + # we can wait only on 1-4, so let's do it even if we can't get yaku on 1 + discard, _ = player.discard_tile() + assert tiles_to_string([discard]) == "7s" + + +def test_choose_balanced_ukeire_in_1_shanten(): + table = _make_table() + player = table.player + + meld = make_meld(MeldPrint.CHI, man="678") + player.add_called_meld(meld) + + tiles = string_to_136_array(man="22678", sou="234568", pin="45") + player.init_hand(tiles) + player.draw_tile(string_to_136_tile(man="2")) + + _assert_tanyao(player) + + # there are lost of options to avoid atodzuke and even if it is atodzuke, + # it is still a good one, so let's choose more efficient 8s discard instead of 2s + discard, _ = player.discard_tile() + assert tiles_to_string([discard]) == "8s" + + +def test_choose_pseudo_atodzuke(): + table = _make_table() + table.has_aka_dora = False + player = table.player + + # one tile is dora indicator and 3 are out + # so this 1-4 wait is not atodzuke + for _ in range(0, 3): + table.add_discarded_tile(1, string_to_136_tile(pin="1"), False) + + meld = make_meld(MeldPrint.CHI, man="678") + player.add_called_meld(meld) + + tiles = string_to_136_array(man="222678", sou="23488", pin="35") + player.init_hand(tiles) + player.draw_tile(string_to_136_tile(pin="2")) + + _assert_tanyao(player) + + discard, _ = player.discard_tile() + assert tiles_to_string([discard]) == "5p" + + +def test_choose_correct_waiting_and_first_opened_meld(): + table = _make_table() + tiles = string_to_136_array(man="2337788", sou="222", pin="234") + table.player.init_hand(tiles) + + tile = string_to_136_tile(man="8") + meld, tile_to_discard = table.player.try_to_call_meld(tile, False) + + _assert_tanyao(table.player) + + discard, _ = table.player.discard_tile(tile_to_discard) + assert tiles_to_string([discard]) == "2m" + + +def test_we_dont_need_to_discard_terminals_from_closed_hand(): + table = _make_table() + tiles = string_to_136_array(man="22234", sou="13588", pin="558") + table.player.init_hand(tiles) + + tile = string_to_136_tile(pin="5") + table.player.draw_tile(tile) + tile_to_discard, _ = table.player.discard_tile() + + # our hand is closed, let's keep terminal for now + assert tiles_to_string([tile_to_discard]) == "8p" + + +def test_dont_open_tanyao_with_two_non_central_doras(): + table = _make_table() + table.add_dora_indicator(string_to_136_tile(pin="8")) + + tiles = string_to_136_array(man="22234", sou="6888", pin="5599") + table.player.init_hand(tiles) + + tile = string_to_136_tile(pin="5") + meld, _ = table.player.try_to_call_meld(tile, False) + assert meld is None + + +def test_dont_open_tanyao_with_three_not_isolated_terminals(): + table = _make_table() + tiles = string_to_136_array(man="22256", sou="2799", pin="5579") + table.player.init_hand(tiles) + + tile = string_to_136_tile(pin="5") + meld, _ = table.player.try_to_call_meld(tile, False) + assert meld is None + + +def test_dont_open_tanyao_with_two_not_isolated_terminals_one_shanten(): + table = _make_table() + tiles = string_to_136_array(man="22234", sou="379", pin="55579") + table.player.init_hand(tiles) + + tile = string_to_136_tile(man="5") + meld, _ = table.player.try_to_call_meld(tile, False) + assert meld is None + + +def test_dont_count_terminal_tiles_in_ukeire(): + table = _make_table() + # for closed hand let's chose tile with best ukeire + tiles = string_to_136_array(man="234578", sou="235", pin="2246") + table.player.init_hand(tiles) + table.player.draw_tile(string_to_136_tile(pin="5")) + discard, _ = table.player.discard_tile() + assert ( + tiles_to_string([discard]) == "5m" or tiles_to_string([discard]) == "2m" or tiles_to_string([discard]) == "5s" + ) + + # but with opened hand we don't need to count not suitable tiles as ukeire + tiles = string_to_136_array(man="234578", sou="235", pin="2246") + table.player.init_hand(tiles) + table.player.add_called_meld(make_meld(MeldPrint.CHI, man="234")) + table.player.draw_tile(string_to_136_tile(pin="5")) + discard, _ = table.player.discard_tile() + assert tiles_to_string([discard]) == "8m" + + +def test_determine_strategy_when_we_try_to_call_meld(): + table = _make_table() + table.has_aka_dora = True + table.player.round_step = 10 + + table.add_dora_indicator(string_to_136_tile(sou="5")) + tiles = string_to_136_array(man="66678", sou="6888", pin="5588") + table.player.init_hand(tiles) + + # with this red five we will have 2 dora in the hand + # and in that case we can open our hand + meld, _ = table.player.try_to_call_meld(FIVE_RED_PIN, False) + assert meld is not None + + _assert_tanyao(table.player) + + +def test_correct_discard_agari_no_yaku(): + table = _make_table() + tiles = string_to_136_array(man="23567", sou="456", pin="22244") + table.player.init_hand(tiles) + + meld = make_meld(MeldPrint.CHI, man="567") + table.player.add_called_meld(meld) + + tile = string_to_136_tile(man="1") + table.player.draw_tile(tile) + discard, _ = table.player.discard_tile() + assert tiles_to_string([discard]) == "1m" + + +# In case we are in temporary furiten, we can't call ron, but can still +# make chi. We assume this chi to be bad, so let's not call it. +def test_dont_meld_agari(): + table = _make_table() + tiles = string_to_136_array(man="23567", sou="456", pin="22244") + table.player.init_hand(tiles) + + meld = make_meld(MeldPrint.CHI, man="567") + table.player.add_called_meld(meld) + + tile = string_to_136_tile(man="4") + meld, _ = table.player.try_to_call_meld(tile, True) + assert meld is None + + +def test_dont_open_tanyao_with_good_one_shanten_hand_into_one_shanten(): + table = _make_table() + table.has_aka_dora = True + table.add_dora_indicator(string_to_136_tile(pin="2")) + tiles = string_to_136_array(man="3488", sou="3478", pin="1345") + [FIVE_RED_SOU] # aka dora + table.player.init_hand(tiles) + table.player.round_step = 10 + + # tile is not suitable to our strategy + tile = string_to_136_tile(sou="9") + meld, _ = table.player.try_to_call_meld(tile, True) + assert meld is None + + # after meld we are tempai + tile = string_to_136_tile(sou="6") + meld, _ = table.player.try_to_call_meld(tile, True) + assert meld is not None + assert tiles_to_string(meld.tiles) == "678s" + + # we have a good one shanten and after meld we are not tempai, abort melding + tile = FIVE_RED_SOU + 1 # not aka dora + meld, _ = table.player.try_to_call_meld(tile, True) + assert meld is None + + # we must still take chi when improving from 2-shanten to 1-shanten though + tiles = string_to_136_array(man="34788", sou="3478", pin="135") + [FIVE_RED_SOU] # aka dora + table.player.init_hand(tiles) + tile = string_to_136_tile(sou="6") + meld, _ = table.player.try_to_call_meld(tile, True) + assert meld is not None + assert tiles_to_string(meld.tiles) == "678s" + + tile = FIVE_RED_SOU + 1 # not aka dora + meld, _ = table.player.try_to_call_meld(tile, True) + assert meld is None + + # from 2-shanten to 2-shanten, but with clear improvement and dora pon + tiles = string_to_136_array(man="2257", sou="3456899", pin="68") + table.player.init_hand(tiles) + tile = string_to_136_tile(man="2") + meld, _ = table.player.try_to_call_meld(tile, False) + assert meld is not None + assert tiles_to_string(meld.tiles) == "222m" + + +def test_kuikae_simple(): + # case 1: simple chi + table = _make_table() + table.add_dora_indicator(string_to_136_tile(pin="2")) + # but with opened hand we don't need to count not suitable tiles as ukeire + tiles = string_to_136_array(man="234678", sou="135", pin="3335") + table.player.init_hand(tiles) + table.player.add_called_meld(make_meld(MeldPrint.CHI, man="234")) + + tile = string_to_136_tile(sou="4") + meld, _ = table.player.try_to_call_meld(tile, True) + assert meld is not None + + # case 2: kuikae + table = _make_table() + table.add_dora_indicator(string_to_136_tile(pin="2")) + # but with opened hand we don't need to count not suitable tiles as ukeire + tiles = string_to_136_array(man="234678", sou="123", pin="3335") + table.player.init_hand(tiles) + table.player.add_called_meld(make_meld(MeldPrint.CHI, man="234")) + + tile = string_to_136_tile(sou="4") + meld, _ = table.player.try_to_call_meld(tile, True) + assert meld is None + + # case 3: no kuikae can be applie to pon + table = _make_table() + table.add_dora_indicator(string_to_136_tile(pin="2")) + # but with opened hand we don't need to count not suitable tiles as ukeire + tiles = string_to_136_array(man="234678", sou="144", pin="3335") + table.player.init_hand(tiles) + table.player.add_called_meld(make_meld(MeldPrint.CHI, man="234")) + + tile = string_to_136_tile(sou="4") + meld, _ = table.player.try_to_call_meld(tile, False) + assert meld is not None + + # case 4: no false kuikae + table = _make_table() + table.add_dora_indicator(string_to_136_tile(pin="2")) + # but with opened hand we don't need to count not suitable tiles as ukeire + tiles = string_to_136_array(man="234678", sou="237", pin="3335") + table.player.init_hand(tiles) + table.player.add_called_meld(make_meld(MeldPrint.CHI, man="234")) + + tile = string_to_136_tile(sou="4") + meld, _ = table.player.try_to_call_meld(tile, True) + assert meld is not None + + +def test_kuikae_advanced(): + # case 0: sanity check + table = _make_table() + table.add_dora_indicator(string_to_136_tile(pin="2")) + tiles = string_to_136_array(man="234", sou="23456", pin="33359") + table.player.init_hand(tiles) + table.player.add_called_meld(make_meld(MeldPrint.CHI, man="234")) + # just force tanyao for the test + table.player.ai.open_hand_handler.current_strategy = TanyaoStrategy(BaseStrategy.TANYAO, table.player) + _assert_tanyao(table.player) + + tile = string_to_136_array(sou="4444")[1] + meld, _ = table.player.try_to_call_meld(tile, True) + assert meld is not None + + # case 1: allowed chi + table = _make_table() + table.add_dora_indicator(string_to_136_tile(pin="2")) + tiles = string_to_136_array(man="234", sou="123456", pin="3335") + table.player.init_hand(tiles) + table.player.add_called_meld(make_meld(MeldPrint.CHI, man="234")) + # just force tanyao for the test + table.player.ai.open_hand_handler.current_strategy = TanyaoStrategy(BaseStrategy.TANYAO, table.player) + _assert_tanyao(table.player) + + tile = string_to_136_array(sou="4444")[1] + meld, _ = table.player.try_to_call_meld(tile, True) + assert meld is not None + + # case 2: another allowed chi + table = _make_table() + table.add_dora_indicator(string_to_136_tile(pin="2")) + tiles = string_to_136_array(man="234", sou="123345", pin="3335") + table.player.init_hand(tiles) + table.player.add_called_meld(make_meld(MeldPrint.CHI, man="234")) + # just force tanyao for the test + table.player.ai.open_hand_handler.current_strategy = TanyaoStrategy(BaseStrategy.TANYAO, table.player) + _assert_tanyao(table.player) + + tile = string_to_136_array(sou="4444")[1] + meld, _ = table.player.try_to_call_meld(tile, True) + assert meld is not None + + # case 3: another allowed chi + table = _make_table() + table.add_dora_indicator(string_to_136_tile(pin="2")) + tiles = string_to_136_array(man="234", sou="12345", pin="33355") + table.player.init_hand(tiles) + table.player.add_called_meld(make_meld(MeldPrint.CHI, man="234")) + # just force tanyao for the test + table.player.ai.open_hand_handler.current_strategy = TanyaoStrategy(BaseStrategy.TANYAO, table.player) + _assert_tanyao(table.player) + + tile = string_to_136_array(sou="4444")[1] + meld, _ = table.player.try_to_call_meld(tile, True) + assert meld is not None + + # case 4: useless chi, don't do that + table = _make_table() + table.add_dora_indicator(string_to_136_tile(pin="2")) + tiles = string_to_136_array(man="234", sou="234567", pin="3335") + table.player.init_hand(tiles) + table.player.add_called_meld(make_meld(MeldPrint.CHI, man="234")) + # just force tanyao for the test + table.player.ai.open_hand_handler.current_strategy = TanyaoStrategy(BaseStrategy.TANYAO, table.player) + _assert_tanyao(table.player) + + tile = string_to_136_array(sou="2222")[2] + meld, _ = table.player.try_to_call_meld(tile, True) + assert meld is None + + tile = string_to_136_array(sou="5555")[2] + meld, _ = table.player.try_to_call_meld(tile, True) + assert meld is None + + tile = string_to_136_array(sou="8888")[2] + meld, _ = table.player.try_to_call_meld(tile, True) + assert meld is None + + +def _make_table(): + table = Table() + table.has_open_tanyao = True + table.player.ai.open_hand_handler = OpenHandHandlerV2(table.player) + + # add doras so we are sure to go for tanyao + table.add_dora_indicator(string_to_136_tile(sou="1")) + table.add_dora_indicator(string_to_136_tile(man="1")) + table.add_dora_indicator(string_to_136_tile(pin="1")) + + return table + + +def _assert_tanyao(player): + assert player.ai.open_hand_handler.current_strategy is not None + assert player.ai.open_hand_handler.current_strategy.type == BaseStrategy.TANYAO diff --git a/project/game/ai/strategies_v2/tests/test_yakuhai.py b/project/game/ai/strategies_v2/tests/test_yakuhai.py new file mode 100644 index 00000000..8f05aa30 --- /dev/null +++ b/project/game/ai/strategies_v2/tests/test_yakuhai.py @@ -0,0 +1,587 @@ +from game.ai.strategies_v2.main import BaseStrategy +from game.ai.strategies_v2.tests.test_chiitoitsu import make_open_hand_v2_table +from game.ai.strategies_v2.yakuhai import YakuhaiStrategy +from mahjong.constants import EAST, SOUTH, WEST +from mahjong.tile import Tile +from utils.decisions_logger import MeldPrint +from utils.test_helpers import make_meld, string_to_136_array, string_to_136_tile, tiles_to_string + + +def test_should_activate_strategy(): + table = make_open_hand_v2_table() + strategy = YakuhaiStrategy(BaseStrategy.YAKUHAI, table.player) + + tiles = string_to_136_array(sou="12355689", man="89", honors="123") + table.player.init_hand(tiles) + assert strategy.should_activate_strategy(table.player.tiles) is False + + table.dora_indicators.append(string_to_136_tile(honors="7")) + tiles = string_to_136_array(sou="12355689", man="899", honors="55") + table.player.init_hand(tiles) + assert strategy.should_activate_strategy(table.player.tiles) is True + + # with chitoitsu-like hand we don't need to go for yakuhai + tiles = string_to_136_array(sou="1235566", man="8899", honors="66") + table.player.init_hand(tiles) + assert strategy.should_activate_strategy(table.player.tiles) is False + + # don't count tile discarded by other player as our pair + tiles = string_to_136_array(sou="12355689", man="899", honors="25") + table.player.init_hand(tiles) + tiles = string_to_136_array(sou="12355689", man="899", honors="255") + assert strategy.should_activate_strategy(tiles) is False + + +def test_dont_activate_strategy_if_we_dont_have_enough_tiles_in_the_wall(): + table = make_open_hand_v2_table() + strategy = YakuhaiStrategy(BaseStrategy.YAKUHAI, table.player) + + table.dora_indicators.append(string_to_136_tile(honors="7")) + tiles = string_to_136_array(man="59", sou="1235", pin="12789", honors="55") + table.player.init_hand(tiles) + + assert strategy.should_activate_strategy(table.player.tiles) is True + + table.add_discarded_tile(3, string_to_136_tile(honors="5"), False) + table.add_discarded_tile(3, string_to_136_tile(honors="5"), False) + + # we can't complete yakuhai, because there is not enough honor tiles + assert strategy.should_activate_strategy(table.player.tiles) is False + + +def test_suitable_tiles(): + table = make_open_hand_v2_table() + strategy = YakuhaiStrategy(BaseStrategy.YAKUHAI, table.player) + + # for yakuhai we can use any tile + for tile in range(0, 136): + assert strategy.is_tile_suitable(tile) is True + + +def test_force_yakuhai_pair_waiting_for_tempai_hand(): + """ + If hand shanten = 1 don't open hand except the situation where is + we have tempai on yakuhai tile after open set + """ + table = make_open_hand_v2_table() + + table.dora_indicators.append(string_to_136_tile(man="3")) + tiles = string_to_136_array(sou="123", pin="678", man="34468", honors="66") + table.player.init_hand(tiles) + + # we will not get tempai on yakuhai pair with this hand, so let's skip this call + tile = string_to_136_tile(man="5") + meld, _ = table.player.try_to_call_meld(tile, False) + assert meld is None + + # but here we will have atodzuke tempai + tile = string_to_136_tile(man="7") + meld, _ = table.player.try_to_call_meld(tile, True) + assert meld is not None + assert meld.type == MeldPrint.CHI + assert tiles_to_string(meld.tiles) == "678m" + + table = make_open_hand_v2_table() + # we can open hand in that case + table.dora_indicators.append(string_to_136_tile(sou="5")) + tiles = string_to_136_array(man="44556", sou="366789", honors="77") + table.player.init_hand(tiles) + + strategy = YakuhaiStrategy(BaseStrategy.YAKUHAI, table.player) + assert strategy.should_activate_strategy(table.player.tiles) is True + + tile = string_to_136_tile(honors="7") + meld, _ = table.player.try_to_call_meld(tile, True) + assert meld is not None + assert tiles_to_string(meld.tiles) == "777z" + + +def test_tempai_without_yaku(): + table = make_open_hand_v2_table() + tiles = string_to_136_array(sou="678", pin="12355", man="456", honors="77") + table.player.init_hand(tiles) + + tile = string_to_136_tile(pin="5") + table.player.draw_tile(tile) + meld = make_meld(MeldPrint.CHI, sou="678") + table.player.add_called_meld(meld) + + discard, _ = table.player.discard_tile() + assert tiles_to_string([discard]) != "7z" + + +def test_wrong_shanten_improvements_detection(): + """ + With hand 2345s1p11z bot wanted to open set on 2s, + so after opened set we will get 25s1p11z + it is not correct logic, because we ruined our hand + :return: + """ + table = make_open_hand_v2_table() + + tiles = string_to_136_array(sou="2345999", honors="114446") + table.player.init_hand(tiles) + + meld = make_meld(MeldPrint.PON, sou="999") + table.player.add_called_meld(meld) + meld = make_meld(MeldPrint.PON, honors="444") + table.player.add_called_meld(meld) + + tile = string_to_136_array(sou="2222")[1] + meld, _ = table.player.try_to_call_meld(tile, True) + assert meld is None + + +def test_open_hand_with_doras_in_the_hand(): + """ + If we have valuable pair in the hand, and 2+ dora let's open on this + valuable pair + """ + table = make_open_hand_v2_table() + table.player.dealer_seat = 3 + + tiles = string_to_136_array(man="59", sou="1235", pin="12789", honors="11") + table.player.init_hand(tiles) + + tile = string_to_136_tile(honors="1") + meld, _ = table.player.try_to_call_meld(tile, True) + assert meld is None + + # add doras to the hand + table.dora_indicators.append(string_to_136_tile(pin="7")) + table.dora_indicators.append(string_to_136_tile(pin="8")) + table.player.init_hand(tiles) + + # and now we can open hand on the valuable pair + tile = string_to_136_tile(honors="1") + meld, _ = table.player.try_to_call_meld(tile, True) + assert meld is not None + + # but we don't need to open hand for atodzuke here + tile = string_to_136_tile(pin="3") + meld, _ = table.player.try_to_call_meld(tile, True) + assert meld is None + + +def test_open_hand_with_doras_in_the_hand_and_atodzuke(): + """ + If we have valuable pair in the hand, and 2+ dora we can open hand on any tile + but only if we have other pair in the hand + """ + table = make_open_hand_v2_table() + table.player.dealer_seat = 3 + + tiles = string_to_136_array(man="59", sou="1235", pin="12788", honors="11") + table.player.init_hand(tiles) + + tile = string_to_136_tile(pin="3") + meld, _ = table.player.try_to_call_meld(tile, True) + assert meld is None + + # add doras to the hand + table.dora_indicators.append(string_to_136_tile(pin="7")) + table.player.init_hand(tiles) + + # we have other pair in the hand, so we can open atodzuke here + tile = string_to_136_tile(pin="3") + meld, _ = table.player.try_to_call_meld(tile, True) + assert meld is not None + + +def test_open_hand_on_fifth_round_step(): + """ + If we have valuable pair in the hand, 1+ dora and 5+ round step + let's open on this valuable pair + """ + table = make_open_hand_v2_table() + table.player.dealer_seat = 3 + + tiles = string_to_136_array(man="59", sou="1235", pin="12789", honors="11") + table.player.init_hand(tiles) + + tile = string_to_136_tile(honors="1") + meld, _ = table.player.try_to_call_meld(tile, True) + assert meld is None + + # add doras to the hand + table.dora_indicators.append(string_to_136_tile(pin="7")) + table.player.init_hand(tiles) + + tile = string_to_136_tile(honors="1") + meld, _ = table.player.try_to_call_meld(tile, True) + assert meld is None + + # one discard == one round step + table.player.add_discarded_tile(Tile(0, False)) + table.player.add_discarded_tile(Tile(0, False)) + table.player.add_discarded_tile(Tile(0, False)) + table.player.add_discarded_tile(Tile(0, False)) + table.player.add_discarded_tile(Tile(0, False)) + table.player.add_discarded_tile(Tile(0, False)) + table.player.init_hand(tiles) + + # after 5 round step we can open hand + tile = string_to_136_tile(honors="1") + meld, _ = table.player.try_to_call_meld(tile, True) + assert meld is not None + + # but we don't need to open hand for atodzuke here + tile = string_to_136_tile(pin="3") + meld, _ = table.player.try_to_call_meld(tile, True) + assert meld is None + + +def test_open_hand_with_two_valuable_pairs(): + """ + If we have two valuable pairs in the hand and 1+ dora or we are dealer + let's open on one of this valuable pairs + """ + table = make_open_hand_v2_table() + table.player.seat = 3 + + tiles = string_to_136_array(man="159", sou="128", pin="789", honors="5566") + table.player.init_hand(tiles) + + tile = string_to_136_tile(honors="5") + meld, _ = table.player.try_to_call_meld(tile, True) + assert meld is None + + # add doras to the hand + table.dora_indicators.append(string_to_136_tile(pin="7")) + table.player.init_hand(tiles) + + tile = string_to_136_tile(honors="5") + meld, _ = table.player.try_to_call_meld(tile, True) + assert meld is not None + + tile = string_to_136_tile(honors="6") + meld, _ = table.player.try_to_call_meld(tile, True) + assert meld is not None + + # but we don't need to open hand for atodzuke here + tile = string_to_136_tile(pin="3") + meld, _ = table.player.try_to_call_meld(tile, True) + assert meld is None + + +def test_open_hand_and_once_discarded_tile(): + """ + If we have valuable pair in the hand, this tile was discarded once and we have 1+ shanten + let's open on this valuable pair + """ + table = make_open_hand_v2_table() + strategy = YakuhaiStrategy(BaseStrategy.YAKUHAI, table.player) + + tiles = string_to_136_array(sou="678", pin="14689", man="456", honors="77") + table.player.init_hand(tiles) + + # we don't activate strategy yet + assert strategy.should_activate_strategy(table.player.tiles) is False + + # let's skip first yakuhai early in the game + tile = string_to_136_tile(honors="7") + meld, _ = table.player.try_to_call_meld(tile, True) + assert meld is None + + # now one is out + table.add_discarded_tile(1, tile, False) + + meld, _ = table.player.try_to_call_meld(tile, True) + assert meld is not None + assert tiles_to_string(meld.tiles) == "777z" + + # but we don't need to open hand for atodzuke here + tile = string_to_136_tile(pin="7") + meld, _ = table.player.try_to_call_meld(tile, True) + assert meld is None + + +def test_open_hand_when_yakuhai_already_in_the_hand(): + # make sure yakuhai strategy is activated by adding 3 doras to the hand + table = make_open_hand_v2_table() + player = table.player + table.add_dora_indicator(string_to_136_tile(honors="5")) + + tiles = string_to_136_array(man="46", pin="4679", sou="1348", honors="666") + player.init_hand(tiles) + + strategy = YakuhaiStrategy(BaseStrategy.YAKUHAI, player) + assert strategy.should_activate_strategy(player.tiles) is True + + tile = string_to_136_tile(sou="2") + meld, _ = player.try_to_call_meld(tile, True) + assert meld is not None + + +def test_always_open_double_east_wind(): + table = make_open_hand_v2_table() + tiles = string_to_136_array(man="59", sou="1235", pin="12788", honors="11") + table.player.init_hand(tiles) + + # player is is not east + table.player.dealer_seat = 2 + assert table.player.player_wind == WEST + + table.player.init_hand(tiles) + tile = string_to_136_tile(honors="1") + meld, _ = table.player.try_to_call_meld(tile, True) + assert meld is None + + # player is is east + table.player.dealer_seat = 0 + assert table.player.player_wind == EAST + + table.player.init_hand(tiles) + tile = string_to_136_tile(honors="1") + meld, _ = table.player.try_to_call_meld(tile, True) + assert meld is not None + + +def test_open_double_south_wind(): + table = make_open_hand_v2_table() + tiles = string_to_136_array(man="59", sou="1235", pin="12788", honors="22") + table.player.init_hand(tiles) + + tile = string_to_136_tile(honors="2") + meld, _ = table.player.try_to_call_meld(tile, True) + assert meld is None + + # player is south and round is south + table.round_wind_number = 5 + table.player.dealer_seat = 3 + assert table.player.player_wind == SOUTH + + table.player.init_hand(tiles) + tile = string_to_136_tile(honors="2") + meld, _ = table.player.try_to_call_meld(tile, True) + assert meld is None + + # add dora in the hand and after that we can open a hand + table.dora_indicators.append(string_to_136_tile(pin="6")) + + table.player.init_hand(tiles) + tile = string_to_136_tile(honors="2") + meld, _ = table.player.try_to_call_meld(tile, True) + assert meld is not None + + +def test_keep_yakuhai_in_closed_hand(): + table = make_open_hand_v2_table() + tiles = string_to_136_array(man="14", sou="15", pin="113347", honors="777") + table.player.init_hand(tiles) + + tile = string_to_136_tile(honors="3") + table.player.draw_tile(tile) + + discard, _ = table.player.discard_tile() + assert tiles_to_string([discard]) != "7z" + + +def test_keep_only_yakuhai_pon(): + # make sure yakuhai strategy is activated by adding 3 doras to the hand + table = make_open_hand_v2_table() + player = table.player + table.add_dora_indicator(string_to_136_tile(man="9")) + table.add_dora_indicator(string_to_136_tile(man="3")) + + tiles = string_to_136_array(man="11144", sou="567", pin="56", honors="777") + player.init_hand(tiles) + + meld = make_meld(MeldPrint.PON, man="111") + player.add_called_meld(meld) + + strategy = YakuhaiStrategy(BaseStrategy.YAKUHAI, player) + assert strategy.should_activate_strategy(player.tiles) is True + + player.draw_tile(string_to_136_tile(man="4")) + discarded_tile, _ = player.discard_tile() + assert tiles_to_string([discarded_tile]) != "7z" + + +def test_keep_only_yakuhai_pair(): + # make sure yakuhai strategy is activated by adding 3 doras to the hand + table = make_open_hand_v2_table() + player = table.player + table.add_dora_indicator(string_to_136_tile(man="9")) + table.add_dora_indicator(string_to_136_tile(man="3")) + + table.add_discarded_tile(1, string_to_136_tile(honors="7"), False) + + tiles = string_to_136_array(man="11144", sou="567", pin="156", honors="77") + player.init_hand(tiles) + + meld = make_meld(MeldPrint.PON, man="111") + player.add_called_meld(meld) + + strategy = YakuhaiStrategy(BaseStrategy.YAKUHAI, player) + assert strategy.should_activate_strategy(player.tiles) is True + + player.draw_tile(string_to_136_tile(pin="1")) + discarded_tile, _ = player.discard_tile() + assert tiles_to_string([discarded_tile]) != "7z" + + +def test_atodzuke_keep_yakuhai_wait(): + # make sure yakuhai strategy is activated by adding 3 doras to the hand + table = make_open_hand_v2_table() + player = table.player + table.add_dora_indicator(string_to_136_tile(man="9")) + + tiles = string_to_136_array(man="11144", sou="567", pin="567", honors="77") + player.init_hand(tiles) + + meld = make_meld(MeldPrint.PON, man="111") + player.add_called_meld(meld) + + # two of 4 man tiles are already out, so it would seem our wait is worse, but we know + # we must keep two pairs in order to be atodzuke tempai + table.add_discarded_tile(1, string_to_136_tile(man="4"), False) + table.add_discarded_tile(1, string_to_136_tile(man="4"), False) + + strategy = YakuhaiStrategy(BaseStrategy.YAKUHAI, player) + assert strategy.should_activate_strategy(player.tiles) is True + + player.draw_tile(string_to_136_tile(man="2")) + discarded_tile, _ = player.discard_tile() + assert tiles_to_string([discarded_tile]) == "2m" + + +def test_atodzuke_dont_destroy_second_pair(): + # make sure yakuhai strategy is activated by adding 3 doras to the hand + table = make_open_hand_v2_table() + player = table.player + table.add_dora_indicator(string_to_136_tile(man="9")) + + tiles = string_to_136_array(man="111445", sou="468", pin="56", honors="77") + player.init_hand(tiles) + + meld = make_meld(MeldPrint.PON, man="111") + player.add_called_meld(meld) + + strategy = YakuhaiStrategy(BaseStrategy.YAKUHAI, player) + assert strategy.should_activate_strategy(player.tiles) is True + + # 6 man is bad meld, we lose our second pair and so is 4 man + tile = string_to_136_tile(man="6") + meld, _ = player.try_to_call_meld(tile, True) + assert meld is None + + tile = string_to_136_tile(man="4") + meld, _ = player.try_to_call_meld(tile, True) + assert meld is None + + # but if we have backup pair it's ok + tiles = string_to_136_array(man="111445", sou="468", pin="88", honors="77") + player.init_hand(tiles) + + meld = make_meld(MeldPrint.PON, man="111") + player.add_called_meld(meld) + + strategy = YakuhaiStrategy(BaseStrategy.YAKUHAI, player) + assert strategy.should_activate_strategy(player.tiles) is True + + # 6 man is bad meld, we lose our second pair and so is 4 man + tile = string_to_136_tile(man="6") + meld, _ = player.try_to_call_meld(tile, True) + assert meld is not None + + tile = string_to_136_tile(man="4") + meld, _ = player.try_to_call_meld(tile, True) + assert meld is not None + + +def test_atodzuke_dont_open_no_yaku_tempai(): + # make sure yakuhai strategy is activated by adding 3 doras to the hand + table = make_open_hand_v2_table() + player = table.player + table.add_dora_indicator(string_to_136_tile(man="9")) + + tiles = string_to_136_array(man="111445", sou="567", pin="56", honors="77") + player.init_hand(tiles) + + meld = make_meld(MeldPrint.PON, man="111") + player.add_called_meld(meld) + + # 6 man is bad meld, we lose our second pair and so is 4 man + tile = string_to_136_tile(man="6") + meld, _ = player.try_to_call_meld(tile, True) + assert meld is None + + strategy = YakuhaiStrategy(BaseStrategy.YAKUHAI, player) + assert strategy.should_activate_strategy(player.tiles) is True + + tile = string_to_136_tile(man="4") + meld, _ = player.try_to_call_meld(tile, True) + assert meld is None + + strategy = YakuhaiStrategy(BaseStrategy.YAKUHAI, player) + assert strategy.should_activate_strategy(player.tiles) is True + + # 7 pin is a good meld, we get to tempai keeping yakuhai wait + tile = string_to_136_tile(pin="7") + meld, _ = player.try_to_call_meld(tile, True) + assert meld is not None + + strategy = YakuhaiStrategy(BaseStrategy.YAKUHAI, player) + assert strategy.should_activate_strategy(player.tiles) is True + + +def test_atodzuke_choose_hidden_syanpon(): + # make sure yakuhai strategy is activated by adding 3 doras to the hand + table = make_open_hand_v2_table() + player = table.player + table.add_dora_indicator(string_to_136_tile(man="9")) + + tiles = string_to_136_array(man="111678", sou="56678", honors="77") + player.init_hand(tiles) + + meld = make_meld(MeldPrint.PON, man="111") + player.add_called_meld(meld) + + strategy = YakuhaiStrategy(BaseStrategy.YAKUHAI, player) + assert strategy.should_activate_strategy(player.tiles) is True + + for _ in range(0, 4): + table.add_discarded_tile(1, string_to_136_tile(sou="9"), False) + + player.draw_tile(string_to_136_tile(sou="6")) + discarded_tile, _ = player.discard_tile() + assert tiles_to_string([discarded_tile]) != "6s" + assert tiles_to_string([discarded_tile]) == "5s" or tiles_to_string([discarded_tile]) == "8s" + + +def test_tempai_with_open_yakuhai_meld_and_yakuhai_pair_in_the_hand(): + """ + there was a bug where bot didn't handle tempai properly + with opened yakuhai pon and pair in the hand + 56m555p6678s55z + [777z] + """ + table = make_open_hand_v2_table() + player = table.player + + tiles = string_to_136_array(man="56", pin="555", sou="667", honors="55777") + player.init_hand(tiles) + player.add_called_meld(make_meld(MeldPrint.PON, honors="777")) + player.draw_tile(string_to_136_tile(sou="8")) + + player.ai.open_hand_handler.current_strategy = YakuhaiStrategy(BaseStrategy.YAKUHAI, player) + + discarded_tile, _ = player.discard_tile() + assert tiles_to_string([discarded_tile]) == "6s" + + +def test_tempai_with_closed_kan(): + """ + there was a bug where bot didn't handle tempai properly + with closed kan which was viewed as open one and thus open hand + """ + table = make_open_hand_v2_table() + player = table.player + + tiles = string_to_136_array(man="56", pin="4444", sou="223789", honors="55") + player.init_hand(tiles) + player.table.add_called_meld(player.seat, make_meld(MeldPrint.KAN, False, pin="4444")) + player.draw_tile(string_to_136_tile(sou="1")) + + discarded_tile, _ = player.discard_tile() + assert tiles_to_string([discarded_tile]) == "2s" diff --git a/project/game/ai/strategies_v2/yakuhai.py b/project/game/ai/strategies_v2/yakuhai.py new file mode 100644 index 00000000..2b5a356e --- /dev/null +++ b/project/game/ai/strategies_v2/yakuhai.py @@ -0,0 +1,287 @@ +import utils.decisions_constants as log +from game.ai.strategies_v2.main import BaseStrategy +from mahjong.constants import EAST, SOUTH +from mahjong.tile import TilesConverter +from utils.decisions_logger import MeldPrint + + +class YakuhaiStrategy(BaseStrategy): + valued_pairs = None + has_valued_anko = None + + def __init__(self, strategy_type, player): + super().__init__(strategy_type, player) + + self.valued_pairs = [] + self.valued_anko = [] + self.has_valued_anko = False + self.last_chance_calls = [] + + def get_open_hand_han(self): + # kinda rough estimation + return len(self.valued_anko) + len(self.valued_pairs) + + def should_activate_strategy(self, tiles_136, meld_tile=None): + """ + We can go for yakuhai strategy if we have at least one yakuhai pair in the hand + :return: boolean + """ + result = super(YakuhaiStrategy, self).should_activate_strategy(tiles_136) + if not result: + return False + + tiles_34 = TilesConverter.to_34_array(tiles_136) + player_hand_tiles_34 = TilesConverter.to_34_array(self.player.tiles) + player_closed_hand_tiles_34 = TilesConverter.to_34_array(self.player.closed_hand) + self.valued_pairs = [x for x in self.player.valued_honors if player_hand_tiles_34[x] == 2] + + is_double_east_wind = len([x for x in self.valued_pairs if x == EAST]) == 2 + is_double_south_wind = len([x for x in self.valued_pairs if x == SOUTH]) == 2 + + self.valued_pairs = list(set(self.valued_pairs)) + self.valued_anko = [x for x in self.player.valued_honors if player_hand_tiles_34[x] >= 3] + self.has_valued_anko = len(self.valued_anko) >= 1 + + opportunity_to_meld_yakuhai = False + + for x in range(0, 34): + if x in self.valued_pairs and tiles_34[x] - player_hand_tiles_34[x] == 1: + opportunity_to_meld_yakuhai = True + + has_valued_pair = False + + for pair in self.valued_pairs: + # we have valued pair in the hand and there are enough tiles + # in the wall + if ( + opportunity_to_meld_yakuhai + or self.player.number_of_revealed_tiles(pair, player_closed_hand_tiles_34) < 4 + ): + has_valued_pair = True + break + + # we don't have valuable pair or pon to open our hand + if not has_valued_pair and not self.has_valued_anko: + return False + + # let's always open double east + if is_double_east_wind: + return True + + # let's open double south if we have a dora in the hand + # or we have other valuable pairs + if is_double_south_wind and (self.dora_count_total >= 1 or len(self.valued_pairs) >= 2): + return True + + # there are 2+ valuable pairs let's open hand + if len(self.valued_pairs) >= 2: + # if we are dealer let's open hand + if self.player.is_dealer: + return True + + # if we have 1+ dora in the hand it is fine to open yakuhai + if self.dora_count_total >= 1: + return True + + # If we have 2+ dora in the hand let's open hand + if self.dora_count_total >= 2: + for x in range(0, 34): + # we have other pair in the hand + # so we can open hand for atodzuke + if player_hand_tiles_34[x] >= 2 and x not in self.valued_pairs: + self.go_for_atodzuke = True + return True + + # If we have 1+ dora in the hand and there is 5+ round step let's open hand + if self.dora_count_total >= 1 and self.player.round_step > 5: + return True + + for pair in self.valued_pairs: + # last chance to get that yakuhai, let's go for it + if ( + opportunity_to_meld_yakuhai + and self.player.number_of_revealed_tiles(pair, player_closed_hand_tiles_34) == 3 + and self.player.ai.shanten >= 1 + ): + + if pair not in self.last_chance_calls: + self.last_chance_calls.append(pair) + + return True + + # finally check if we need a cheap hand in oorasu - so don't skip first yakujai + if self.player.ai.placement.is_oorasu and opportunity_to_meld_yakuhai: + placement = self.player.ai.placement.get_current_placement() + logger_context = { + "placement": placement, + } + + if placement and placement["place"] == 4: + enough_cost = self.player.ai.placement.get_minimal_cost_needed_considering_west() + simple_han_scale = [0, 1000, 2000, 3900, 7700, 8000, 12000, 12000] + num_han = self.get_open_hand_han() + self.dora_count_total + if num_han >= len(simple_han_scale): + # why are we even here? + self.player.logger.debug( + log.PLACEMENT_MELD_DECISION, + "We are 4th in oorasu and have expensive hand, call meld", + logger_context, + ) + return True + + # be pessimistic and don't count on direct ron + hand_cost = simple_han_scale[num_han] + if hand_cost >= enough_cost: + self.player.logger.debug( + log.PLACEMENT_MELD_DECISION, + "We are 4th in oorasu and our hand can give us 3rd with meld, take it", + logger_context, + ) + return True + + if ( + placement + and placement["place"] == 3 + and placement["diff_with_4th"] < self.player.ai.placement.comfortable_diff + ): + self.player.logger.debug( + log.PLACEMENT_MELD_DECISION, "We are 3rd in oorasu and want to secure it, take meld", logger_context + ) + return True + + return False + + def determine_what_to_discard(self, discard_options, hand, open_melds): + is_open_hand = self.player.is_open_hand + + tiles_34 = TilesConverter.to_34_array(hand) + + valued_pairs = [x for x in self.player.valued_honors if tiles_34[x] == 2] + + # closed pon sets + valued_pons = [x for x in self.player.valued_honors if tiles_34[x] == 3] + # open pon sets + valued_pons += [ + x for x in open_melds if x.type == MeldPrint.PON and x.tiles[0] // 4 in self.player.valued_honors + ] + + acceptable_options = [] + for item in discard_options: + if is_open_hand: + if len(valued_pons) == 0: + # don't destroy our only yakuhai pair + if len(valued_pairs) == 1 and item.tile_to_discard_34 in valued_pairs: + continue + elif len(valued_pons) == 1: + # don't destroy our only yakuhai pon + if item.tile_to_discard_34 in valued_pons: + continue + + acceptable_options.append(item) + + # we don't have a choice + if not acceptable_options: + return discard_options + + preferred_options = [] + for item in acceptable_options: + # ignore wait without yakuhai yaku if possible + if is_open_hand and len(valued_pons) == 0 and len(valued_pairs) == 1: + if item.shanten == 0 and valued_pairs[0] not in item.waiting: + continue + + preferred_options.append(item) + + if not preferred_options: + return acceptable_options + + return preferred_options + + def is_tile_suitable(self, tile): + """ + For yakuhai we don't have any limits + :param tile: 136 tiles format + :return: True + """ + return True + + def meld_had_to_be_called(self, tile): + tile //= 4 + tiles_34 = TilesConverter.to_34_array(self.player.tiles) + valued_pairs = [x for x in self.player.valued_honors if tiles_34[x] == 2] + + # for big shanten number we don't need to check already opened pon set, + # because it will improve our hand anyway + if self.player.ai.shanten < 2: + for meld in self.player.melds: + # we have already opened yakuhai pon + # so we don't need to open hand without shanten improvement + if self._is_yakuhai_pon(meld): + return False + + # if we don't have any yakuhai pon and this is our last chance, we must call this tile + if tile in self.last_chance_calls: + return True + + # in all other cases for closed hand we don't need to open hand with special conditions + if not self.player.is_open_hand: + return False + + # we have opened the hand already and don't yet have yakuhai pon + # so we now must get it + for valued_pair in valued_pairs: + if valued_pair == tile: + return True + + return False + + def try_to_call_meld(self, tile, is_kamicha_discard, tiles_136): + if self.has_valued_anko: + return super(YakuhaiStrategy, self).try_to_call_meld(tile, is_kamicha_discard, tiles_136) + + tile_34 = tile // 4 + # we will open hand for atodzuke only in the special cases + if not self.player.is_open_hand and tile_34 not in self.valued_pairs: + if self.go_for_atodzuke: + return super(YakuhaiStrategy, self).try_to_call_meld(tile, is_kamicha_discard, tiles_136) + + return None, None + + return super(YakuhaiStrategy, self).try_to_call_meld(tile, is_kamicha_discard, tiles_136) + + def validate_meld(self, chosen_meld_dict): + # choose if base method requires us to keep hand closed + if not super(YakuhaiStrategy, self).validate_meld(chosen_meld_dict): + return False + + closed_tiles_34 = TilesConverter.to_34_array(self.player.closed_hand) + pairs_before_meld = len([x for x in closed_tiles_34 if x == 2]) + valued_pairs_before_meld = len([x for x in self.player.valued_honors if closed_tiles_34[x] == 2]) + # we don't have valued pairs to keep + if not valued_pairs_before_meld: + return True + + # it is fine to destroy pairs if we have plenty of them + if pairs_before_meld > 2: + return True + + closed_tiles_34 = TilesConverter.to_34_array(chosen_meld_dict["closed_hand_tiles_after_meld"]) + pairs_after_meld = len([x for x in closed_tiles_34 if x == 2]) + valued_pairs_after_meld = len([x for x in self.player.valued_honors if closed_tiles_34[x] == 2]) + + # condition to prevent calling from form 344m 77z on 4m + if pairs_after_meld < pairs_before_meld and valued_pairs_before_meld == valued_pairs_after_meld: + self.player.logger.debug( + log.MELD_DEBUG, + "Yakuhai: let's skip meld that destroying our pair", + { + "pairs_after_meld": pairs_after_meld, + "pairs_before_meld": pairs_before_meld, + }, + ) + return False + + return True + + def _is_yakuhai_pon(self, meld): + return meld.type == MeldPrint.PON and meld.tiles[0] // 4 in self.player.valued_honors