def _check_discard_order(self, suit, early_position): # let's check the following considiton: # if enemy had discarded tiles from that suit or honor and after that he had discarded a tile from a different # suit from his hand - let's believe it's not honitsu suit_discards_positions = [ self.enemy.discards.index(x) for x in self.enemy.discards if suit["function"](x.value // 4) ] if suit_discards_positions: # we consider second discard of chosen suit to be reference point # first one could have happened when player was not yet sure if he is going to honitsu # after the second one there should be no discars of other suit from hand reference_discard = suit_discards_positions[min( 1, len(suit_discards_positions) - 1)] discards_after = self.enemy.discards[reference_discard:] if discards_after: has_discarded_other_suit_from_hand = [ x for x in discards_after if (not x.is_tsumogiri and not is_honor(x.value // 4) and not suit["function"](x.value // 4)) ] if has_discarded_other_suit_from_hand: return False # if we started discards suit tiles early, it's probably not honitsu if suit_discards_positions[0] <= early_position: return False # discard order seems similar to honitsu/chinitsu one return True
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) if is_aka_dora(tile_136, self.player.table.has_aka_dora): self.aka_dora_count += 1 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 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 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 calculate_value(self, shanten=None): # base is 100 for ability to mark tiles as not needed (like set value to 50) value = 100 honored_value = 20 # we don't need to keep honor tiles in almost completed hand if shanten and shanten <= 2: honored_value = 0 if is_honor(self.tile_to_discard): if self.tile_to_discard in self.player.valued_honors: count_of_winds = [ x for x in self.player.valued_honors if x == self.tile_to_discard ] # for west-west, east-east we had to double tile value value += honored_value * len(count_of_winds) else: # suits suit_tile_grades = [10, 20, 30, 40, 50, 40, 30, 20, 10] simplified_tile = simplify(self.tile_to_discard) value += suit_tile_grades[simplified_tile] count_of_dora = plus_dora(self.tile_to_discard * 4, self.player.table.dora_indicators) if is_aka_dora(self.tile_to_discard * 4, self.player.table.has_open_tanyao): count_of_dora += 1 value += 50 * count_of_dora if is_honor(self.tile_to_discard): # depends on how much honor tiles were discarded # we will decrease tile value discard_percentage = [100, 75, 20, 0, 0] discarded_tiles = self.player.table.revealed_tiles[ self.tile_to_discard] value = (value * discard_percentage[discarded_tiles]) / 100 # three honor tiles were discarded, # so we don't need this tile anymore if value == 0: self.had_to_be_discarded = True self.valuation = value
def player_can_call_kyuushu_kyuuhai(self, player): if len(player.discards) > 0 or len(player.melds) > 0: return False tiles_34 = [x // 4 for x in player.tiles] terminals_and_honors = [ x for x in tiles_34 if is_honor(x) or is_terminal(x) ] return len(list(set(terminals_and_honors))) >= 9
def _mark_safe_tiles_against_honitsu(self, player): against_honitsu = [] for tile in range(0, 34): if not self.closed_hand_34[tile]: continue if not player.chosen_suit(tile) and not is_honor(tile): against_honitsu.append(tile) return against_honitsu
def get_safe_tiles_34(self): safe_tiles = [] for x in range(0, 34): if not is_honor(x): safe_tiles.append(x) elif not self.enemy.valued_honors.count(x): safe_tiles.append(x) return safe_tiles
def get_safe_tiles_34(self): if not self.chosen_suit: return [] safe_tiles = [] for x in range(0, 34): if not self.chosen_suit(x) and not is_honor(x): safe_tiles.append(x) return safe_tiles
def _calculate_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 = tiles_34[x] if not tile: continue if not suit(x) and not is_honor(x): self.tiles_count_other_suits += tile if not is_tile_strictly_isolated(tiles_34, x): self.tiles_count_other_suits_not_isolated += 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 get_bonus_danger(self, tile_136, number_of_revealed_tiles): tile_34 = tile_136 // 4 if is_honor(tile_34): if number_of_revealed_tiles == 4: return [] elif number_of_revealed_tiles == 3: return [TileDanger.HONITSU_THIRD_HONOR_BONUS_DANGER] elif number_of_revealed_tiles == 2: return [TileDanger.HONITSU_SECOND_HONOR_BONUS_DANGER] else: return [TileDanger.HONITSU_SHONPAI_HONOR_BONUS_DANGER] return []
def test_is_threatening_and_atodzuke(): table = Table() threatening_players = table.player.ai.defence.get_threatening_players() assert len(threatening_players) == 0 table.add_dora_indicator(string_to_136_tile(honors="5")) enemy_seat = 2 table.add_called_meld(enemy_seat, make_meld(MeldPrint.CHI, man="234")) table.add_called_meld(enemy_seat, make_meld(MeldPrint.PON, sou="333")) table.add_called_meld(enemy_seat, make_meld(MeldPrint.KAN, pin="9999")) table.player.round_step = 5 table.add_discarded_tile(enemy_seat, string_to_136_tile(honors="1"), False) table.add_discarded_tile(enemy_seat, string_to_136_tile(honors="4"), False) table.add_discarded_tile(enemy_seat, string_to_136_tile(sou="8"), False) table.add_discarded_tile(enemy_seat, string_to_136_tile(pin="9"), False) table.add_discarded_tile(enemy_seat, string_to_136_tile(sou="1"), False) table.add_discarded_tile(enemy_seat, string_to_136_tile(man="6"), False) # atodzuke with 3 melds is a threat threatening_players = table.player.ai.defence.get_threatening_players() assert len(threatening_players) == 1 assert threatening_players[0].enemy.seat == enemy_seat assert threatening_players[0].threat_reason[ "id"] == EnemyDanger.THREAT_OPEN_HAND_UNKNOWN_COST["id"] assert threatening_players[0].get_assumed_hand_cost( string_to_136_tile(honors="5")) == 2000 assert threatening_players[0].get_assumed_hand_cost( string_to_136_tile(honors="6")) == 8000 for tile_136 in range(0, 136): bonus_danger = threatening_players[0].threat_reason.get( "active_yaku")[0].get_bonus_danger(tile_136, 1) if not is_honor(tile_136 // 4): assert not bonus_danger elif ((tile_136 // 4 == string_to_34_tile(honors="1")) or (tile_136 // 4 == string_to_34_tile(honors="3")) or (tile_136 // 4 == string_to_34_tile(honors="5")) or (tile_136 // 4 == string_to_34_tile(honors="6")) or (tile_136 // 4 == string_to_34_tile(honors="7"))): assert bonus_danger else: assert not bonus_danger
def test_is_threatening_and_honitsu_hand(): table = Table() table.add_dora_indicator(string_to_136_tile(pin="1")) threatening_players = table.player.ai.defence.get_threatening_players() assert len(threatening_players) == 0 enemy_seat = 1 table.add_called_meld(enemy_seat, make_meld(MeldPrint.PON, honors="444")) table.add_called_meld(enemy_seat, make_meld(MeldPrint.CHI, pin="123")) table.add_called_meld(enemy_seat, make_meld(MeldPrint.CHI, pin="345")) table.add_discarded_tile(enemy_seat, string_to_136_tile(sou="1"), False) table.add_discarded_tile(enemy_seat, string_to_136_tile(sou="5"), False) table.add_discarded_tile(enemy_seat, string_to_136_tile(sou="8"), False) table.add_discarded_tile(enemy_seat, string_to_136_tile(sou="9"), False) table.add_discarded_tile(enemy_seat, string_to_136_tile(man="1"), False) table.add_discarded_tile(enemy_seat, string_to_136_tile(man="1"), False) table.add_discarded_tile(enemy_seat, string_to_136_tile(pin="1"), False) threatening_players = table.player.ai.defence.get_threatening_players() assert len(threatening_players) == 1 assert threatening_players[0].threat_reason[ "id"] == EnemyDanger.THREAT_EXPENSIVE_OPEN_HAND["id"] assert threatening_players[0].get_assumed_hand_cost( string_to_136_tile(pin="4")) == 3900 assert threatening_players[0].get_assumed_hand_cost( string_to_136_tile(pin="2")) == 8000 assert HonitsuAnalyzer.id in [ x.id for x in threatening_players[0].threat_reason["active_yaku"] ] honitsu_analyzer = [ x for x in threatening_players[0].threat_reason["active_yaku"] if x.id == HonitsuAnalyzer.id ][0] for tile_136 in range(0, 136): bonus_danger = honitsu_analyzer.get_bonus_danger(tile_136, 1) if is_honor(tile_136 // 4): assert bonus_danger else: assert not bonus_danger
def _calculate_assumed_hand_cost(self, tile_136) -> int: tile_34 = tile_136 // 4 melds_han = self.get_melds_han(tile_34) if melds_han == 0: return 0 scale_index = melds_han scale_index += self.threat_reason.get("dora_count", 0) scale_index += self._get_dora_scale_bonus(tile_136) if self.enemy.is_dealer: scale = [ 1000, 2900, 5800, 12000, 12000, 18000, 18000, 24000, 24000, 24000, 36000, 36000, 48000 ] else: scale = [ 1000, 2000, 3900, 8000, 8000, 12000, 12000, 16000, 16000, 16000, 24000, 24000, 32000 ] # add more danger for kan sets (basically it is additional hand cost because of fu) for meld in self.enemy.melds: if meld.type != Meld.KAN and meld.type != Meld.SHOUMINKAN: continue if meld.opened: # enemy will get additional fu for opened honors or terminals kan if is_honor(meld.tiles[0] // 4) or is_terminal( meld.tiles[0] // 4): scale_index += 1 else: # enemy will get additional fu for closed kan scale_index += 1 if scale_index > len(scale) - 1: scale_index = len(scale) - 1 elif scale_index == 0: scale_index = 1 return scale[scale_index - 1]
def _get_dora_scale_bonus(self, tile_136): tile_34 = tile_136 // 4 scale_bonus = 0 dora_count = plus_dora(tile_136, self.table.dora_indicators, add_aka_dora=self.table.has_aka_dora) if is_honor(tile_34): closed_hand_34 = TilesConverter.to_34_array( self.main_player.closed_hand) revealed_tiles = self.main_player.number_of_revealed_tiles( tile_34, closed_hand_34) if revealed_tiles < 2: scale_bonus += dora_count * 3 else: scale_bonus += dora_count * 2 else: scale_bonus += dora_count return scale_bonus
def _simplified_danger_valuation(self, discard_option): tile_34 = discard_option.tile_to_discard_34 tile_136 = discard_option.tile_to_discard_136 number_of_revealed_tiles = self.player.number_of_revealed_tiles( tile_34, TilesConverter.to_34_array(self.player.closed_hand)) if is_honor(tile_34): if not self.player.table.is_common_yakuhai(tile_34): if number_of_revealed_tiles == 4: simple_danger = 0 elif number_of_revealed_tiles == 3: simple_danger = 10 elif number_of_revealed_tiles == 2: simple_danger = 20 else: simple_danger = 30 else: if number_of_revealed_tiles == 4: simple_danger = 0 elif number_of_revealed_tiles == 3: simple_danger = 11 elif number_of_revealed_tiles == 2: simple_danger = 21 else: simple_danger = 32 elif is_terminal(tile_34): simple_danger = 100 elif simplify(tile_34) < 2 or simplify(tile_34) > 6: # 2, 3 or 7, 8 simple_danger = 200 else: # 4, 5, 6 simple_danger = 300 if simple_danger != 0: simple_danger += plus_dora( tile_136, self.player.table.dora_indicators, add_aka_dora=self.player.table.has_aka_dora) return simple_danger
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 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 boxes_is_chi(boxes): items = sorted([t[0] for t in boxes[0:3]]) if is_honor(items[0]) or is_honor(items[2]): return False return is_chi(items)
def _get_early_danger_bonus(self, enemy_analyzer, tile_analyze_34, has_other_danger_bonus): discards = enemy_analyzer.enemy_discards_until_all_tsumogiri discards_34 = [x.value // 4 for x in discards] assert not is_honor(tile_analyze_34) # +1 here to make it easier to read tile_analyze_simplified = simplify(tile_analyze_34) + 1 # we only those border tiles if tile_analyze_simplified not in [1, 2, 8, 9]: return None # too early to make statements if len(discards_34) <= 5: return None central_discards_34 = [x for x in discards_34 if not is_honor(x) and not is_terminal(x)] # also too early to make statements if len(central_discards_34) <= 3: return None # we also want to check how many non-tsumogiri tiles there were after those discards latest_discards_34 = [x.value // 4 for x in discards if not x.is_tsumogiri][-3:] if len(latest_discards_34) != 3: return None # no more than 3, but we expect at least 3 non-central tiles after that one for pattern to matter num_early_discards = min(len(central_discards_34) - 3, 3) first_central_discards_34 = central_discards_34[:num_early_discards] patterns_config = [] if not has_other_danger_bonus: # patterns lowering danger has higher priority in case they are possible # +1 implied here to make it easier to read # order is important, as 28 priority pattern is higher than 37 one patterns_config.extend( [ { "pattern": 2, "danger": [1], "bonus": TileDanger.BONUS_EARLY_28, }, { "pattern": 8, "danger": [9], "bonus": TileDanger.BONUS_EARLY_28, }, { "pattern": 3, "danger": [1, 2], "bonus": TileDanger.BONUS_EARLY_37, }, { "pattern": 7, "danger": [8, 9], "bonus": TileDanger.BONUS_EARLY_37, }, ] ) # patterns increasing danger have lower priority, but are always applied patterns_config.extend( [ { "pattern": 5, "danger": [1, 9], "bonus": TileDanger.BONUS_EARLY_5, }, ] ) # we return the first pattern we see for enemy_discard_34 in first_central_discards_34: # being also discarded late from hand kinda ruins our previous logic, so don't modify danger in that case if enemy_discard_34 in latest_discards_34: continue if not is_tiles_same_suit(enemy_discard_34, tile_analyze_34): continue # +1 here to make it easier read matagi patterns enemy_discard_simplified = simplify(enemy_discard_34) + 1 for pattern_config in patterns_config: has_pattern = enemy_discard_simplified == pattern_config["pattern"] if not has_pattern: continue if tile_analyze_simplified in pattern_config["danger"]: return pattern_config["bonus"] return None
def calculate_tiles_danger( self, discard_candidates: List[DiscardOption], enemy_analyzer: EnemyAnalyzer ) -> List[DiscardOption]: closed_hand_34 = TilesConverter.to_34_array(self.player.closed_hand) safe_against_threat_34 = [] # First, add all genbutsu to the list safe_against_threat_34.extend(list(set([x for x in enemy_analyzer.enemy.all_safe_tiles]))) # Then add tiles not suitable for yaku in enemy open hand if enemy_analyzer.threat_reason.get("active_yaku"): safe_against_yaku = set.intersection( *[set(x.get_safe_tiles_34()) for x in enemy_analyzer.threat_reason.get("active_yaku")] ) if safe_against_yaku: safe_against_threat_34.extend(list(safe_against_yaku)) possible_forms = self.possible_forms_analyzer.calculate_possible_forms(enemy_analyzer.enemy.all_safe_tiles) kabe_tiles = self.player.ai.kabe.find_all_kabe(closed_hand_34) suji_tiles = self.player.ai.suji.find_suji([x.value for x in enemy_analyzer.enemy.discards]) for discard_option in discard_candidates: tile_34 = discard_option.tile_to_discard_34 tile_136 = discard_option.find_tile_in_hand(self.player.closed_hand) number_of_revealed_tiles = self.player.number_of_revealed_tiles(tile_34, closed_hand_34) # like 1-9 against tanyao etc. if tile_34 in safe_against_threat_34: self._update_discard_candidate( tile_34, discard_candidates, enemy_analyzer.enemy.seat, TileDanger.SAFE_AGAINST_THREATENING_HAND, ) continue # safe tiles that can be safe based on the table situation if self.total_possible_forms_for_tile(possible_forms, tile_34) == 0: self._update_discard_candidate( tile_34, discard_candidates, enemy_analyzer.enemy.seat, TileDanger.IMPOSSIBLE_WAIT, ) continue # honors if is_honor(tile_34): danger = self._process_danger_for_honor(enemy_analyzer, tile_34, number_of_revealed_tiles) # terminals elif is_terminal(tile_34): danger = self._process_danger_for_terminal_tiles_and_kabe_suji( enemy_analyzer, tile_34, number_of_revealed_tiles, kabe_tiles, suji_tiles ) # 2-8 tiles else: danger = self._process_danger_for_2_8_tiles_suji_and_kabe( enemy_analyzer, tile_34, number_of_revealed_tiles, suji_tiles, kabe_tiles ) if danger: self._update_discard_candidate( tile_34, discard_candidates, enemy_analyzer.enemy.seat, danger, ) forms_count = possible_forms[tile_34] self._update_discard_candidate( tile_34, discard_candidates, enemy_analyzer.enemy.seat, { "value": self.possible_forms_analyzer.calculate_possible_forms_danger(forms_count), "description": TileDanger.FORM_BONUS_DESCRIPTION, "forms_count": forms_count, }, ) # for ryanmen waits we also account for number of dangerous suji tiles forms_ryanmen_count = forms_count[PossibleFormsAnalyzer.POSSIBLE_RYANMEN_SIDES] if forms_ryanmen_count == 1: self._update_discard_candidate( tile_34, discard_candidates, enemy_analyzer.enemy.seat, TileDanger.RYANMEN_BASE_SINGLE, ) elif forms_ryanmen_count == 2: self._update_discard_candidate( tile_34, discard_candidates, enemy_analyzer.enemy.seat, TileDanger.RYANMEN_BASE_DOUBLE, ) if forms_ryanmen_count == 1 or forms_ryanmen_count == 2: has_matagi = self._is_matagi_suji(enemy_analyzer, tile_34) if has_matagi: self._update_discard_candidate( tile_34, discard_candidates, enemy_analyzer.enemy.seat, TileDanger.BONUS_MATAGI_SUJI, can_be_used_for_ryanmen=True, ) has_aidayonken = self.is_aidayonken_pattern(enemy_analyzer, tile_34) if has_aidayonken: self._update_discard_candidate( tile_34, discard_candidates, enemy_analyzer.enemy.seat, TileDanger.BONUS_AIDAYONKEN, can_be_used_for_ryanmen=True, ) early_danger_bonus = self._get_early_danger_bonus(enemy_analyzer, tile_34, has_matagi or has_aidayonken) if early_danger_bonus is not None: self._update_discard_candidate( tile_34, discard_candidates, enemy_analyzer.enemy.seat, early_danger_bonus, can_be_used_for_ryanmen=True, ) self._update_discard_candidate( tile_34, discard_candidates, enemy_analyzer.enemy.seat, TileDanger.make_unverified_suji_coeff(enemy_analyzer.unverified_suji_coeff), can_be_used_for_ryanmen=True, ) if is_dora_connector(tile_136, self.player.table.dora_indicators): self._update_discard_candidate( tile_34, discard_candidates, enemy_analyzer.enemy.seat, TileDanger.DORA_CONNECTOR_BONUS, can_be_used_for_ryanmen=True, ) dora_count = plus_dora( tile_136, self.player.table.dora_indicators, add_aka_dora=self.player.table.has_aka_dora ) if dora_count > 0: danger = copy(TileDanger.DORA_BONUS) danger["value"] = dora_count * danger["value"] danger["dora_count"] = dora_count self._update_discard_candidate( tile_34, discard_candidates, enemy_analyzer.enemy.seat, danger, ) if enemy_analyzer.threat_reason.get("active_yaku"): for yaku_analyzer in enemy_analyzer.threat_reason.get("active_yaku"): bonus_danger = yaku_analyzer.get_bonus_danger(tile_136, number_of_revealed_tiles) for danger in bonus_danger: self._update_discard_candidate( tile_34, discard_candidates, enemy_analyzer.enemy.seat, danger, ) return discard_candidates
def should_activate_strategy(self, tiles_136): """ 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_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']) self._calculate_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 non-isolated # tiles in other suits if self.tiles_count_other_suits >= 5: 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 = plus_dora(pon * 4, self.player.table.dora_indicators) if dora_count > 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]) # 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: # 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 8 step if self.player.round_step > 8: 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_yaku_active(self): # TODO: in some distant future we may want to analyze menhon as well if not self.enemy.melds: return False total_melds = len(self.enemy.melds) total_discards = len(self.enemy.discards) # let's check if there is too little info to analyze if total_discards < HonitsuAnalyzer.MIN_DISCARD and total_melds < HonitsuAnalyzer.MAX_MELDS: return False # first of all - check melds, they must be all from one suit or honors current_suit = None for meld in self.enemy.melds: tile = meld.tiles[0] tile_34 = tile // 4 if is_honor(tile_34): continue suit = ChinitsuAnalyzer._get_tile_suit(tile) if not current_suit: current_suit = suit elif suit["name"] != current_suit["name"]: return False # let's check discards discards = [x.value for x in self.enemy.discards] discards_34 = TilesConverter.to_34_array(discards) result = count_tiles_by_suits(discards_34) honors = [x for x in result if x["name"] == "honor"][0] suits = [x for x in result if x["name"] != "honor"] suits = sorted(suits, key=lambda x: x["count"], reverse=False) less_suit = suits[0] less_suit_tiles = less_suit["count"] percentage_of_less_suit = (less_suit_tiles / total_discards) * 100 percentage_of_honor_tiles = (honors["count"] / total_discards) * 100 # there is not too much one suit + honor tiles in the discard # so we can tell that user trying to collect honitsu if (percentage_of_less_suit <= HonitsuAnalyzer.LESS_SUIT_PERCENTAGE_BORDER and percentage_of_honor_tiles <= HonitsuAnalyzer.HONORS_PERCENTAGE_BORDER): if not current_suit: current_suit = less_suit elif current_suit != less_suit: return False # still cannot determine the suit - this is probably not honitsu if not current_suit: return False if not self._check_discard_order( current_suit, int(total_discards / HonitsuAnalyzer.EARLY_DISCARD_DIVISOR)): return False # all checks have passed - assume this is honitsu self.chosen_suit = current_suit["function"] return True
def is_yaku_active(self): # TODO: in some distant future we may want to analyze menchin as well if not self.enemy.melds: return False total_melds = len(self.enemy.melds) total_discards = len(self.enemy.discards) # let's check if there is too little info to analyze if total_discards < ChinitsuAnalyzer.MIN_DISCARD and total_melds < ChinitsuAnalyzer.MAX_MELDS: return False # first of all - check melds, they must be all from one suit current_suit = None for meld in self.enemy.melds: tile = meld.tiles[0] tile_34 = tile // 4 if is_honor(tile_34): return False suit = self._get_tile_suit(tile) if not current_suit: current_suit = suit elif suit["name"] != current_suit["name"]: return False assert current_suit if not self._check_discard_order( current_suit, int(total_discards / ChinitsuAnalyzer.EARLY_DISCARD_DIVISOR)): return False # finally let's check if discard is not too full of chosen suit discards = [x.value for x in self.enemy.discards] discards_34 = TilesConverter.to_34_array(discards) result = count_tiles_by_suits(discards_34) suits = [x for x in result if x["name"] != "honor"] suits = sorted(suits, key=lambda x: x["count"], reverse=False) less_suits = [x for x in suits if x["count"] == suits[0]["count"]] assert len(less_suits) != 0 current_suit_is_less_suit = False for less_suit in less_suits: if less_suit["name"] == current_suit["name"]: current_suit_is_less_suit = True if not current_suit_is_less_suit: return False less_suit = suits[0] less_suit_tiles = less_suit["count"] if total_discards >= ChinitsuAnalyzer.MIN_DISCARD_FOR_LESS_SUIT: percentage_of_less_suit = (less_suit_tiles / total_discards) * 100 if percentage_of_less_suit > ChinitsuAnalyzer.LESS_SUIT_PERCENTAGE_BORDER: return False else: if len(self.enemy.melds) < 2: return False if less_suit_tiles > 1: return False self.chosen_suit = current_suit["function"] return True
def _calculate_assumed_hand_cost_for_riichi( self, tile_136, can_be_used_for_ryanmen) -> int: scale_index = 0 tile_34 = tile_136 // 4 if self.enemy.is_dealer: scale = [ 2900, 5800, 7700, 12000, 12000, 18000, 18000, 24000, 24000, 48000 ] else: scale = [ 2000, 3900, 5200, 8000, 8000, 12000, 12000, 16000, 16000, 32000 ] # it wasn't early riichi, let's think that it could be more expensive if 6 <= self.enemy.riichi_called_on_step <= 11: scale_index += 1 # more late riichi, probably means more expensive riichi if self.enemy.riichi_called_on_step >= 12: scale_index += 2 if self.enemy.is_ippatsu: scale_index += 1 total_dora_in_game = len(self.table.dora_indicators) * 4 + ( 3 * int(self.table.has_aka_dora)) visible_tiles = self.table.revealed_tiles_136 + self.main_player.closed_hand visible_dora_tiles = sum([ plus_dora(x, self.table.dora_indicators, add_aka_dora=self.table.has_aka_dora) for x in visible_tiles ]) live_dora_tiles = total_dora_in_game - visible_dora_tiles assert live_dora_tiles >= 0, "Live dora tiles can't be less than 0" # there are too many live dora tiles, let's increase hand cost if live_dora_tiles >= 4: scale_index += 1 # if we are discarding dora we are obviously going to make enemy hand more expensive scale_index += self._get_dora_scale_bonus(tile_136) # if enemy has closed kan, his hand is more expensive on average for meld in self.enemy.melds: # if he is in riichi he can only have closed kan assert meld.type == Meld.KAN and not meld.opened # plus two just because of riichi with kan scale_index += 2 # higher danger for doras for tile in meld.tiles: scale_index += plus_dora(tile, self.table.dora_indicators, add_aka_dora=self.table.has_aka_dora) # higher danger for yakuhai tile_meld_34 = meld.tiles[0] // 4 scale_index += len( [x for x in self.enemy.valued_honors if x == tile_meld_34]) # let's add more danger for all other opened kan sets on the table for other_player in self.table.players: if other_player.seat == self.enemy.seat: continue for meld in other_player.melds: if meld.type == Meld.KAN or meld.type == Meld.SHOUMINKAN: scale_index += 1 # additional danger for tiles that could be used for tanyao if not is_honor(tile_34): # +1 here to make it more readable simplified_tile = simplify(tile_34) + 1 if simplified_tile in [4, 5, 6]: scale_index += 1 if simplified_tile in [2, 3, 7, 8] and can_be_used_for_ryanmen: scale_index += 1 if scale_index > len(scale) - 1: scale_index = len(scale) - 1 return scale[scale_index]
def _choose_best_discard_in_tempai(self, tiles, melds, discard_options): # first of all we find tiles that have the best hand cost * ukeire value call_riichi = not self.player.is_open_hand discard_desc = [] player_tiles_copy = self.player.tiles.copy() player_melds_copy = self.player.melds.copy() closed_tiles_34 = TilesConverter.to_34_array(self.player.closed_hand) for discard_option in discard_options: tile = discard_option.find_tile_in_hand(self.player.closed_hand) # temporary remove discard option to estimate hand value self.player.tiles = tiles.copy() self.player.tiles.remove(tile) # temporary replace melds self.player.melds = melds.copy() # for kabe/suji handling discarded_tile = Tile(tile, False) self.player.discards.append(discarded_tile) is_furiten = self._is_discard_option_furiten(discard_option) if len(discard_option.waiting) == 1: waiting = discard_option.waiting[0] cost_x_ukeire, hand_cost = self._estimate_cost_x_ukeire(discard_option, call_riichi) # let's check if this is a tanki wait results, tiles_34 = self.divide_hand(self.player.tiles, waiting) result = results[0] tanki_type = None is_tanki = False for hand_set in result: if waiting not in hand_set: continue if is_pair(hand_set): is_tanki = True if is_honor(waiting): # TODO: differentiate between self honor and honor for all players if waiting in self.player.valued_honors: tanki_type = self.TankiWait.TANKI_WAIT_ALL_YAKUHAI else: tanki_type = self.TankiWait.TANKI_WAIT_NON_YAKUHAI break simplified_waiting = simplify(waiting) have_suji, have_kabe = self.check_suji_and_kabe(closed_tiles_34, waiting) # TODO: not sure about suji/kabe priority, so we keep them same for now if 3 <= simplified_waiting <= 5: if have_suji or have_kabe: tanki_type = self.TankiWait.TANKI_WAIT_456_KABE else: tanki_type = self.TankiWait.TANKI_WAIT_456_RAW elif 2 <= simplified_waiting <= 6: if have_suji or have_kabe: tanki_type = self.TankiWait.TANKI_WAIT_37_KABE else: tanki_type = self.TankiWait.TANKI_WAIT_37_RAW elif 1 <= simplified_waiting <= 7: if have_suji or have_kabe: tanki_type = self.TankiWait.TANKI_WAIT_28_KABE else: tanki_type = self.TankiWait.TANKI_WAIT_28_RAW else: if have_suji or have_kabe: tanki_type = self.TankiWait.TANKI_WAIT_69_KABE else: tanki_type = self.TankiWait.TANKI_WAIT_69_RAW break discard_desc.append({ 'discard_option': discard_option, 'hand_cost': hand_cost, 'cost_x_ukeire': cost_x_ukeire, 'is_furiten': is_furiten, 'is_tanki': is_tanki, 'tanki_type': tanki_type }) else: cost_x_ukeire, _ = self._estimate_cost_x_ukeire(discard_option, call_riichi) discard_desc.append({ 'discard_option': discard_option, 'hand_cost': None, 'cost_x_ukeire': cost_x_ukeire, 'is_furiten': is_furiten, 'is_tanki': False, 'tanki_type': None }) # reverse all temporary tile tweaks self.player.tiles = player_tiles_copy self.player.melds = player_melds_copy self.player.discards.remove(discarded_tile) discard_desc = sorted(discard_desc, key=lambda k: (k['cost_x_ukeire'], not k['is_furiten']), reverse=True) # if we don't have any good options, e.g. all our possible waits ara karaten # FIXME: in that case, discard the safest tile if discard_desc[0]['cost_x_ukeire'] == 0: return sorted(discard_options, key=lambda x: x.valuation)[0] num_tanki_waits = len([x for x in discard_desc if x['is_tanki']]) # what if all our waits are tanki waits? we need a special handling for that case if num_tanki_waits == len(discard_options): return self._choose_best_tanki_wait(discard_desc) best_discard_desc = [x for x in discard_desc if x['cost_x_ukeire'] == discard_desc[0]['cost_x_ukeire']] # we only have one best option based on ukeire and cost, nothing more to do here if len(best_discard_desc) == 1: return best_discard_desc[0]['discard_option'] # if we have several options that give us similar wait # FIXME: 1. we find the safest tile to discard # FIXME: 2. if safeness is the same, we try to discard non-dora tiles return best_discard_desc[0]['discard_option']
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) # 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, } ) 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]
def _choose_best_discard_in_tempai(self, discard_options, after_meld): discard_desc = [] closed_tiles_34 = TilesConverter.to_34_array(self.player.closed_hand) for discard_option in discard_options: call_riichi = discard_option.with_riichi tiles_original, discard_original = self.emulate_discard( discard_option) is_furiten = self._is_discard_option_furiten(discard_option) if len(discard_option.waiting) == 1: waiting = discard_option.waiting[0] cost_x_ukeire, hand_cost = self._estimate_cost_x_ukeire( discard_option, call_riichi) # let's check if this is a tanki wait results, tiles_34 = self.divide_hand(self.player.tiles, waiting) result = results[0] tanki_type = None is_tanki = False for hand_set in result: if waiting not in hand_set: continue if is_pair(hand_set): is_tanki = True if is_honor(waiting): # TODO: differentiate between self honor and honor for all players if waiting in self.player.valued_honors: tanki_type = TankiWait.TANKI_WAIT_ALL_YAKUHAI else: tanki_type = TankiWait.TANKI_WAIT_NON_YAKUHAI break simplified_waiting = simplify(waiting) have_suji, have_kabe = self.check_suji_and_kabe( closed_tiles_34, waiting) # TODO: not sure about suji/kabe priority, so we keep them same for now if 3 <= simplified_waiting <= 5: if have_suji or have_kabe: tanki_type = TankiWait.TANKI_WAIT_456_KABE else: tanki_type = TankiWait.TANKI_WAIT_456_RAW elif 2 <= simplified_waiting <= 6: if have_suji or have_kabe: tanki_type = TankiWait.TANKI_WAIT_37_KABE else: tanki_type = TankiWait.TANKI_WAIT_37_RAW elif 1 <= simplified_waiting <= 7: if have_suji or have_kabe: tanki_type = TankiWait.TANKI_WAIT_28_KABE else: tanki_type = TankiWait.TANKI_WAIT_28_RAW else: if have_suji or have_kabe: tanki_type = TankiWait.TANKI_WAIT_69_KABE else: tanki_type = TankiWait.TANKI_WAIT_69_RAW break tempai_descriptor = { "discard_option": discard_option, "hand_cost": hand_cost, "cost_x_ukeire": cost_x_ukeire, "is_furiten": is_furiten, "is_tanki": is_tanki, "tanki_type": tanki_type, "max_danger": discard_option.danger.get_max_danger(), "sum_danger": discard_option.danger.get_sum_danger(), "weighted_danger": discard_option.danger.get_weighted_danger(), } discard_desc.append(tempai_descriptor) else: cost_x_ukeire, _ = self._estimate_cost_x_ukeire( discard_option, call_riichi) tempai_descriptor = { "discard_option": discard_option, "hand_cost": None, "cost_x_ukeire": cost_x_ukeire, "is_furiten": is_furiten, "is_tanki": False, "tanki_type": None, "max_danger": discard_option.danger.get_max_danger(), "sum_danger": discard_option.danger.get_sum_danger(), "weighted_danger": discard_option.danger.get_weighted_danger(), } discard_desc.append(tempai_descriptor) # save descriptor to discard option for future users discard_option.tempai_descriptor = tempai_descriptor # reverse all temporary tile tweaks self.restore_after_emulate_discard(tiles_original, discard_original) discard_desc = sorted( discard_desc, key=lambda k: (-k["cost_x_ukeire"], k["is_furiten"], k["weighted_danger"])) # if we don't have any good options, e.g. all our possible waits are karaten if discard_desc[0]["cost_x_ukeire"] == 0: # we still choose between options that give us tempai, because we may be going to formal tempai # with no hand cost return self._choose_safest_tile(discard_options) num_tanki_waits = len([x for x in discard_desc if x["is_tanki"]]) # what if all our waits are tanki waits? we need a special handling for that case if num_tanki_waits == len(discard_options): return self._choose_best_tanki_wait(discard_desc) best_discard_desc = [ x for x in discard_desc if x["cost_x_ukeire"] == discard_desc[0]["cost_x_ukeire"] ] best_discard_desc = sorted(best_discard_desc, key=lambda k: (k["is_furiten"], k["weighted_danger"])) # if we have several options that give us similar wait return best_discard_desc[0]["discard_option"]
def _should_call_riichi_one_sided(self, waiting): count_tiles = self.player.ai.hand_builder.count_tiles( waiting, TilesConverter.to_34_array(self.player.closed_hand)) waiting = waiting[0] hand_value = self.player.ai.estimate_hand_value_or_get_from_cache( waiting, call_riichi=False) hand_value_with_riichi = self.player.ai.estimate_hand_value_or_get_from_cache( waiting, call_riichi=True) must_riichi = self.player.ai.placement.must_riichi( has_yaku=(hand_value.yaku is not None and hand_value.cost is not None), num_waits=count_tiles, cost_with_riichi=hand_value_with_riichi.cost["main"], cost_with_damaten=(hand_value.cost and hand_value.cost["main"] or 0), ) if must_riichi == Placement.MUST_RIICHI: return True elif must_riichi == Placement.MUST_DAMATEN: return False tiles = self.player.closed_hand[:] closed_melds = [x for x in self.player.melds if not x.opened] for meld in closed_melds: tiles.extend(meld.tiles[:3]) results, tiles_34 = self.player.ai.hand_builder.divide_hand( tiles, waiting) result = results[0] closed_tiles_34 = TilesConverter.to_34_array(self.player.closed_hand) have_suji, have_kabe = self.player.ai.hand_builder.check_suji_and_kabe( closed_tiles_34, waiting) # what if we have yaku if hand_value.yaku is not None and hand_value.cost is not None: min_cost = hand_value.cost["main"] # tanki honor is a good wait, let's damaten only if hand is already expensive if is_honor(waiting): if self.player.is_dealer and min_cost < 12000: return True if not self.player.is_dealer and min_cost < 8000: return True return False is_chiitoitsu = len([x for x in result if is_pair(x)]) == 7 simplified_waiting = simplify(waiting) for hand_set in result: if waiting not in hand_set: continue # tanki wait but not chiitoitsu if is_pair(hand_set) and not is_chiitoitsu: # let's not riichi tanki 4, 5, 6 if 3 <= simplified_waiting <= 5: return False # don't riichi tanki wait on 1, 2, 3, 7, 8, 9 if it's only 1 tile if count_tiles == 1: return False # don't riichi 2378 tanki if hand has good value if simplified_waiting != 0 and simplified_waiting != 8: if self.player.is_dealer and min_cost >= 7700: return False if not self.player.is_dealer and min_cost >= 5200: return False # only riichi if we have suji-trab or there is kabe if not have_suji and not have_kabe: return False return True # tanki wait with chiitoitsu if is_pair(hand_set) and is_chiitoitsu: # chiitoitsu on last suit tile is not the best if count_tiles == 1: return False # early riichi on 19 tanki is good if (simplified_waiting == 0 or simplified_waiting == 8) and self.player.round_step < 7: return True # riichi on 19 tanki is good later too if we have 3 tiles to wait for if ((simplified_waiting == 0 or simplified_waiting == 8) and self.player.round_step < 12 and count_tiles == 3): return True # riichi on 28 tanki is good if we have 3 tiles to wait for if ((simplified_waiting == 1 or simplified_waiting == 7) and self.player.round_step < 12 and count_tiles == 3): return True # otherwise only riichi if we have suji-trab or there is kabe if not have_suji and not have_kabe: return False return True # 1-sided wait means kanchan or penchan if is_chi(hand_set): # let's not riichi kanchan on 4, 5, 6 if 3 <= simplified_waiting <= 5: return False # now checking waiting for 2, 3, 7, 8 # if we only have 1 tile to wait for, let's damaten if count_tiles == 1: return False # if we have 2 tiles to wait for and hand cost is good without riichi, # let's damaten if count_tiles == 2: if self.player.is_dealer and min_cost >= 7700: return False if not self.player.is_dealer and min_cost >= 5200: return False # if we have more than two tiles to wait for and we have kabe or suji - insta riichi if count_tiles > 2 and (have_suji or have_kabe): return True # 2 and 8 are good waits but not in every condition if simplified_waiting == 1 or simplified_waiting == 7: if self.player.round_step < 7: if self.player.is_dealer and min_cost < 18000: return True if not self.player.is_dealer and min_cost < 8000: return True if self.player.round_step < 12: if self.player.is_dealer and min_cost < 12000: return True if not self.player.is_dealer and min_cost < 5200: return True if self.player.round_step < 15: if self.player.is_dealer and 2000 < min_cost < 7700: return True # 3 and 7 are ok waits sometimes too if simplified_waiting == 2 or simplified_waiting == 6: if self.player.round_step < 7: if self.player.is_dealer and min_cost < 12000: return True if not self.player.is_dealer and min_cost < 5200: return True if self.player.round_step < 12: if self.player.is_dealer and min_cost < 7700: return True if not self.player.is_dealer and min_cost < 5200: return True if self.player.round_step < 15: if self.player.is_dealer and 2000 < min_cost < 7700: return True # otherwise only riichi if we have suji-trab or there is kabe if not have_suji and not have_kabe: return False return True # what if we don't have yaku # our tanki wait is good, let's riichi if is_honor(waiting): return True if count_tiles > 1: # terminal tanki is ok, too, just should be more than one tile left if is_terminal(waiting): return True # whatever dora wait is ok, too, just should be more than one tile left if plus_dora(waiting * 4, self.player.table.dora_indicators, add_aka_dora=False) > 0: return True simplified_waiting = simplify(waiting) for hand_set in result: if waiting not in hand_set: continue if is_pair(hand_set): # let's not riichi tanki wait without suji-trap or kabe if not have_suji and not have_kabe: return False # let's not riichi tanki on last suit tile if it's early if count_tiles == 1 and self.player.round_step < 6: return False # let's not riichi tanki 4, 5, 6 if it's early if 3 <= simplified_waiting <= 5 and self.player.round_step < 6: return False # 1-sided wait means kanchan or penchan # let's only riichi this bad wait if # it has all 4 tiles available or it # it's not too early if is_chi(hand_set) and 4 <= simplified_waiting <= 6: return count_tiles == 4 or self.player.round_step >= 6 return True
def _should_call_riichi_one_sided(self): count_tiles = self.player.ai.hand_builder.count_tiles( self.player.ai.waiting, TilesConverter.to_34_array(self.player.closed_hand) ) waiting = self.player.ai.waiting[0] hand_value = self.player.ai.estimate_hand_value(waiting, call_riichi=False) tiles = self.player.closed_hand.copy() closed_melds = [x for x in self.player.melds if not x.opened] for meld in closed_melds: tiles.extend(meld.tiles[:3]) results, tiles_34 = self.player.ai.hand_builder.divide_hand(tiles, waiting) result = results[0] closed_tiles_34 = TilesConverter.to_34_array(self.player.closed_hand) have_suji, have_kabe = self.player.ai.hand_builder.check_suji_and_kabe(closed_tiles_34, waiting) # what if we have yaku if hand_value.yaku is not None and hand_value.cost is not None: min_cost = hand_value.cost['main'] # tanki honor is a good wait, let's damaten only if hand is already expensive if is_honor(waiting): if self.player.is_dealer and min_cost < 12000: return True if not self.player.is_dealer and min_cost < 8000: return True return False is_chiitoitsu = len([x for x in result if is_pair(x)]) == 7 simplified_waiting = simplify(waiting) for hand_set in result: if waiting not in hand_set: continue # tanki wait but not chiitoitsu if is_pair(hand_set) and not is_chiitoitsu: # let's not riichi tanki 4, 5, 6 if 3 <= simplified_waiting <= 5: return False # don't riichi tanki wait on 1, 2, 3, 7, 8, 9 if it's only 1 tile if count_tiles == 1: return False # don't riichi 2378 tanki if hand has good value if simplified_waiting != 0 and simplified_waiting != 8: if self.player.is_dealer and min_cost >= 7700: return False if not self.player.is_dealer and min_cost >= 5200: return False # only riichi if we have suji-trab or there is kabe if not have_suji and not have_kabe: return False return True # tanki wait with chiitoitsu if is_pair(hand_set) and is_chiitoitsu: # chiitoitsu on last suit tile is no the best if count_tiles == 1: return False # only riichi if we have suji-trab or there is kabe if not have_suji and not have_kabe: return False return True # 1-sided wait means kanchan or penchan if is_chi(hand_set): # let's not riichi kanchan on 4, 5, 6 if 3 <= simplified_waiting <= 5: return False # now checking waiting for 2, 3, 7, 8 # if we only have 1 tile to wait for, let's damaten if count_tiles == 1: return False # if we have 2 tiles to wait for and hand cost is good without riichi, # let's damaten if count_tiles == 2: if self.player.is_dealer and min_cost >= 7700: return False if not self.player.is_dealer and min_cost >= 5200: return False # only riichi if we have suji-trab or there is kabe if not have_suji and not have_kabe: return False return True # what if we don't have yaku # our tanki wait is good, let's riichi if is_honor(waiting): return True simplified_waiting = simplify(waiting) for hand_set in result: if waiting not in hand_set: continue if is_pair(hand_set): # let's not riichi tanki wait without suji-trap or kabe if not have_suji and not have_kabe: return False # let's not riichi tanki on last suit tile if it's early if count_tiles == 1 and self.player.round_step < 6: return False # let's not riichi tanki 4, 5, 6 if it's early if 3 <= simplified_waiting <= 5 and self.player.round_step < 6: return False # 1-sided wait means kanchan or penchan if is_chi(hand_set): # let's only riichi this bad wait if # it has all 4 tiles available or it # it's not too early if 4 <= simplified_waiting <= 6: return count_tiles == 4 or self.player.round_step >= 6 return True
def should_activate_strategy(self, tiles_136): """ 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 8th turn is too slow, considering alternative if suit['count'] == 10 and self.player.ai.shanten == 1 and self.player.round_step > 8: 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 calculate_valuation(self): # base is 100 for ability to mark tiles as not needed (like set value to 50) value = 100 honored_value = 20 if is_honor(self.tile_to_discard_34): if self.tile_to_discard_34 in self.player.valued_honors: count_of_winds = [ x for x in self.player.valued_honors if x == self.tile_to_discard_34 ] # for west-west, east-east we had to double tile value value += honored_value * len(count_of_winds) else: # aim for tanyao if self.player.ai.current_strategy and self.player.ai.current_strategy.type == BaseStrategy.TANYAO: suit_tile_grades = [10, 20, 30, 50, 40, 50, 30, 20, 10] # usual hand else: suit_tile_grades = [10, 20, 40, 50, 30, 50, 40, 20, 10] simplified_tile = simplify(self.tile_to_discard_34) value += suit_tile_grades[simplified_tile] for indicator in self.player.table.dora_indicators: indicator_34 = indicator // 4 if is_honor(indicator_34): continue # indicator and tile not from the same suit if is_sou(indicator_34) and not is_sou( self.tile_to_discard_34): continue # indicator and tile not from the same suit if is_man(indicator_34) and not is_man( self.tile_to_discard_34): continue # indicator and tile not from the same suit if is_pin(indicator_34) and not is_pin( self.tile_to_discard_34): continue simplified_indicator = simplify(indicator_34) simplified_dora = simplified_indicator + 1 # indicator is 9 man if simplified_dora == 9: simplified_dora = 0 # tile so close to the dora if simplified_tile + 1 == simplified_dora or simplified_tile - 1 == simplified_dora: value += DiscardOption.DORA_FIRST_NEIGHBOUR # tile not far away from dora if simplified_tile + 2 == simplified_dora or simplified_tile - 2 == simplified_dora: value += DiscardOption.DORA_SECOND_NEIGHBOUR count_of_dora = plus_dora(self.tile_to_discard_136, self.player.table.dora_indicators, add_aka_dora=self.player.table.has_aka_dora) self.count_of_dora = count_of_dora value += count_of_dora * DiscardOption.DORA_VALUE if is_honor(self.tile_to_discard_34): # depends on how much honor tiles were discarded # we will decrease tile value discard_percentage = [100, 75, 20, 0, 0] discarded_tiles = self.player.table.revealed_tiles[ self.tile_to_discard_34] value = (value * discard_percentage[discarded_tiles]) / 100 # three honor tiles were discarded, # so we don't need this tile anymore if value == 0: self.had_to_be_discarded = True self.valuation = int(value)
def calculate_value(self): # base is 100 for ability to mark tiles as not needed (like set value to 50) value = 100 honored_value = 20 if is_honor(self.tile_to_discard): if self.tile_to_discard in self.player.valued_honors: count_of_winds = [x for x in self.player.valued_honors if x == self.tile_to_discard] # for west-west, east-east we had to double tile value value += honored_value * len(count_of_winds) else: # aim for tanyao if self.player.ai.current_strategy and self.player.ai.current_strategy.type == BaseStrategy.TANYAO: suit_tile_grades = [10, 20, 30, 50, 40, 50, 30, 20, 10] # usual hand else: suit_tile_grades = [10, 20, 40, 50, 30, 50, 40, 20, 10] simplified_tile = simplify(self.tile_to_discard) value += suit_tile_grades[simplified_tile] for indicator in self.player.table.dora_indicators: indicator_34 = indicator // 4 if is_honor(indicator_34): continue # indicator and tile not from the same suit if is_sou(indicator_34) and not is_sou(self.tile_to_discard): continue # indicator and tile not from the same suit if is_man(indicator_34) and not is_man(self.tile_to_discard): continue # indicator and tile not from the same suit if is_pin(indicator_34) and not is_pin(self.tile_to_discard): continue simplified_indicator = simplify(indicator_34) simplified_dora = simplified_indicator + 1 # indicator is 9 man if simplified_dora == 9: simplified_dora = 0 # tile so close to the dora if simplified_tile + 1 == simplified_dora or simplified_tile - 1 == simplified_dora: value += DiscardOption.DORA_FIRST_NEIGHBOUR # tile not far away from dora if simplified_tile + 2 == simplified_dora or simplified_tile - 2 == simplified_dora: value += DiscardOption.DORA_SECOND_NEIGHBOUR count_of_dora = plus_dora(self.tile_to_discard * 4, self.player.table.dora_indicators) tile_136 = self.find_tile_in_hand(self.player.closed_hand) if is_aka_dora(tile_136, self.player.table.has_aka_dora): count_of_dora += 1 self.count_of_dora = count_of_dora value += count_of_dora * DiscardOption.DORA_VALUE if is_honor(self.tile_to_discard): # depends on how much honor tiles were discarded # we will decrease tile value discard_percentage = [100, 75, 20, 0, 0] discarded_tiles = self.player.table.revealed_tiles[self.tile_to_discard] value = (value * discard_percentage[discarded_tiles]) / 100 # three honor tiles were discarded, # so we don't need this tile anymore if value == 0: self.had_to_be_discarded = True self.valuation = int(value)