def test_shanten_number_and_chiitoitsu(self): shanten = Shanten() tiles = self._string_to_34_array(sou="114477", pin="114477", man="77") self.assertEqual(shanten.calculate_shanten_for_chiitoitsu_hand(tiles), Shanten.AGARI_STATE) tiles = self._string_to_34_array(sou="114477", pin="114477", man="76") self.assertEqual(shanten.calculate_shanten_for_chiitoitsu_hand(tiles), 0) tiles = self._string_to_34_array(sou="114477", pin="114479", man="76") self.assertEqual(shanten.calculate_shanten_for_chiitoitsu_hand(tiles), 1) tiles = self._string_to_34_array(sou="114477", pin="14479", man="76", honors="1") self.assertEqual(shanten.calculate_shanten_for_chiitoitsu_hand(tiles), 2) tiles = self._string_to_34_array(sou="114477", pin="13479", man="76", honors="1") self.assertEqual(shanten.calculate_shanten_for_chiitoitsu_hand(tiles), 3) tiles = self._string_to_34_array(sou="114467", pin="13479", man="76", honors="1") self.assertEqual(shanten.calculate_shanten_for_chiitoitsu_hand(tiles), 4) tiles = self._string_to_34_array(sou="114367", pin="13479", man="76", honors="1") self.assertEqual(shanten.calculate_shanten_for_chiitoitsu_hand(tiles), 5) tiles = self._string_to_34_array(sou="124367", pin="13479", man="76", honors="1") self.assertEqual(shanten.calculate_shanten_for_chiitoitsu_hand(tiles), 6)
class MahjongAI: version = "0.5.1" agari = None shanten_calculator = None defence = None riichi = None hand_divider = None finished_hand = None shanten = 7 ukeire = 0 ukeire_second = 0 waiting = None current_strategy = None last_discard_option = None hand_cache_shanten = {} hand_cache_estimation = {} def __init__(self, player): self.player = player self.table = player.table self.kan = Kan(player) self.agari = Agari() self.shanten_calculator = Shanten() self.defence = TileDangerHandler(player) self.riichi = Riichi(player) self.hand_divider = HandDivider() self.finished_hand = HandCalculator() self.hand_builder = HandBuilder(player, self) self.placement = player.config.PLACEMENT_HANDLER_CLASS(player) self.suji = Suji(player) self.kabe = Kabe(player) self.erase_state() def erase_state(self): self.shanten = 7 self.ukeire = 0 self.ukeire_second = 0 self.waiting = None self.current_strategy = None self.last_discard_option = None self.hand_cache_shanten = {} self.hand_cache_estimation = {} # to erase hand cache self.finished_hand = HandCalculator() def init_hand(self): self.player.logger.debug( log.INIT_HAND, context=[ f"Round wind: {DISPLAY_WINDS[self.table.round_wind_tile]}", f"Player wind: {DISPLAY_WINDS[self.player.player_wind]}", f"Hand: {self.player.format_hand_for_print()}", ], ) self.shanten, _ = self.hand_builder.calculate_shanten_and_decide_hand_structure( TilesConverter.to_34_array(self.player.tiles)) def draw_tile(self, tile_136): if not self.player.in_riichi: self.determine_strategy(self.player.tiles) def discard_tile(self, discard_tile): # we called meld and we had discard tile that we wanted to discard if discard_tile is not None: if not self.last_discard_option: return discard_tile, False return self.hand_builder.process_discard_option( self.last_discard_option) return self.hand_builder.discard_tile() def try_to_call_meld(self, tile_136, is_kamicha_discard, meld_type=None): 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.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 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 = [] if self.player.table.has_open_tanyao: strategies.append(TanyaoStrategy(BaseStrategy.TANYAO, self.player)) strategies.append(YakuhaiStrategy(BaseStrategy.YAKUHAI, self.player)) strategies.append(HonitsuStrategy(BaseStrategy.HONITSU, self.player)) strategies.append(ChinitsuStrategy(BaseStrategy.CHINITSU, self.player)) 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 estimate_hand_value_or_get_from_cache(self, win_tile_34, tiles=None, call_riichi=False, is_tsumo=False, is_rinshan=False, is_chankan=False): win_tile_136 = win_tile_34 * 4 # we don't need to think, that our waiting is aka dora if win_tile_136 in AKA_DORA_LIST: win_tile_136 += 1 if not tiles: tiles = self.player.tiles[:] else: tiles = tiles[:] tiles += [win_tile_136] config = HandConfig( is_riichi=call_riichi, player_wind=self.player.player_wind, round_wind=self.player.table.round_wind_tile, is_tsumo=is_tsumo, is_rinshan=is_rinshan, is_chankan=is_chankan, options=OptionalRules( has_aka_dora=self.player.table.has_aka_dora, has_open_tanyao=self.player.table.has_open_tanyao, has_double_yakuman=False, ), tsumi_number=self.player.table.count_of_honba_sticks, kyoutaku_number=self.player.table.count_of_riichi_sticks, ) return self._estimate_hand_value_or_get_from_cache( win_tile_136, tiles, call_riichi, is_tsumo, 0, config, is_rinshan, is_chankan) def calculate_exact_hand_value_or_get_from_cache( self, win_tile_136, tiles=None, call_riichi=False, is_tsumo=False, is_chankan=False, is_haitei=False, is_ippatsu=False, ): if not tiles: tiles = self.player.tiles[:] else: tiles = tiles[:] tiles += [win_tile_136] additional_han = 0 if is_chankan: additional_han += 1 if is_haitei: additional_han += 1 if is_ippatsu: additional_han += 1 config = HandConfig( is_riichi=call_riichi, player_wind=self.player.player_wind, round_wind=self.player.table.round_wind_tile, is_tsumo=is_tsumo, options=OptionalRules( has_aka_dora=self.player.table.has_aka_dora, has_open_tanyao=self.player.table.has_open_tanyao, has_double_yakuman=False, ), is_chankan=is_chankan, is_ippatsu=is_ippatsu, is_haitei=is_tsumo and is_haitei or False, is_houtei=(not is_tsumo) and is_haitei or False, tsumi_number=self.player.table.count_of_honba_sticks, kyoutaku_number=self.player.table.count_of_riichi_sticks, ) return self._estimate_hand_value_or_get_from_cache( win_tile_136, tiles, call_riichi, is_tsumo, additional_han, config) def _estimate_hand_value_or_get_from_cache(self, win_tile_136, tiles, call_riichi, is_tsumo, additional_han, config, is_rinshan=False, is_chankan=False): cache_key = build_estimate_hand_value_cache_key( tiles, call_riichi, is_tsumo, self.player.melds, self.player.table.dora_indicators, self.player.table.count_of_riichi_sticks, self.player.table.count_of_honba_sticks, additional_han, is_rinshan, is_chankan, ) if self.hand_cache_estimation.get(cache_key): return self.hand_cache_estimation.get(cache_key) result = self.finished_hand.estimate_hand_value( tiles, win_tile_136, self.player.melds, self.player.table.dora_indicators, config, use_hand_divider_cache=True, ) self.hand_cache_estimation[cache_key] = result return result def estimate_weighted_mean_hand_value(self, discard_option): weighted_hand_cost = 0 number_of_tiles = 0 for waiting in discard_option.waiting: tiles = self.player.tiles[:] tiles.remove(discard_option.tile_to_discard_136) hand_cost = self.estimate_hand_value_or_get_from_cache( waiting, tiles=tiles, call_riichi=discard_option.with_riichi, is_tsumo=True) if not hand_cost.cost: continue weighted_hand_cost += (hand_cost.cost["main"] + 2 * hand_cost.cost["additional"] ) * discard_option.wait_to_ukeire[waiting] number_of_tiles += discard_option.wait_to_ukeire[waiting] cost = number_of_tiles and int( weighted_hand_cost / number_of_tiles) or 0 # we are karaten, or we don't have yaku # in that case let's add possible tempai cost if cost == 0 and self.player.round_step > 12: cost = 1000 if self.player.round_step > 15 and cost < 2500: cost = 2500 return cost def should_call_kyuushu_kyuuhai(self) -> bool: """ Kyuushu kyuuhai 「九種九牌」 (9 kinds of honor or terminal tiles) """ # TODO aim for kokushi return True def should_call_win(self, tile, is_tsumo, enemy_seat=None, is_chankan=False): # don't skip win in riichi if self.player.in_riichi: return True # currently we don't support win skipping for tsumo if is_tsumo: return True # fast path - check it first to not calculate hand cost cost_needed = self.placement.get_minimal_cost_needed() if cost_needed == 0: return True # 1 and not 0 because we call check for win this before updating remaining tiles is_hotei = self.player.table.count_of_remaining_tiles == 1 hand_response = self.calculate_exact_hand_value_or_get_from_cache( tile, tiles=self.player.tiles, call_riichi=self.player.in_riichi, is_tsumo=is_tsumo, is_chankan=is_chankan, is_haitei=is_hotei, ) assert hand_response is not None assert not hand_response.error, hand_response.error cost = hand_response.cost return self.placement.should_call_win(cost, is_tsumo, enemy_seat) def enemy_called_riichi(self, enemy_seat): """ After enemy riichi we had to check will we fold or not it is affect open hand decisions :return: """ pass def calculate_shanten_or_get_from_cache(self, closed_hand_34: List[int], use_chiitoitsu: bool): """ Sometimes we are calculating shanten for the same hand multiple times to save some resources let's cache previous calculations """ key = build_shanten_cache_key(closed_hand_34, use_chiitoitsu) if key in self.hand_cache_shanten: return self.hand_cache_shanten[key] if use_chiitoitsu and not self.player.is_open_hand: result = self.shanten_calculator.calculate_shanten_for_chiitoitsu_hand( closed_hand_34) else: result = self.shanten_calculator.calculate_shanten_for_regular_hand( closed_hand_34) self.hand_cache_shanten[key] = result return result @property def enemy_players(self): """ Return list of players except our bot """ return self.player.table.players[1:]
class Phoenix: def __init__(self, player): self.player = player self.table = player.table self.chi = Chi(player) self.pon = Pon(player) self.kan = Kan(player) self.riichi = Riichi(player) self.discard = Discard(player) self.grp = GlobalRewardPredictor() self.hand_builder = HandBuilder(player, self) self.shanten_calculator = Shanten() self.hand_cache_shanten = {} self.placement = player.config.PLACEMENT_HANDLER_CLASS(player) self.finished_hand = HandCalculator() self.hand_divider = HandDivider() self.erase_state() def erase_state(self): self.hand_cache_shanten = {} self.hand_cache_estimation = {} self.finished_hand = HandCalculator() self.grp_features = [] def collect_experience(self): #collect round info init_scores = np.array(self.table.init_scores) / 1e5 gains = np.array(self.table.gains) / 1e5 dans = np.array( [RANKS.index(p.rank) for p in self.player.table.players]) dealer = int(self.player.dealer_seat) repeat_dealer = self.player.table.count_of_honba_sticks riichi_bets = self.player.table.count_of_riichi_sticks features = np.concatenate( (init_scores, gains, dans, np.array([dealer, repeat_dealer, riichi_bets])), axis=0) self.grp_features.append(features) #prepare input grp_input = [np.zeros(pred_emb_dim)] * max( round_num - len(self.grp_features), 0) + self.grp_features[:] reward = self.grp.get_global_reward( np.expand_dims(np.asarray(grp_input), axis=0))[0][self.player.seat] for model in [self.chi, self.pon, self.kan, self.riichi, self.discard]: model.collector.complete_episode(reward) def write_buffer(self): for model in [self.chi, self.pon, self.kan, self.riichi, self.discard]: model.collector.to_buffer() def init_hand(self): self.player.logger.debug( log.INIT_HAND, context=[ f"Round wind: {DISPLAY_WINDS[self.table.round_wind_tile]}", f"Player wind: {DISPLAY_WINDS[self.player.player_wind]}", f"Hand: {self.player.format_hand_for_print()}", ], ) self.shanten, _ = self.hand_builder.calculate_shanten_and_decide_hand_structure( TilesConverter.to_34_array(self.player.tiles)) def draw_tile(self): pass def discard_tile(self, discard_tile): ''' return discarded_tile and with_riichi ''' if discard_tile is not None: #discard after meld return discard_tile, False if self.player.is_open_hand: #can not riichi return self.discard.discard_tile(), False shanten = self.calculate_shanten_or_get_from_cache( TilesConverter.to_34_array(self.player.closed_hand)) if shanten != 0: #can not riichi return self.discard.discard_tile(), False with_riichi, p = self.riichi.should_call_riichi() if with_riichi: # fix here: might need review riichi_options = [ tile for tile in self.player.closed_hand if self.calculate_shanten_or_get_from_cache( TilesConverter.to_34_array( [t for t in self.player.closed_hand if t != tile])) == 0 ] tile_to_discard = self.discard.discard_tile( with_riichi_options=riichi_options) else: tile_to_discard = self.discard.discard_tile() return tile_to_discard, with_riichi def try_to_call_meld(self, tile_136, is_kamicha_discard, meld_type): # 1 pon # 2 kan (it is a closed kan and can be send only to the self draw) # 4 chi # there is two return value, meldPrint() and discardOption(), # while the second would not be used by client.py meld_chi, meld_pon = None, None should_chi, should_pon = False, False # print(tile_136) # print(self.player.closed_hand) melds_chi, melds_pon = self.get_possible_meld(tile_136, is_kamicha_discard) if melds_chi and meld_type & 4: should_chi, chi_score, tiles_chi = self.chi.should_call_chi( tile_136, melds_chi) # fix here: tiles_chi is now the first possible meld ---fixed! # tiles_chi = melds_chi[0] meld_chi = Meld(meld_type="chi", tiles=tiles_chi) if meld_chi else None if melds_pon and meld_type & 1: should_pon, pon_score = self.pon.should_call_pon( tile_136, is_kamicha_discard) tiles_pon = melds_pon[0] meld_pon = Meld(meld_type="pon", tiles=tiles_pon) if meld_pon else None if not should_chi and not should_pon: return None, None if should_chi and should_pon: meld = meld_chi if chi_score > pon_score else meld_pon elif should_chi: meld = meld_chi else: meld = meld_pon all_tiles_copy, meld_tiles_copy = self.player.tiles[:], self.player.meld_tiles[:] all_tiles_copy.append(tile_136) meld_tiles_copy.append(meld) closed_hand_copy = [ item for item in all_tiles_copy if item not in meld_tiles_copy ] discard_option = self.discard.discard_tile( all_hands_136=all_tiles_copy, closed_hands_136=closed_hand_copy) return meld, discard_option def should_call_kyuushu_kyuuhai(self): #try kokushi strategy if with 10 types tiles_34 = TilesConverter.to_34_array(self.player.tiles) types = sum([1 for t in tiles_34 if t > 0]) if types >= 10: return False else: return True def should_call_win(self, tile, is_tsumo, enemy_seat=None, is_chankan=False): # don't skip win in riichi if self.player.in_riichi: return True # currently we don't support win skipping for tsumo if is_tsumo: return True # fast path - check it first to not calculate hand cost cost_needed = self.placement.get_minimal_cost_needed() if cost_needed == 0: return True # 1 and not 0 because we call check for win this before updating remaining tiles is_hotei = self.player.table.count_of_remaining_tiles == 1 hand_response = self.calculate_exact_hand_value_or_get_from_cache( tile, tiles=self.player.tiles, call_riichi=self.player.in_riichi, is_tsumo=is_tsumo, is_chankan=is_chankan, is_haitei=is_hotei, ) assert hand_response is not None assert not hand_response.error, hand_response.error cost = hand_response.cost return self.placement.should_call_win(cost, is_tsumo, enemy_seat) def calculate_shanten_or_get_from_cache(self, closed_hand_34: List[int], use_chiitoitsu=True): """ Sometimes we are calculating shanten for the same hand multiple times to save some resources let's cache previous calculations """ key = build_shanten_cache_key(closed_hand_34, use_chiitoitsu) if key in self.hand_cache_shanten: return self.hand_cache_shanten[key] # if use_chiitoitsu and not self.player.is_open_hand: # result = self.shanten_calculator.calculate_shanten_for_chiitoitsu_hand(closed_hand_34) # else: # result = self.shanten_calculator.calculate_shanten_for_regular_hand(closed_hand_34) # fix here: a little bit strange in use_chiitoitsu shanten_results = [] if use_chiitoitsu and not self.player.is_open_hand: shanten_results.append( self.shanten_calculator.calculate_shanten_for_chiitoitsu_hand( closed_hand_34)) shanten_results.append( self.shanten_calculator.calculate_shanten_for_regular_hand( closed_hand_34)) result = min(shanten_results) self.hand_cache_shanten[key] = result return result def calculate_exact_hand_value_or_get_from_cache( self, win_tile_136, tiles=None, call_riichi=False, is_tsumo=False, is_chankan=False, is_haitei=False, is_ippatsu=False, ): if not tiles: tiles = self.player.tiles[:] else: tiles = tiles[:] if win_tile_136 not in tiles: tiles += [win_tile_136] additional_han = 0 if is_chankan: additional_han += 1 if is_haitei: additional_han += 1 if is_ippatsu: additional_han += 1 config = HandConfig( is_riichi=call_riichi, player_wind=self.player.player_wind, round_wind=self.player.table.round_wind_tile, is_tsumo=is_tsumo, options=OptionalRules( has_aka_dora=self.player.table.has_aka_dora, has_open_tanyao=self.player.table.has_open_tanyao, has_double_yakuman=False, ), is_chankan=is_chankan, is_ippatsu=is_ippatsu, is_haitei=is_tsumo and is_haitei or False, is_houtei=(not is_tsumo) and is_haitei or False, tsumi_number=self.player.table.count_of_honba_sticks, kyoutaku_number=self.player.table.count_of_riichi_sticks, ) return self._estimate_hand_value_or_get_from_cache( win_tile_136, tiles, call_riichi, is_tsumo, additional_han, config) def _estimate_hand_value_or_get_from_cache(self, win_tile_136, tiles, call_riichi, is_tsumo, additional_han, config, is_rinshan=False, is_chankan=False): cache_key = build_estimate_hand_value_cache_key( tiles, call_riichi, is_tsumo, self.player.melds, self.player.table.dora_indicators, self.player.table.count_of_riichi_sticks, self.player.table.count_of_honba_sticks, additional_han, is_rinshan, is_chankan, ) if self.hand_cache_estimation.get(cache_key): return self.hand_cache_estimation.get(cache_key) result = self.finished_hand.estimate_hand_value( tiles, win_tile_136, self.player.melds, self.player.table.dora_indicators, config, use_hand_divider_cache=True, ) self.hand_cache_estimation[cache_key] = result return result @property def enemy_players(self): """ Return list of players except our bot """ return self.player.table.players[1:] def enemy_called_riichi(self, enemy_seat): """ After enemy riichi we had to check will we fold or not it is affect open hand decisions :return: """ pass def get_possible_meld(self, tile, is_kamicha_discard): closed_hand = self.player.closed_hand[:] # we can't open hand anymore if len(closed_hand) == 1: 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.hand_divider.find_valid_combinations( closed_hand_34, first_limit, second_limit, True) if combinations: combinations = combinations[0] # possible_melds = [] melds_chi, melds_pon = [], [] 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 melds_pon: melds_pon.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 melds_chi: melds_chi.append(best_meld_34) return melds_chi, melds_pon def enemy_called_riichi(self, enemy_seat): """ After enemy riichi we had to check will we fold or not it is affect open hand decisions :return: """ pass