diff --git a/.flake8 b/.flake8 index 2d5c5816..42f0565a 100644 --- a/.flake8 +++ b/.flake8 @@ -1,3 +1,3 @@ [flake8] max-line-length = 120 -exclude = settings.py \ No newline at end of file +exclude = *settings.py,tests_validate_hand.py \ No newline at end of file diff --git a/.gitignore b/.gitignore index 6a71182b..a720f7d1 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,7 @@ project/game/data/* project/analytics/data/* # temporary files -experiments \ No newline at end of file +experiments + +*.prof +profile.py \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index dad4a5be..480b8b8c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,10 @@ language: python +dist: xenial +sudo: true python: - "3.5" - "3.6" + - "3.7" before_script: - cd project install: "pip install -r project/requirements.txt" diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..4087cbea --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,5 @@ +## Tenhou bot code of conduct: + +1. A robot may not injure a human being or, through inaction, allow a human being to come to harm. +2. A robot must obey orders given it by human beings except where such orders would conflict with the First Law. +3. A robot must protect its own existence as long as such protection does not conflict with the First or Second Law. diff --git a/bin/run.sh b/bin/run.sh index 42287e1d..e0742c84 100644 --- a/bin/run.sh +++ b/bin/run.sh @@ -1,19 +1,21 @@ #!/bin/sh -e -# cron will run each 5 minutes -# and will search the run process +# the cron job will be executed each 5 minutes +# and it will try to find bot process # if there is no process, it will run it +# example of usage +# */5 * * * * bash /root/bot/bin/run.sh bot_settings_name -# */5 * * * * bash /root/bot/bin/run.sh +SETTINGS_NAME="$1" -PID=`ps -eaf | grep project/main.py | grep -v grep | awk '{print $2}'` +PID=`ps -eaf | grep "project/main.py -s ${SETTINGS_NAME}" | grep -v grep | awk '{print $2}'` -if [[ "" = "$PID" ]]; then - /root/bot/env/bin/python /root/bot/project/main.py +if [[ "" = "$PID" ]]; then + /root/bot/env/bin/python /root/bot/project/main.py -s ${SETTINGS_NAME} else WORKED_SECONDS=`ps -p "$PID" -o etimes=` - # if process run > 60 minutes, probably it hang and we need to kill it - if [ ${WORKED_SECONDS} -gt "3600" ]; then + # if process run > 60 minutes, probably it hangs and we need to kill it + if [[ ${WORKED_SECONDS} -gt "3600" ]]; then kill ${PID} fi fi \ No newline at end of file diff --git a/project/game/ai/base/main.py b/project/game/ai/base/main.py index 65318521..2af139ac 100644 --- a/project/game/ai/base/main.py +++ b/project/game/ai/base/main.py @@ -56,11 +56,12 @@ def should_call_riichi(self): """ return False - def should_call_kan(self, tile, is_open_kan): + def should_call_kan(self, tile, is_open_kan, from_riichi=False): """ - When bot can call kan or chankan this method will be called + When bot can call kan or shouminkan this method will be called :param tile: 136 tile format :param is_open_kan: boolean + :param from_riichi: boolean :return: kan type (Meld.KAN, Meld.CHANKAN) or None """ return False diff --git a/project/game/ai/discard.py b/project/game/ai/discard.py index 0b460b54..7a5e0802 100644 --- a/project/game/ai/discard.py +++ b/project/game/ai/discard.py @@ -1,10 +1,16 @@ # -*- coding: utf-8 -*- from mahjong.constants import AKA_DORA_LIST from mahjong.tile import TilesConverter -from mahjong.utils import is_honor, simplify, plus_dora, is_aka_dora +from mahjong.utils import is_honor, simplify, plus_dora, is_aka_dora, is_sou, is_man, is_pin + +from game.ai.first_version.strategies.main import BaseStrategy class DiscardOption(object): + DORA_VALUE = 10000 + DORA_FIRST_NEIGHBOUR = 1000 + DORA_SECOND_NEIGHBOUR = 100 + player = None # in 34 tile format @@ -12,7 +18,8 @@ class DiscardOption(object): # array of tiles that will improve our hand waiting = None # how much tiles will improve our hand - tiles_count = None + ukeire = None + ukeire_second = None # number of shanten for that tile shanten = None # sometimes we had to force tile to be discarded @@ -23,25 +30,45 @@ class DiscardOption(object): valuation = None # how danger this tile is danger = None + # wait to ukeire map + wait_to_ukeire = None + # second level cost approximation for 1-shanten hands + second_level_cost = None - def __init__(self, player, tile_to_discard, shanten, waiting, tiles_count, danger=100): + def __init__(self, player, tile_to_discard, shanten, waiting, ukeire, danger=100, wait_to_ukeire=None): """ :param player: :param tile_to_discard: tile in 34 format :param waiting: list of tiles in 34 format - :param tiles_count: count of tiles to wait after discard + :param ukeire: count of tiles to wait after discard """ self.player = player self.tile_to_discard = tile_to_discard self.shanten = shanten self.waiting = waiting - self.tiles_count = tiles_count + self.ukeire = ukeire + self.ukeire_second = 0 + self.count_of_dora = 0 self.danger = danger self.had_to_be_saved = False self.had_to_be_discarded = False + self.wait_to_ukeire = wait_to_ukeire self.calculate_value() + def __unicode__(self): + tile_format_136 = TilesConverter.to_one_line_string([self.tile_to_discard*4]) + return 'tile={}, shanten={}, ukeire={}, ukeire2={}, valuation={}'.format( + tile_format_136, + self.shanten, + self.ukeire, + self.ukeire_second, + self.valuation + ) + + def __repr__(self): + return '{}'.format(self.__unicode__()) + def find_tile_in_hand(self, closed_hand): """ Find and return 136 tile in closed player hand @@ -69,31 +96,66 @@ def find_tile_in_hand(self, closed_hand): return TilesConverter.find_34_tile_in_136_array(self.tile_to_discard, closed_hand) - def calculate_value(self, shanten=None): + 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 - # 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] + # 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) - if is_aka_dora(self.tile_to_discard * 4, self.player.table.has_open_tanyao): + + 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 - value += 50 * count_of_dora + 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 @@ -108,4 +170,4 @@ def calculate_value(self, shanten=None): if value == 0: self.had_to_be_discarded = True - self.valuation = value + self.valuation = int(value) diff --git a/project/game/ai/first_version/defence/impossible_wait.py b/project/game/ai/first_version/defence/impossible_wait.py index a6104f61..e1198f2a 100644 --- a/project/game/ai/first_version/defence/impossible_wait.py +++ b/project/game/ai/first_version/defence/impossible_wait.py @@ -14,10 +14,10 @@ def find_tiles_to_discard(self, _): results = [] for x in HONOR_INDICES: - if self.player.total_tiles(x, self.defence.hand_34) == 4: + if self.player.total_tiles(x, self.defence.closed_hand_34) == 4: results.append(DefenceTile(x, DefenceTile.SAFE)) - if self.player.total_tiles(x, self.defence.hand_34) == 3: + if self.player.total_tiles(x, self.defence.closed_hand_34) == 3: results.append(DefenceTile(x, DefenceTile.ALMOST_SAFE_TILE)) return results diff --git a/project/game/ai/first_version/defence/kabe.py b/project/game/ai/first_version/defence/kabe.py index 8d534da8..339f7994 100644 --- a/project/game/ai/first_version/defence/kabe.py +++ b/project/game/ai/first_version/defence/kabe.py @@ -7,52 +7,96 @@ class Kabe(Defence): - def find_tiles_to_discard(self, _): - results = [] - + def find_all_kabe(self, tiles_34): # all indices shifted to -1 kabe_matrix = [ - {'indices': [1], 'blocked_tiles': [0]}, - {'indices': [2], 'blocked_tiles': [0, 1]}, - {'indices': [3], 'blocked_tiles': [1, 2]}, - {'indices': [4], 'blocked_tiles': [2, 6]}, - {'indices': [5], 'blocked_tiles': [6, 7]}, - {'indices': [6], 'blocked_tiles': [7, 8]}, - {'indices': [7], 'blocked_tiles': [8]}, - {'indices': [1, 5], 'blocked_tiles': [3]}, - {'indices': [2, 6], 'blocked_tiles': [4]}, - {'indices': [3, 7], 'blocked_tiles': [5]}, - {'indices': [1, 4], 'blocked_tiles': [2, 3]}, - {'indices': [2, 5], 'blocked_tiles': [3, 4]}, - {'indices': [3, 6], 'blocked_tiles': [4, 5]}, - {'indices': [4, 7], 'blocked_tiles': [5, 6]}, + {'indices': [1], 'blocked_tiles': [0], 'type': KabeTile.STRONG_KABE}, + {'indices': [2], 'blocked_tiles': [0, 1], 'type': KabeTile.STRONG_KABE}, + {'indices': [6], 'blocked_tiles': [7, 8], 'type': KabeTile.STRONG_KABE}, + {'indices': [7], 'blocked_tiles': [8], 'type': KabeTile.STRONG_KABE}, + {'indices': [0, 3], 'blocked_tiles': [2, 3], 'type': KabeTile.STRONG_KABE}, + {'indices': [1, 3], 'blocked_tiles': [2], 'type': KabeTile.STRONG_KABE}, + {'indices': [1, 4], 'blocked_tiles': [2, 3], 'type': KabeTile.STRONG_KABE}, + {'indices': [2, 4], 'blocked_tiles': [3], 'type': KabeTile.STRONG_KABE}, + {'indices': [2, 5], 'blocked_tiles': [3, 4], 'type': KabeTile.STRONG_KABE}, + {'indices': [3, 5], 'blocked_tiles': [4], 'type': KabeTile.STRONG_KABE}, + {'indices': [3, 6], 'blocked_tiles': [4, 5], 'type': KabeTile.STRONG_KABE}, + {'indices': [4, 6], 'blocked_tiles': [5], 'type': KabeTile.STRONG_KABE}, + {'indices': [4, 7], 'blocked_tiles': [5, 6], 'type': KabeTile.STRONG_KABE}, + {'indices': [5, 7], 'blocked_tiles': [6], 'type': KabeTile.STRONG_KABE}, + {'indices': [5, 8], 'blocked_tiles': [6, 7], 'type': KabeTile.STRONG_KABE}, + + {'indices': [3], 'blocked_tiles': [1, 2], 'type': KabeTile.WEAK_KABE}, + {'indices': [4], 'blocked_tiles': [2, 6], 'type': KabeTile.WEAK_KABE}, + {'indices': [5], 'blocked_tiles': [6, 7], 'type': KabeTile.WEAK_KABE}, + {'indices': [1, 5], 'blocked_tiles': [3], 'type': KabeTile.WEAK_KABE}, + {'indices': [2, 6], 'blocked_tiles': [4], 'type': KabeTile.WEAK_KABE}, + {'indices': [3, 7], 'blocked_tiles': [5], 'type': KabeTile.WEAK_KABE}, ] - suits = self._suits_tiles(self.defence.hand_34) + kabe_tiles_strong = [] + kabe_tiles_weak = [] + kabe_tiles_partial = [] + + suits = self._suits_tiles(tiles_34) for x in range(0, 3): suit = suits[x] # "kabe" - 4 revealed tiles kabe_tiles = [] + partial_kabe_tiles = [] for y in range(0, 9): suit_tile = suit[y] if suit_tile == 4: kabe_tiles.append(y) + elif suit_tile == 3: + partial_kabe_tiles.append(y) - blocked_indices = [] for matrix_item in kabe_matrix: - all_indices = len(list(set(matrix_item['indices']) - set(kabe_tiles))) == 0 - if all_indices: - blocked_indices.extend(matrix_item['blocked_tiles']) + if len(list(set(matrix_item['indices']) - set(kabe_tiles))) == 0: + for tile in matrix_item['blocked_tiles']: + if matrix_item['type'] == KabeTile.STRONG_KABE: + kabe_tiles_strong.append(tile + x * 9) + else: + kabe_tiles_weak.append(tile + x * 9) + + if len(list(set(matrix_item['indices']) - set(partial_kabe_tiles))) == 0: + for tile in matrix_item['blocked_tiles']: + kabe_tiles_partial.append(tile + x * 9) + + kabe_tiles_unique = [] + kabe_tiles_strong = list(set(kabe_tiles_strong)) + kabe_tiles_weak = list(set(kabe_tiles_weak)) + kabe_tiles_partial = list(set(kabe_tiles_partial)) + + for tile in kabe_tiles_strong: + kabe_tiles_unique.append(KabeTile(tile, KabeTile.STRONG_KABE)) + + for tile in kabe_tiles_weak: + if tile not in kabe_tiles_strong: + kabe_tiles_unique.append(KabeTile(tile, KabeTile.WEAK_KABE)) + + for tile in kabe_tiles_partial: + if tile not in kabe_tiles_strong and tile not in kabe_tiles_weak: + kabe_tiles_unique.append(KabeTile(tile, KabeTile.PARTIAL_KABE)) + + return kabe_tiles_unique - blocked_indices = list(set(blocked_indices)) - for index in blocked_indices: - # let's find 34 tile index - tile = index + x * 9 - if self.player.total_tiles(tile, self.defence.hand_34) == 4: - results.append(DefenceTile(tile, DefenceTile.SAFE)) + def find_tiles_to_discard(self, _): + all_kabe = self.find_all_kabe(self.defence.closed_hand_34) + + results = [] + + for kabe in all_kabe: + # we don't use it for defence now + if kabe.kabe_type == KabeTile.PARTIAL_KABE: + continue - if self.player.total_tiles(tile, self.defence.hand_34) == 3: - results.append(DefenceTile(tile, DefenceTile.ALMOST_SAFE_TILE)) + tile = kabe.tile_34 + if self.player.total_tiles(tile, self.defence.closed_hand_34) == 4: + results.append(DefenceTile(tile, DefenceTile.SAFE)) + + if self.player.total_tiles(tile, self.defence.closed_hand_34) == 3: + results.append(DefenceTile(tile, DefenceTile.ALMOST_SAFE_TILE)) return results @@ -88,3 +132,16 @@ def _suits_tiles(self, tiles_34): suits[suit_index][simplified_tile] += total_tiles return suits + + +class KabeTile(object): + STRONG_KABE = 0 + WEAK_KABE = 1 + PARTIAL_KABE = 2 + + tile_34 = None + kabe_type = None + + def __init__(self, tile_34, kabe_type): + self.tile_34 = tile_34 + self.kabe_type = kabe_type diff --git a/project/game/ai/first_version/defence/main.py b/project/game/ai/first_version/defence/main.py index c1bc1205..a9dc14c6 100644 --- a/project/game/ai/first_version/defence/main.py +++ b/project/game/ai/first_version/defence/main.py @@ -45,9 +45,12 @@ def should_go_to_defence_mode(self, discard_candidate=None): waiting = discard_candidate.waiting # we have 13 tiles in hand (this is not our turn) else: - shanten = self.player.ai.previous_shanten + shanten = self.player.ai.shanten waiting = self.player.ai.waiting + if not waiting: + waiting = [] + # if we are in riichi, we can't defence if self.player.in_riichi: return False @@ -108,7 +111,13 @@ def should_go_to_defence_mode(self, discard_candidate=None): return False - def try_to_find_safe_tile_to_discard(self, discard_results): + def try_to_find_safe_tile_to_discard(self): + discard_results, _ = self.player.ai.hand_builder.find_discard_options( + self.player.tiles, + self.player.closed_hand, + self.player.melds + ) + self.hand_34 = TilesConverter.to_34_array(self.player.tiles) self.closed_hand_34 = TilesConverter.to_34_array(self.player.closed_hand) @@ -205,7 +214,7 @@ def _find_tile_to_discard(self, safe_tiles, discard_tiles): if not was_safe_tiles: return None - final_results = sorted(discard_tiles, key=lambda x: (x.danger, x.shanten, -x.tiles_count, x.valuation)) + final_results = sorted(discard_tiles, key=lambda x: (x.danger, x.shanten, -x.ukeire, x.valuation)) return final_results[0] diff --git a/project/game/ai/first_version/defence/suji.py b/project/game/ai/first_version/defence/suji.py index e9cf7432..cde1f749 100644 --- a/project/game/ai/first_version/defence/suji.py +++ b/project/game/ai/first_version/defence/suji.py @@ -12,54 +12,90 @@ class Suji(Defence): # 3-6-9 THIRD_SUJI = 3 - def find_tiles_to_discard(self, players): - found_suji = [] - for player in players: - suji = [] - suits = [[], [], []] + def find_suji(self, safe_tiles_34): + suji = [] + suits = [[], [], []] + + # let's cast each tile to 0-8 presentation + for tile in safe_tiles_34: + if is_man(tile): + suits[0].append(simplify(tile)) + + if is_pin(tile): + suits[1].append(simplify(tile)) - # let's cast each tile to 0-8 presentation - safe_tiles = player.all_safe_tiles - for tile in safe_tiles: - if is_man(tile): - suits[0].append(simplify(tile)) + if is_sou(tile): + suits[2].append(simplify(tile)) - if is_pin(tile): - suits[1].append(simplify(tile)) + for x in range(0, 3): + simplified_tiles = suits[x] + base = x * 9 - if is_sou(tile): - suits[2].append(simplify(tile)) + # 1-4-7 + if 3 in simplified_tiles: + suji.append(self.FIRST_SUJI + base) - for x in range(0, 3): - simplified_tiles = suits[x] - base = x * 9 + # double 1-4-7 + if 0 in simplified_tiles and 6 in simplified_tiles: + suji.append(self.FIRST_SUJI + base) - # 1-4-7 - if 3 in simplified_tiles: - suji.append(self.FIRST_SUJI + base) + # 2-5-8 + if 4 in simplified_tiles: + suji.append(self.SECOND_SUJI + base) - # double 1-4-7 - if 0 in simplified_tiles and 6 in simplified_tiles: - suji.append(self.FIRST_SUJI + base) + # double 2-5-8 + if 1 in simplified_tiles and 7 in simplified_tiles: + suji.append(self.SECOND_SUJI + base) - # 2-5-8 - if 4 in simplified_tiles: - suji.append(self.SECOND_SUJI + base) + # 3-6-9 + if 5 in simplified_tiles: + suji.append(self.THIRD_SUJI + base) - # double 2-5-8 - if 1 in simplified_tiles and 7 in simplified_tiles: - suji.append(self.SECOND_SUJI + base) + # double 3-6-9 + if 2 in simplified_tiles and 8 in simplified_tiles: + suji.append(self.THIRD_SUJI + base) - # 3-6-9 - if 5 in simplified_tiles: - suji.append(self.THIRD_SUJI + base) + suji = list(set(suji)) - # double 3-6-9 - if 2 in simplified_tiles and 8 in simplified_tiles: - suji.append(self.THIRD_SUJI + base) + return suji - suji = list(set(suji)) + def find_suji_against_self(self, player): + discards_34 = list(set([x.value // 4 for x in player.discards])) + all_suji = self.find_suji(discards_34) + + result = [] + for suji in all_suji: + suji_temp = suji % 9 + base = suji - suji_temp - 1 + + if suji_temp == self.FIRST_SUJI: + result += [ + base + 1, + base + 4, + base + 7 + ] + + if suji_temp == self.SECOND_SUJI: + result += [ + base + 2, + base + 5, + base + 8 + ] + + if suji_temp == self.THIRD_SUJI: + result += [ + base + 3, + base + 6, + base + 9 + ] + + return result + + def find_tiles_to_discard(self, enemies): + found_suji = [] + for enemy in enemies: + suji = self.find_suji(enemy.all_safe_tiles) found_suji.append(suji) if not found_suji: @@ -108,7 +144,9 @@ def _suji_tiles(self, suji): # mark dora tiles as dangerous tiles to discard for tile in result: - if plus_dora(tile.value * 4, self.table.dora_indicators) or is_aka_dora(tile.value * 4, self.table.has_open_tanyao): + is_dora = plus_dora(tile.value * 4, self.table.dora_indicators) \ + or is_aka_dora(tile.value * 4, self.table.has_open_tanyao) + if is_dora: tile.danger += 100 return result diff --git a/project/game/ai/first_version/hand_builder.py b/project/game/ai/first_version/hand_builder.py new file mode 100644 index 00000000..9ff28a66 --- /dev/null +++ b/project/game/ai/first_version/hand_builder.py @@ -0,0 +1,767 @@ +from mahjong.constants import AKA_DORA_LIST +from mahjong.shanten import Shanten +from mahjong.tile import TilesConverter, Tile +from mahjong.utils import is_tile_strictly_isolated, is_pair, is_honor, simplify + +import utils.decisions_constants as log +from game.ai.discard import DiscardOption +from game.ai.first_version.defence.kabe import KabeTile +from utils.decisions_logger import DecisionsLogger + + +class HandBuilder: + player = None + ai = None + + def __init__(self, player, ai): + self.player = player + self.ai = ai + + class TankiWait: + TANKI_WAIT_NON_YAKUHAI = 1 + TANKI_WAIT_SELF_YAKUHAI = 2 + TANKI_WAIT_ALL_YAKUHAI = 3 + TANKI_WAIT_69_KABE = 4 + TANKI_WAIT_69_SUJI = 5 + TANKI_WAIT_69_RAW = 6 + TANKI_WAIT_28_KABE = 7 + TANKI_WAIT_28_SUJI = 8 + TANKI_WAIT_28_RAW = 9 + TANKI_WAIT_37_KABE = 10 + TANKI_WAIT_37_SUJI = 11 + TANKI_WAIT_37_RAW = 12 + TANKI_WAIT_456_KABE = 13 + TANKI_WAIT_456_SUJI = 14 + TANKI_WAIT_456_RAW = 15 + + tanki_wait_same_ukeire_2_3_prio = { + TANKI_WAIT_NON_YAKUHAI: 15, + TANKI_WAIT_69_KABE: 14, + TANKI_WAIT_69_SUJI: 14, + TANKI_WAIT_SELF_YAKUHAI: 13, + TANKI_WAIT_ALL_YAKUHAI: 12, + TANKI_WAIT_28_KABE: 11, + TANKI_WAIT_28_SUJI: 11, + TANKI_WAIT_37_KABE: 10, + TANKI_WAIT_37_SUJI: 10, + TANKI_WAIT_69_RAW: 9, + TANKI_WAIT_456_KABE: 8, + TANKI_WAIT_456_SUJI: 8, + TANKI_WAIT_28_RAW: 7, + TANKI_WAIT_456_RAW: 6, + TANKI_WAIT_37_RAW: 5 + } + + tanki_wait_same_ukeire_1_prio = { + TANKI_WAIT_NON_YAKUHAI: 15, + TANKI_WAIT_SELF_YAKUHAI: 14, + TANKI_WAIT_ALL_YAKUHAI: 13, + TANKI_WAIT_69_KABE: 12, + TANKI_WAIT_69_SUJI: 12, + TANKI_WAIT_28_KABE: 11, + TANKI_WAIT_28_SUJI: 11, + TANKI_WAIT_37_KABE: 10, + TANKI_WAIT_37_SUJI: 10, + TANKI_WAIT_69_RAW: 9, + TANKI_WAIT_456_KABE: 8, + TANKI_WAIT_456_SUJI: 8, + TANKI_WAIT_28_RAW: 7, + TANKI_WAIT_456_RAW: 6, + TANKI_WAIT_37_RAW: 5 + } + + tanki_wait_diff_ukeire_prio = { + TANKI_WAIT_NON_YAKUHAI: 1, + TANKI_WAIT_SELF_YAKUHAI: 1, + TANKI_WAIT_ALL_YAKUHAI: 1, + TANKI_WAIT_69_KABE: 1, + TANKI_WAIT_69_SUJI: 1, + TANKI_WAIT_28_KABE: 0, + TANKI_WAIT_28_SUJI: 0, + TANKI_WAIT_37_KABE: 0, + TANKI_WAIT_37_SUJI: 0, + TANKI_WAIT_69_RAW: 0, + TANKI_WAIT_456_KABE: 0, + TANKI_WAIT_456_SUJI: 0, + TANKI_WAIT_28_RAW: 0, + TANKI_WAIT_456_RAW: 0, + TANKI_WAIT_37_RAW: 0 + } + + def discard_tile(self, tiles, closed_hand, melds, print_log=True): + selected_tile = self.choose_tile_to_discard( + tiles, + closed_hand, + melds, + print_log=print_log + ) + + # bot think that there is a threat on the table + # and better to fold + # if we can't find safe tiles, let's continue to build our hand + if self.ai.defence.should_go_to_defence_mode(selected_tile): + if not self.ai.in_defence: + DecisionsLogger.debug(log.DEFENCE_ACTIVATE) + self.ai.in_defence = True + + defence_tile = self.ai.defence.try_to_find_safe_tile_to_discard() + if defence_tile: + return self.process_discard_option(defence_tile, closed_hand) + else: + if self.ai.in_defence: + DecisionsLogger.debug(log.DEFENCE_DEACTIVATE) + self.ai.in_defence = False + + return self.process_discard_option(selected_tile, closed_hand, print_log=print_log) + + def calculate_shanten(self, tiles_34, open_sets_34=None): + shanten_with_chiitoitsu = self.ai.shanten_calculator.calculate_shanten(tiles_34, + open_sets_34, + chiitoitsu=True) + shanten_without_chiitoitsu = self.ai.shanten_calculator.calculate_shanten(tiles_34, + open_sets_34, + chiitoitsu=False) + + if shanten_with_chiitoitsu == 0 and shanten_without_chiitoitsu >= 1: + shanten = shanten_with_chiitoitsu + use_chiitoitsu = True + elif shanten_with_chiitoitsu == 1 and shanten_without_chiitoitsu >= 3: + shanten = shanten_with_chiitoitsu + use_chiitoitsu = True + else: + shanten = shanten_without_chiitoitsu + use_chiitoitsu = False + + return shanten, use_chiitoitsu + + def calculate_waits(self, tiles_34, open_sets_34=None): + """ + :param tiles_34: array of tiles in 34 formant, 13 of them (this is important) + :param open_sets_34: array of array with tiles in 34 format + :return: array of waits in 34 format and number of shanten + """ + + shanten, use_chiitoitsu = self.calculate_shanten(tiles_34, open_sets_34) + + waiting = [] + for j in range(0, 34): + if tiles_34[j] == 4: + continue + + tiles_34[j] += 1 + + key = '{},{},{}'.format( + ''.join([str(x) for x in tiles_34]), + ';'.join([str(x) for x in open_sets_34]), + use_chiitoitsu and 1 or 0 + ) + + if key in self.ai.hand_cache: + new_shanten = self.ai.hand_cache[key] + else: + new_shanten = self.ai.shanten_calculator.calculate_shanten( + tiles_34, + open_sets_34, + chiitoitsu=use_chiitoitsu + ) + self.ai.hand_cache[key] = new_shanten + + if new_shanten == shanten - 1: + waiting.append(j) + + tiles_34[j] -= 1 + + return waiting, shanten + + def find_discard_options(self, tiles, closed_hand, melds=None): + """ + :param tiles: array of tiles in 136 format + :param closed_hand: array of tiles in 136 format + :param melds: + :return: + """ + if melds is None: + melds = [] + + open_sets_34 = [x.tiles_34 for x in melds] + + tiles_34 = TilesConverter.to_34_array(tiles) + closed_tiles_34 = TilesConverter.to_34_array(closed_hand) + is_agari = self.ai.agari.is_agari(tiles_34, self.player.meld_34_tiles) + + results = [] + for hand_tile in range(0, 34): + if not closed_tiles_34[hand_tile]: + continue + + tiles_34[hand_tile] -= 1 + waiting, shanten = self.calculate_waits(tiles_34, open_sets_34) + tiles_34[hand_tile] += 1 + + if waiting: + wait_to_ukeire = dict(zip(waiting, [self.count_tiles([x], closed_tiles_34) for x in waiting])) + results.append(DiscardOption(player=self.player, + shanten=shanten, + tile_to_discard=hand_tile, + waiting=waiting, + ukeire=self.count_tiles(waiting, closed_tiles_34), + wait_to_ukeire=wait_to_ukeire)) + + if is_agari: + shanten = Shanten.AGARI_STATE + else: + shanten, _ = self.calculate_shanten( + tiles_34, + open_sets_34 + ) + + return results, shanten + + def count_tiles(self, waiting, tiles_34): + n = 0 + for tile_34 in waiting: + n += 4 - self.player.total_tiles(tile_34, tiles_34) + return n + + def divide_hand(self, tiles, waiting): + tiles_copy = tiles.copy() + + for i in range(0, 4): + if waiting * 4 + i not in tiles_copy: + tiles_copy += [waiting * 4 + i] + break + + tiles_34 = TilesConverter.to_34_array(tiles_copy) + + results = self.player.ai.hand_divider.divide_hand(tiles_34) + return results, tiles_34 + + def check_suji_and_kabe(self, tiles_34, waiting): + # let's find suji-traps in our discard + suji_tiles = self.player.ai.defence.suji.find_suji_against_self(self.player) + have_suji = waiting in suji_tiles + + # let's find kabe + kabe_tiles = self.player.ai.defence.kabe.find_all_kabe(tiles_34) + have_kabe = False + for kabe in kabe_tiles: + if waiting == kabe.tile_34 and kabe.kabe_type == KabeTile.STRONG_KABE: + have_kabe = True + + return have_suji, have_kabe + + def _choose_best_tanki_wait(self, discard_desc): + discard_desc = sorted(discard_desc, key=lambda k: k['hand_cost'], reverse=True) + + # we are always choosing between exactly two tanki waits + assert len(discard_desc) == 2 + + discard_desc = [x for x in discard_desc if x['hand_cost'] != 0] + + # we are guaranteed to have at least one wait with cost by caller logic + assert len(discard_desc) > 0 + + if len(discard_desc) == 1: + return discard_desc[0]['discard_option'] + + # if not 1 then 2 + assert len(discard_desc) == 2 + + num_furiten_waits = len([x for x in discard_desc if x['is_furiten']]) + # if we choose tanki, we always prefer non-furiten wait over furiten one, no matter what the cost is + if num_furiten_waits == 1: + return [x for x in discard_desc if not x['is_furiten']][0]['discard_option'] + + best_discard_desc = [x for x in discard_desc if x['hand_cost'] == discard_desc[0]['hand_cost']] + + # first of all we choose the most expensive wait + if len(best_discard_desc) == 1: + return best_discard_desc[0]['discard_option'] + + best_ukeire = best_discard_desc[0]['discard_option'].ukeire + diff = best_ukeire - best_discard_desc[1]['discard_option'].ukeire + # if both tanki waits have the same ukeire + if diff == 0: + # case when we have 2 or 3 tiles to wait for + if best_ukeire == 2 or best_ukeire == 3: + best_discard_desc = sorted(best_discard_desc, + key=lambda k: self.TankiWait.tanki_wait_same_ukeire_2_3_prio[ + k['tanki_type']], + reverse=True) + return best_discard_desc[0]['discard_option'] + + # case when we have 1 tile to wait for + if best_ukeire == 1: + best_discard_desc = sorted(best_discard_desc, + key=lambda k: self.TankiWait.tanki_wait_same_ukeire_1_prio[ + k['tanki_type']], + reverse=True) + return best_discard_desc[0]['discard_option'] + + # should never reach here + assert False + + # if one tanki wait has 1 more tile to wait than the other we only choose the latter one if it is + # a wind or alike and the first one is not + if diff == 1: + prio_0 = self.TankiWait.tanki_wait_diff_ukeire_prio[best_discard_desc[0]['tanki_type']] + prio_1 = self.TankiWait.tanki_wait_diff_ukeire_prio[best_discard_desc[1]['tanki_type']] + if prio_1 > prio_0: + return best_discard_desc[1]['discard_option'] + + return best_discard_desc[0]['discard_option'] + + if diff > 1: + return best_discard_desc[0]['discard_option'] + + # if everything is the same we just choose the first one + return best_discard_desc[0]['discard_option'] + + def _is_waiting_furiten(self, tile_34): + discarded_tiles = [x.value // 4 for x in self.player.discards] + return tile_34 in discarded_tiles + + def _is_discard_option_furiten(self, discard_option): + is_furiten = False + + for waiting in discard_option.waiting: + is_furiten = is_furiten or self._is_waiting_furiten(waiting) + + return is_furiten + + 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 choose_tile_to_discard(self, tiles, closed_hand, melds, print_log=True): + """ + Try to find best tile to discard, based on different rules + """ + + discard_options, _ = self.find_discard_options( + tiles, + closed_hand, + melds + ) + + # our strategy can affect discard options + if self.ai.current_strategy: + discard_options = self.ai.current_strategy.determine_what_to_discard( + discard_options, + closed_hand, + melds + ) + + had_to_be_discarded_tiles = [x for x in discard_options if x.had_to_be_discarded] + if had_to_be_discarded_tiles: + discard_options = sorted(had_to_be_discarded_tiles, key=lambda x: (x.shanten, -x.ukeire, x.valuation)) + DecisionsLogger.debug( + log.DISCARD_OPTIONS, + 'Discard marked tiles first', + discard_options, + print_log=print_log + ) + return discard_options[0] + + # remove needed tiles from discard options + discard_options = [x for x in discard_options if not x.had_to_be_saved] + + discard_options = sorted(discard_options, key=lambda x: (x.shanten, -x.ukeire)) + first_option = discard_options[0] + results_with_same_shanten = [x for x in discard_options if x.shanten == first_option.shanten] + + possible_options = [first_option] + ukeire_borders = self._choose_ukeire_borders(first_option, 20, 'ukeire') + for discard_option in results_with_same_shanten: + # there is no sense to check already chosen tile + if discard_option.tile_to_discard == first_option.tile_to_discard: + continue + + # let's choose tiles that are close to the max ukeire tile + if discard_option.ukeire >= first_option.ukeire - ukeire_borders: + possible_options.append(discard_option) + + if first_option.shanten in [1, 2, 3]: + ukeire_field = 'ukeire_second' + for x in possible_options: + self.calculate_second_level_ukeire(x, tiles, melds) + + possible_options = sorted(possible_options, key=lambda x: -getattr(x, ukeire_field)) + + filter_percentage = 20 + possible_options = self._filter_list_by_percentage( + possible_options, + ukeire_field, + filter_percentage + ) + else: + ukeire_field = 'ukeire' + possible_options = sorted(possible_options, key=lambda x: -getattr(x, ukeire_field)) + + # only one option - so we choose it + if len(possible_options) == 1: + return possible_options[0] + + # tempai state has a special handling + if first_option.shanten == 0: + other_tiles_with_same_shanten = [x for x in possible_options if x.shanten == 0] + return self._choose_best_discard_in_tempai(tiles, melds, other_tiles_with_same_shanten) + + tiles_without_dora = [x for x in possible_options if x.count_of_dora == 0] + + # we have only dora candidates to discard + if not tiles_without_dora: + DecisionsLogger.debug( + log.DISCARD_OPTIONS, + context=possible_options, + print_log=print_log + ) + + min_dora = min([x.count_of_dora for x in possible_options]) + min_dora_list = [x for x in possible_options if x.count_of_dora == min_dora] + + return sorted(min_dora_list, key=lambda x: -getattr(x, ukeire_field))[0] + + # only one option - so we choose it + if len(tiles_without_dora) == 1: + return tiles_without_dora[0] + + # 1-shanten hands have special handling - we can consider future hand cost here + if first_option.shanten == 1: + return sorted(tiles_without_dora, key=lambda x: (-x.second_level_cost, -x.ukeire_second, x.valuation))[0] + + if first_option.shanten == 2 or first_option.shanten == 3: + # we filter 10% of options here + second_filter_percentage = 10 + filtered_options = self._filter_list_by_percentage( + tiles_without_dora, + ukeire_field, + second_filter_percentage + ) + # we should also consider borders for 3+ shanten hands + else: + best_option_without_dora = tiles_without_dora[0] + ukeire_borders = self._choose_ukeire_borders(best_option_without_dora, 10, ukeire_field) + filtered_options = [] + for discard_option in tiles_without_dora: + val = getattr(best_option_without_dora, ukeire_field) - ukeire_borders + if getattr(discard_option, ukeire_field) >= val: + filtered_options.append(discard_option) + + DecisionsLogger.debug( + log.DISCARD_OPTIONS, + context=possible_options, + print_log=print_log + ) + + closed_hand_34 = TilesConverter.to_34_array(closed_hand) + isolated_tiles = [x for x in filtered_options if is_tile_strictly_isolated(closed_hand_34, x.tile_to_discard)] + # isolated tiles should be discarded first + if isolated_tiles: + # let's sort tiles by value and let's choose less valuable tile to discard + return sorted(isolated_tiles, key=lambda x: x.valuation)[0] + + # there are no isolated tiles or we don't care about them + # let's discard tile with greater ukeire/ukeire2 + filtered_options = sorted(filtered_options, key=lambda x: -getattr(x, ukeire_field)) + first_option = filtered_options[0] + + other_tiles_with_same_ukeire = [x for x in filtered_options + if getattr(x, ukeire_field) == getattr(first_option, ukeire_field)] + + # it will happen with shanten=1, all tiles will have ukeire_second == 0 + # or in tempai we can have several tiles with same ukeire + if other_tiles_with_same_ukeire: + return sorted(other_tiles_with_same_ukeire, key=lambda x: x.valuation)[0] + + # we have only one candidate to discard with greater ukeire + return first_option + + def process_discard_option(self, discard_option, closed_hand, force_discard=False, print_log=True): + if print_log: + DecisionsLogger.debug( + log.DISCARD, + context=discard_option + ) + + self.player.in_tempai = discard_option.shanten == 0 + self.ai.waiting = discard_option.waiting + self.ai.shanten = discard_option.shanten + self.ai.ukeire = discard_option.ukeire + self.ai.ukeire_second = discard_option.ukeire_second + + # when we called meld we don't need "smart" discard + if force_discard: + return discard_option.find_tile_in_hand(closed_hand) + + last_draw_34 = self.player.last_draw and self.player.last_draw // 4 or None + if self.player.last_draw not in AKA_DORA_LIST and last_draw_34 == discard_option.tile_to_discard: + return self.player.last_draw + else: + return discard_option.find_tile_in_hand(closed_hand) + + def calculate_second_level_ukeire(self, discard_option, tiles, melds): + not_suitable_tiles = self.ai.current_strategy and self.ai.current_strategy.not_suitable_tiles or [] + call_riichi = not self.player.is_open_hand + + # we are going to do manipulations that require player hand to be updated + # so we save original tiles here and restore it at the end of the function + player_tiles_original = self.player.tiles.copy() + + tile_in_hand = discard_option.find_tile_in_hand(self.player.closed_hand) + + self.player.tiles = tiles.copy() + self.player.tiles.remove(tile_in_hand) + + sum_tiles = 0 + sum_cost = 0 + for wait_34 in discard_option.waiting: + if self.player.is_open_hand and wait_34 in not_suitable_tiles: + continue + + closed_hand_34 = TilesConverter.to_34_array(self.player.closed_hand) + live_tiles = 4 - self.player.total_tiles(wait_34, closed_hand_34) + + if live_tiles == 0: + continue + + wait_136 = wait_34 * 4 + self.player.tiles.append(wait_136) + + results, shanten = self.find_discard_options( + self.player.tiles, + self.player.closed_hand, + melds + ) + results = [x for x in results if x.shanten == discard_option.shanten - 1] + + # let's take best ukeire here + if results: + result_has_atodzuke = False + if self.player.is_open_hand: + best_one = results[0] + best_ukeire = 0 + for result in results: + has_atodzuke = False + ukeire = 0 + for wait_34 in result.waiting: + if wait_34 in not_suitable_tiles: + has_atodzuke = True + else: + ukeire += result.wait_to_ukeire[wait_34] + + # let's consider atodzuke waits to be worse than non-atodzuke ones + if has_atodzuke: + ukeire /= 2 + + if (ukeire > best_ukeire) or (ukeire >= best_ukeire and not has_atodzuke): + best_ukeire = ukeire + best_one = result + result_has_atodzuke = has_atodzuke + else: + best_one = sorted(results, key=lambda x: -x.ukeire)[0] + best_ukeire = best_one.ukeire + + sum_tiles += best_ukeire * live_tiles + + # if we are going to have a tempai (on our second level) - let's also count its cost + if shanten == 0: + next_tile_in_hand = best_one.find_tile_in_hand(self.player.closed_hand) + self.player.tiles.remove(next_tile_in_hand) + cost_x_ukeire, _ = self._estimate_cost_x_ukeire(best_one, call_riichi=call_riichi) + # we reduce tile valuation for atodzuke + if result_has_atodzuke: + cost_x_ukeire /= 2 + sum_cost += cost_x_ukeire + self.player.tiles.append(next_tile_in_hand) + + self.player.tiles.remove(wait_136) + + discard_option.ukeire_second = sum_tiles + if discard_option.shanten == 1: + discard_option.second_level_cost = sum_cost + + # restore original state of player hand + self.player.tiles = player_tiles_original + + @staticmethod + def _filter_list_by_percentage(items, attribute, percentage): + filtered_options = [] + first_option = items[0] + ukeire_borders = round((getattr(first_option, attribute) / 100) * percentage) + for x in items: + if getattr(x, attribute) >= getattr(first_option, attribute) - ukeire_borders: + filtered_options.append(x) + return filtered_options + + @staticmethod + def _choose_ukeire_borders(first_option, border_percentage, border_field): + ukeire_borders = round((getattr(first_option, border_field) / 100) * border_percentage) + + if first_option.shanten == 0 and ukeire_borders < 2: + ukeire_borders = 2 + + if first_option.shanten == 1 and ukeire_borders < 4: + ukeire_borders = 4 + + if first_option.shanten >= 2 and ukeire_borders < 8: + ukeire_borders = 8 + + return ukeire_borders + + def _estimate_cost_x_ukeire(self, discard_option, call_riichi): + cost_x_ukeire_tsumo = 0 + cost_x_ukeire_ron = 0 + hand_cost_tsumo = 0 + hand_cost_ron = 0 + + is_furiten = self._is_discard_option_furiten(discard_option) + + for waiting in discard_option.waiting: + hand_value = self.player.ai.estimate_hand_value(waiting, + call_riichi=call_riichi, + is_tsumo=True) + if hand_value.error is None: + hand_cost_tsumo = hand_value.cost['main'] + 2 * hand_value.cost['additional'] + cost_x_ukeire_tsumo += hand_cost_tsumo * discard_option.wait_to_ukeire[waiting] + + if not is_furiten: + hand_value = self.player.ai.estimate_hand_value(waiting, + call_riichi=call_riichi, + is_tsumo=False) + if hand_value.error is None: + hand_cost_ron = hand_value.cost['main'] + cost_x_ukeire_ron += hand_cost_ron * discard_option.wait_to_ukeire[waiting] + + # these are abstract numbers used to compare different waits + # some don't have yaku, some furiten, etc. + # so we use an abstract formula of 1 tsumo cost + 3 ron costs + cost_x_ukeire = cost_x_ukeire_tsumo + 3 * cost_x_ukeire_ron + + if len(discard_option.waiting) == 1: + hand_cost = hand_cost_tsumo + 3 * hand_cost_ron + else: + hand_cost = None + + return cost_x_ukeire, hand_cost diff --git a/project/game/ai/first_version/main.py b/project/game/ai/first_version/main.py index 872d8c88..d8323f53 100644 --- a/project/game/ai/first_version/main.py +++ b/project/game/ai/first_version/main.py @@ -1,191 +1,131 @@ # -*- coding: utf-8 -*- -import logging +import copy +import utils.decisions_constants as log from mahjong.agari import Agari -from mahjong.constants import AKA_DORA_LIST, CHUN, HAKU, HATSU +from mahjong.constants import AKA_DORA_LIST, DISPLAY_WINDS from mahjong.hand_calculating.divider import HandDivider from mahjong.hand_calculating.hand import HandCalculator from mahjong.hand_calculating.hand_config import HandConfig from mahjong.meld import Meld from mahjong.shanten import Shanten from mahjong.tile import TilesConverter -from mahjong.utils import is_pair, is_pon +from mahjong.utils import is_pon from game.ai.base.main import InterfaceAI -from game.ai.discard import DiscardOption from game.ai.first_version.defence.main import DefenceHandler +from game.ai.first_version.hand_builder import HandBuilder +from game.ai.first_version.riichi import Riichi +from game.ai.first_version.strategies.chiitoitsu import ChiitoitsuStrategy +from game.ai.first_version.strategies.chinitsu import ChinitsuStrategy +from game.ai.first_version.strategies.formal_tempai import FormalTempaiStrategy from game.ai.first_version.strategies.honitsu import HonitsuStrategy from game.ai.first_version.strategies.main import BaseStrategy from game.ai.first_version.strategies.tanyao import TanyaoStrategy from game.ai.first_version.strategies.yakuhai import YakuhaiStrategy - -logger = logging.getLogger('ai') +from utils.decisions_logger import DecisionsLogger class ImplementationAI(InterfaceAI): - version = '0.3.2' + version = '0.4.0-dev' agari = None - shanten = None + shanten_calculator = None defence = None + riichi = None hand_divider = None finished_hand = None - last_discard_option = None - previous_shanten = 7 + shanten = 7 + ukeire = 0 + ukeire_second = 0 in_defence = False waiting = None current_strategy = None + last_discard_option = None + + hand_cache = {} def __init__(self, player): super(ImplementationAI, self).__init__(player) self.agari = Agari() - self.shanten = Shanten() + self.shanten_calculator = Shanten() self.defence = DefenceHandler(player) + self.riichi = Riichi(player) self.hand_divider = HandDivider() self.finished_hand = HandCalculator() - self.previous_shanten = 7 - self.current_strategy = None - self.waiting = [] - self.in_defence = False - self.last_discard_option = None + self.hand_builder = HandBuilder(player, self) - def init_hand(self): - """ - Let's decide what we will do with our hand (like open for tanyao and etc.) - """ - self.determine_strategy() + self.erase_state() def erase_state(self): - self.current_strategy = None + self.shanten = 7 + self.ukeire = 0 + self.ukeire_second = 0 self.in_defence = False + self.waiting = None + + self.current_strategy = None self.last_discard_option = None - def draw_tile(self, tile): - """ - :param tile: 136 tile format - :return: - """ - self.determine_strategy() + self.hand_cache = {} + + def init_hand(self): + DecisionsLogger.debug(log.INIT_HAND, context=[ + 'Round wind: {}'.format(DISPLAY_WINDS[self.table.round_wind_tile]), + 'Player wind: {}'.format(DISPLAY_WINDS[self.player.player_wind]), + 'Hand: {}'.format(self.player.format_hand_for_print()), + ]) + + self.shanten, _ = self.hand_builder.calculate_shanten(TilesConverter.to_34_array(self.player.tiles)) + + def draw_tile(self, tile_136): + self.determine_strategy(self.player.tiles) - def discard_tile(self, discard_tile): + def discard_tile(self, discard_tile, print_log=True): # 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 - return self.process_discard_option(self.last_discard_option, self.player.closed_hand, True) - - results, shanten = self.calculate_outs(self.player.tiles, - self.player.closed_hand, - self.player.open_hand_34_tiles) - - selected_tile = self.process_discard_options_and_select_tile_to_discard(results, shanten) - - # bot think that there is a threat on the table - # and better to fold - # if we can't find safe tiles, let's continue to build our hand - if self.defence.should_go_to_defence_mode(selected_tile): - if not self.in_defence: - logger.info('We decided to fold against other players') - self.in_defence = True - - defence_tile = self.defence.try_to_find_safe_tile_to_discard(results) - if defence_tile: - return self.process_discard_option(defence_tile, self.player.closed_hand) - else: - self.in_defence = False - - return self.process_discard_option(selected_tile, self.player.closed_hand) - - def process_discard_options_and_select_tile_to_discard(self, results, shanten, had_was_open=False): - tiles_34 = TilesConverter.to_34_array(self.player.tiles) - - # we had to update tiles value there - # because it is related with shanten number - for result in results: - result.tiles_count = self.count_tiles(result.waiting, tiles_34) - result.calculate_value(shanten) - - # current strategy can affect on our discard options - # so, don't use strategy specific choices for calling riichi - if self.current_strategy: - results = self.current_strategy.determine_what_to_discard(self.player.closed_hand, - results, - shanten, - False, - None, - had_was_open) - - return self.chose_tile_to_discard(results) - - def calculate_outs(self, tiles, closed_hand, open_sets_34=None): - """ - :param tiles: array of tiles in 136 format - :param closed_hand: array of tiles in 136 format - :param open_sets_34: array of array with tiles in 34 format - :return: - """ - tiles_34 = TilesConverter.to_34_array(tiles) - closed_tiles_34 = TilesConverter.to_34_array(closed_hand) - is_agari = self.agari.is_agari(tiles_34, self.player.open_hand_34_tiles) - - results = [] - for hand_tile in range(0, 34): - if not closed_tiles_34[hand_tile]: - continue - - tiles_34[hand_tile] -= 1 - - shanten = self.shanten.calculate_shanten(tiles_34, open_sets_34) - - waiting = [] - for j in range(0, 34): - if hand_tile == j or tiles_34[j] == 4: - continue - - tiles_34[j] += 1 - if self.shanten.calculate_shanten(tiles_34, open_sets_34) == shanten - 1: - waiting.append(j) - tiles_34[j] -= 1 - - tiles_34[hand_tile] += 1 + return self.hand_builder.process_discard_option(self.last_discard_option, self.player.closed_hand, True) - if waiting: - results.append(DiscardOption(player=self.player, - shanten=shanten, - tile_to_discard=hand_tile, - waiting=waiting, - tiles_count=self.count_tiles(waiting, tiles_34))) + return self.hand_builder.discard_tile( + self.player.tiles, + self.player.closed_hand, + self.player.melds, + print_log + ) - if is_agari: - shanten = Shanten.AGARI_STATE - else: - shanten = self.shanten.calculate_shanten(tiles_34, open_sets_34) + def try_to_call_meld(self, tile_136, is_kamicha_discard): + tiles_136_previous = self.player.tiles[:] + tiles_136 = tiles_136_previous + [tile_136] + self.determine_strategy(tiles_136) - return results, shanten + if not self.current_strategy: + return None, None - def count_tiles(self, waiting, tiles_34): - n = 0 - for item in waiting: - n += 4 - self.player.total_tiles(item, tiles_34) - return n + tiles_34_previous = TilesConverter.to_34_array(tiles_136_previous) + previous_shanten, _ = self.hand_builder.calculate_shanten(tiles_34_previous, self.player.meld_34_tiles) - def try_to_call_meld(self, tile, is_kamicha_discard): - if not self.current_strategy: + 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, is_kamicha_discard) - tile_to_discard = 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 - tile_to_discard = discard_option.tile_to_discard - return meld, tile_to_discard + DecisionsLogger.debug(log.MELD_CALL, 'Try to call meld', context=[ + 'Hand: {}'.format(self.player.format_hand_for_print(tile_136)), + 'Meld: {}'.format(meld), + 'Discard after meld: {}'.format(discard_option) + ]) + + return meld, discard_option - def determine_strategy(self): + def determine_strategy(self, tiles_136): # 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 @@ -193,97 +133,42 @@ def determine_strategy(self): old_strategy = self.current_strategy self.current_strategy = None - # order is important - strategies = [ - YakuhaiStrategy(BaseStrategy.YAKUHAI, self.player), - HonitsuStrategy(BaseStrategy.HONITSU, self.player), - ] + # 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(ChiitoitsuStrategy(BaseStrategy.CHIITOITSU, self.player)) + strategies.append(FormalTempaiStrategy(BaseStrategy.FORMAL_TEMPAI, self.player)) + for strategy in strategies: - if strategy.should_activate_strategy(): + if strategy.should_activate_strategy(tiles_136): self.current_strategy = strategy + break if self.current_strategy: if not old_strategy or self.current_strategy.type != old_strategy.type: - message = '{} switched to {} strategy'.format(self.player.name, self.current_strategy) - if old_strategy: - message += ' from {}'.format(old_strategy) - logger.debug(message) - logger.debug('With hand: {}'.format(TilesConverter.to_one_line_string(self.player.tiles))) + DecisionsLogger.debug( + log.STRATEGY_ACTIVATE, + context=self.current_strategy, + ) if not self.current_strategy and old_strategy: - logger.debug('{} gave up on {}'.format(self.player.name, old_strategy)) + DecisionsLogger.debug(log.STRATEGY_DROP, context=old_strategy) return self.current_strategy and True or False - def chose_tile_to_discard(self, results: [DiscardOption]) -> DiscardOption: - """ - Try to find best tile to discard, based on different valuations - """ - - def sorting(x): - # - is important for x.tiles_count - # in that case we will discard tile that will give for us more tiles - # to complete a hand - return x.shanten, -x.tiles_count, x.valuation - - had_to_be_discarded_tiles = [x for x in results if x.had_to_be_discarded] - if had_to_be_discarded_tiles: - had_to_be_discarded_tiles = sorted(had_to_be_discarded_tiles, key=sorting) - selected_tile = had_to_be_discarded_tiles[0] - else: - results = sorted(results, key=sorting) - # remove needed tiles from discard options - results = [x for x in results if not x.had_to_be_saved] - - # let's chose most valuable tile first - temp_tile = results[0] - # and let's find all tiles with same shanten - results_with_same_shanten = [x for x in results if x.shanten == temp_tile.shanten] - possible_options = [temp_tile] - for discard_option in results_with_same_shanten: - # there is no sense to check already chosen tile - if discard_option.tile_to_discard == temp_tile.tile_to_discard: - continue - - # we don't need to select tiles almost dead waits - if discard_option.tiles_count <= 2: - continue - - # let's check all other tiles with same shanten - # maybe we can find tiles that have almost same tiles count number - if temp_tile.tiles_count - 2 < discard_option.tiles_count < temp_tile.tiles_count + 2: - possible_options.append(discard_option) - - # let's sort got tiles by value and let's chose less valuable tile to discard - possible_options = sorted(possible_options, key=lambda x: x.valuation) - selected_tile = possible_options[0] - - return selected_tile - - def process_discard_option(self, discard_option, closed_hand, force_discard=False): - self.waiting = discard_option.waiting - self.player.ai.previous_shanten = discard_option.shanten - self.player.in_tempai = self.player.ai.previous_shanten == 0 - - # when we called meld we don't need "smart" discard - if force_discard: - return discard_option.find_tile_in_hand(closed_hand) - - last_draw_34 = self.player.last_draw and self.player.last_draw // 4 or None - if self.player.last_draw not in AKA_DORA_LIST and last_draw_34 == discard_option.tile_to_discard: - return self.player.last_draw - else: - return discard_option.find_tile_in_hand(closed_hand) - - def estimate_hand_value(self, win_tile, tiles=None, call_riichi=False): + def estimate_hand_value(self, win_tile, tiles=None, call_riichi=False, is_tsumo=False): """ :param win_tile: 34 tile format :param tiles: :param call_riichi: + :param is_tsumo :return: """ win_tile *= 4 @@ -293,16 +178,17 @@ def estimate_hand_value(self, win_tile, tiles=None, call_riichi=False): win_tile += 1 if not tiles: - tiles = self.player.tiles + tiles = copy.copy(self.player.tiles) tiles += [win_tile] config = HandConfig( is_riichi=call_riichi, player_wind=self.player.player_wind, - round_wind=self.player.table.round_wind, + round_wind=self.player.table.round_wind_tile, has_aka_dora=self.player.table.has_aka_dora, - has_open_tanyao=self.player.table.has_open_tanyao + has_open_tanyao=self.player.table.has_open_tanyao, + is_tsumo=is_tsumo, ) result = self.finished_hand.estimate_hand_value(tiles, @@ -313,49 +199,22 @@ def estimate_hand_value(self, win_tile, tiles=None, call_riichi=False): return result def should_call_riichi(self): - # empty waiting can be found in some cases - if not self.waiting: - return False - - if self.in_defence: - return False - - # we have a good wait, let's riichi - if len(self.waiting) > 1: - return True - - waiting = self.waiting[0] - tiles = self.player.closed_hand + [waiting * 4] - closed_melds = [x for x in self.player.melds if not x.opened] - for meld in closed_melds: - tiles.extend(meld.tiles[:3]) - - tiles_34 = TilesConverter.to_34_array(tiles) - - results = self.hand_divider.divide_hand(tiles_34) - result = results[0] - - count_of_pairs = len([x for x in result if is_pair(x)]) - # with chitoitsu we can call a riichi with pair wait - if count_of_pairs == 7: - return True - - for hand_set in result: - # better to not call a riichi for a pair wait - # it can be easily improved - if is_pair(hand_set) and waiting in hand_set: - return False - - return True + return self.riichi.should_call_riichi() - def should_call_kan(self, tile, open_kan): + def should_call_kan(self, tile, open_kan, from_riichi=False): """ Method will decide should we call a kan, or upgrade pon to kan :param tile: 136 tile format :param open_kan: boolean + :param from_riichi: boolean :return: kan type """ + + # we can't call kan on the latest tile + if self.table.count_of_remaining_tiles <= 1: + return None + # we don't need to add dora for other players if self.player.ai.in_defence: return None @@ -375,39 +234,85 @@ def should_call_kan(self, tile, open_kan): tile_34 = tile // 4 tiles_34 = TilesConverter.to_34_array(self.player.tiles) + closed_hand_34 = TilesConverter.to_34_array(self.player.closed_hand) - pon_melds = [x for x in self.player.open_hand_34_tiles if is_pon(x)] + + melds_34 = copy.copy(self.player.meld_34_tiles) + tiles = copy.copy(self.player.tiles) + closed_hand_tiles = copy.copy(self.player.closed_hand) + + new_shanten = 0 + previous_shanten = 0 + new_waits_count = 0 + previous_waits_count = 0 # let's check can we upgrade opened pon to the kan - if pon_melds: - for meld in pon_melds: - # tile is equal to our already opened pon, - # so let's call chankan! - if tile_34 in meld: - return Meld.CHANKAN - - count_of_needed_tiles = 4 - # for open kan 3 tiles is enough to call a kan - if open_kan: - count_of_needed_tiles = 3 - - # we have 3 tiles in our hand, - # so we can try to call closed meld - if closed_hand_34[tile_34] == count_of_needed_tiles: - if not open_kan: - # to correctly count shanten in the hand - # we had do subtract drown tile + pon_melds = [x for x in self.player.meld_34_tiles if is_pon(x)] + has_shouminkan_candidate = False + for meld in pon_melds: + # tile is equal to our already opened pon + if tile_34 in meld: + has_shouminkan_candidate = True + + tiles.append(tile) + closed_hand_tiles.append(tile) + + previous_shanten, previous_waits_count = self._calculate_shanten_for_kan( + tiles, + closed_hand_tiles, + self.player.melds + ) + + tiles_34 = TilesConverter.to_34_array(tiles) tiles_34[tile_34] -= 1 - melds = self.player.open_hand_34_tiles - previous_shanten = self.shanten.calculate_shanten(tiles_34, melds) + new_waiting, new_shanten = self.hand_builder.calculate_waits( + tiles_34, + self.player.meld_34_tiles + ) + new_waits_count = self.hand_builder.count_tiles(new_waiting, tiles_34) + + if not has_shouminkan_candidate: + # we don't have enough tiles in the hand + if closed_hand_34[tile_34] != 3: + return None + + if open_kan or from_riichi: + # this 4 tiles can only be used in kan, no other options + previous_waiting, previous_shanten = self.hand_builder.calculate_waits(tiles_34, melds_34) + previous_waits_count = self.hand_builder.count_tiles(previous_waiting, closed_hand_34) + else: + tiles.append(tile) + closed_hand_tiles.append(tile) + + previous_shanten, previous_waits_count = self._calculate_shanten_for_kan( + tiles, + closed_hand_tiles, + self.player.melds + ) + + # shanten calculator doesn't like working with kans, so we pretend it's a pon + melds_34 += [[tile_34, tile_34, tile_34]] + new_waiting, new_shanten = self.hand_builder.calculate_waits(tiles_34, melds_34) + + closed_hand_34[tile_34] = 4 + new_waits_count = self.hand_builder.count_tiles(new_waiting, closed_hand_34) + + # it is possible that we don't have results here + # when we are in agari state (but without yaku) + if previous_shanten is None: + return None + + # it is not possible to reduce number of shanten by calling a kan + assert new_shanten >= previous_shanten - melds += [[tile_34, tile_34, tile_34]] - new_shanten = self.shanten.calculate_shanten(tiles_34, melds) + # if shanten number is the same, we should only call kan if ukeire didn't become worse + if new_shanten == previous_shanten: + # we cannot improve ukeire by calling kan (not considering the tile we drew from the dead wall) + assert new_waits_count <= previous_waits_count - # called kan will not ruin our hand - if new_shanten <= previous_shanten: - return Meld.KAN + if new_waits_count == previous_waits_count: + return has_shouminkan_candidate and Meld.CHANKAN or Meld.KAN return None @@ -420,8 +325,10 @@ def enemy_called_riichi(self, enemy_seat): it is affect open hand decisions :return: """ + if self.defence.should_go_to_defence_mode(): self.in_defence = True + DecisionsLogger.debug(log.DEFENCE_ACTIVATE) @property def enemy_players(self): @@ -429,3 +336,21 @@ def enemy_players(self): Return list of players except our bot """ return self.player.table.players[1:] + + def _calculate_shanten_for_kan(self, tiles, closed_hand_tiles, melds): + previous_results, previous_shanten = self.hand_builder.find_discard_options( + tiles, + closed_hand_tiles, + melds + ) + + previous_results = [x for x in previous_results if x.shanten == previous_shanten] + + # it is possible that we don't have results here + # when we are in agari state (but without yaku) + if not previous_results: + return None, None + + previous_waits_cnt = sorted(previous_results, key=lambda x: -x.ukeire)[0].ukeire + + return previous_shanten, previous_waits_cnt diff --git a/project/game/ai/first_version/riichi.py b/project/game/ai/first_version/riichi.py new file mode 100644 index 00000000..321037f0 --- /dev/null +++ b/project/game/ai/first_version/riichi.py @@ -0,0 +1,224 @@ +from mahjong.tile import TilesConverter +from mahjong.utils import is_honor, simplify, is_pair, is_chi + + +class Riichi: + + def __init__(self, player): + self.player = player + + def should_call_riichi(self): + # empty waiting can be found in some cases + if not self.player.ai.waiting: + return False + + if self.player.ai.in_defence: + return False + + # don't call karaten riichi + count_tiles = self.player.ai.hand_builder.count_tiles( + self.player.ai.waiting, + TilesConverter.to_34_array(self.player.closed_hand) + ) + if count_tiles == 0: + return False + + # It is daburi! + first_discard = self.player.round_step == 1 + if first_discard and not self.player.table.meld_was_called: + return True + + if len(self.player.ai.waiting) == 1: + return self._should_call_riichi_one_sided() + + return self._should_call_riichi_many_sided() + + 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_call_riichi_many_sided(self): + count_tiles = self.player.ai.hand_builder.count_tiles( + self.player.ai.waiting, + TilesConverter.to_34_array(self.player.closed_hand) + ) + hand_costs = [] + waits_with_yaku = 0 + for waiting in self.player.ai.waiting: + hand_value = self.player.ai.estimate_hand_value(waiting, call_riichi=False) + if hand_value.error is None: + hand_costs.append(hand_value.cost['main']) + if hand_value.yaku is not None and hand_value.cost is not None: + waits_with_yaku += 1 + + # if we have yaku on every wait + if waits_with_yaku == len(self.player.ai.waiting): + min_cost = min(hand_costs) + + # let's not riichi this bad wait + if count_tiles <= 2: + return False + + # if wait is slighly better, we will riichi only a cheap hand + if count_tiles <= 4: + if self.player.is_dealer and min_cost >= 7700: + return False + + if not self.player.is_dealer and min_cost >= 5200: + return False + + return True + + # wait is even better, but still don't call riichi on damaten mangan + if count_tiles <= 6: + if self.player.is_dealer and min_cost >= 11600: + return False + + if not self.player.is_dealer and min_cost >= 7700: + return False + + return True + + # if wait is good we only damaten haneman + if self.player.is_dealer and min_cost >= 18000: + return False + + if not self.player.is_dealer and min_cost >= 12000: + return False + + return True + + # if we don't have yaku on every wait and it's two-sided or more, we call riichi + return True diff --git a/project/game/ai/first_version/strategies/chiitoitsu.py b/project/game/ai/first_version/strategies/chiitoitsu.py new file mode 100644 index 00000000..fb6c1d14 --- /dev/null +++ b/project/game/ai/first_version/strategies/chiitoitsu.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +from mahjong.tile import TilesConverter + +from game.ai.first_version.strategies.main import BaseStrategy + + +class ChiitoitsuStrategy(BaseStrategy): + min_shanten = 2 + + def should_activate_strategy(self, tiles_136): + """ + We can go for chiitoitsu strategy if we have 5 pairs + """ + + result = super(ChiitoitsuStrategy, self).should_activate_strategy(tiles_136) + if not result: + return False + + tiles_34 = TilesConverter.to_34_array(self.player.tiles) + + num_pairs = len([x for x in range(0, 34) if tiles_34[x] == 2]) + num_pons = len([x for x in range(0, 34) if tiles_34[x] == 3]) + + # for now we don't consider chiitoitsu with less than 5 pair + if num_pairs < 5: + return False + + # if we have 5 pairs and tempai, this is obviously not chiitoitsu + if num_pairs == 5 and self.player.ai.shanten == 0: + return False + + # for now we won't go for chiitoitsu if we have 5 pairs and pon + if num_pairs == 5 and num_pons > 0: + return False + + return True + + def is_tile_suitable(self, tile): + """ + For chiitoitsu we don't have any limits + :param tile: 136 tiles format + :return: True + """ + return True + + def try_to_call_meld(self, tile, is_kamicha_discard, new_tiles): + """ + Never meld with chiitoitsu + :param tile: 136 format tile + :param is_kamicha_discard: boolean + :param new_tiles: + :return: Meld and DiscardOption objects + """ + return None, None diff --git a/project/game/ai/first_version/strategies/chinitsu.py b/project/game/ai/first_version/strategies/chinitsu.py new file mode 100644 index 00000000..7de7074c --- /dev/null +++ b/project/game/ai/first_version/strategies/chinitsu.py @@ -0,0 +1,176 @@ +# -*- coding: utf-8 -*- +from mahjong.tile import TilesConverter +from mahjong.utils import count_tiles_by_suits, is_tile_strictly_isolated +from mahjong.utils import is_man, is_pin, is_sou, plus_dora, is_aka_dora, is_honor + +from game.ai.first_version.strategies.main import BaseStrategy +from game.ai.first_version.strategies.honitsu import HonitsuStrategy + + +class ChinitsuStrategy(BaseStrategy): + min_shanten = 4 + + chosen_suit = None + + dora_count_suitable = 0 + dora_count_not_suitable = 0 + + 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 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) + + if is_aka_dora(tile_136, self.player.table.has_aka_dora): + dora_count += 1 + + 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/first_version/strategies/formal_tempai.py b/project/game/ai/first_version/strategies/formal_tempai.py new file mode 100644 index 00000000..6b40dce0 --- /dev/null +++ b/project/game/ai/first_version/strategies/formal_tempai.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +from game.ai.first_version.strategies.main import BaseStrategy + + +class FormalTempaiStrategy(BaseStrategy): + + def should_activate_strategy(self, tiles_136): + """ + 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/first_version/strategies/honitsu.py b/project/game/ai/first_version/strategies/honitsu.py index a9048194..dd95fdd5 100644 --- a/project/game/ai/first_version/strategies/honitsu.py +++ b/project/game/ai/first_version/strategies/honitsu.py @@ -1,56 +1,119 @@ # -*- coding: utf-8 -*- from mahjong.tile import TilesConverter -from mahjong.utils import count_tiles_by_suits, is_honor, simplify +from mahjong.utils import count_tiles_by_suits, simplify, is_tile_strictly_isolated +from mahjong.utils import is_man, is_pin, is_sou, plus_dora, is_aka_dora, is_honor from game.ai.first_version.strategies.main import BaseStrategy class HonitsuStrategy(BaseStrategy): - REQUIRED_TILES = 10 min_shanten = 4 chosen_suit = None - def should_activate_strategy(self): + dora_count_other_suits_not_isolated = 0 + tiles_count_other_suits = 0 + tiles_count_other_suits_not_isolated = 0 + + def should_activate_strategy(self, tiles_136): """ - We can go for honitsu/chinitsu strategy if we have prevalence of one suit and honor tiles - :return: boolean + We can go for honitsu strategy if we have prevalence of one suit and honor tiles """ - result = super(HonitsuStrategy, self).should_activate_strategy() + result = super(HonitsuStrategy, self).should_activate_strategy(tiles_136) if not result: return False - tiles_34 = TilesConverter.to_34_array(self.player.tiles) + tiles_34 = TilesConverter.to_34_array(tiles_136) suits = count_tiles_by_suits(tiles_34) - honor = [x for x in suits if x['name'] == 'honor'][0] 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_pairs = 0 - for x in range(0, 34): - if tiles_34[x] >= 2: - count_of_pairs += 1 - suits.remove(suit) - count_of_ryanmens = self._find_ryanmen_waits(tiles_34, suits[0]['function']) - count_of_ryanmens += self._find_ryanmen_waits(tiles_34, suits[1]['function']) + 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']) - # it is a bad idea go for honitsu with ryanmen in other suit - if count_of_ryanmens > 0 and not self.player.is_open_hand: + 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 - # we need to have prevalence of one suit and completed forms in the hand - # for now let's check only pairs in the hand - # TODO check ryanmen forms as well and honor tiles count - if suit['count'] + honor['count'] >= HonitsuStrategy.REQUIRED_TILES: - self.chosen_suit = suit['function'] - return count_of_pairs > 0 - else: + # 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_tile_suitable(self, tile): """ We can use only tiles of chosen suit and honor tiles @@ -60,7 +123,71 @@ def is_tile_suitable(self, tile): tile //= 4 return self.chosen_suit(tile) or is_honor(tile) - def _find_ryanmen_waits(self, tiles, suit): + 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_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 _initialize_honitsu_dora_count(self, tiles_136, suit): + tiles_34 = TilesConverter.to_34_array(tiles_136) + + 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) + + if is_aka_dora(tile_136, self.player.table.has_aka_dora): + dora_count += 1 + + if is_man(tile_34): + if not is_tile_strictly_isolated(tiles_34, tile_34): + dora_count_man_not_isolated += dora_count + + if is_pin(tile_34): + if not is_tile_strictly_isolated(tiles_34, tile_34): + dora_count_pin_not_isolated += dora_count + + if is_sou(tile_34): + if not is_tile_strictly_isolated(tiles_34, tile_34): + dora_count_sou_not_isolated += dora_count + + if suit['name'] == '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_other_suits_not_isolated = dora_count_man_not_isolated + dora_count_pin_not_isolated + elif suit['name'] == '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] @@ -86,3 +213,52 @@ def _find_ryanmen_waits(self, tiles, suit): 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/first_version/strategies/main.py b/project/game/ai/first_version/strategies/main.py index f062d6fe..37824471 100644 --- a/project/game/ai/first_version/strategies/main.py +++ b/project/game/ai/first_version/strategies/main.py @@ -1,46 +1,76 @@ # -*- coding: utf-8 -*- +import utils.decisions_constants as log + from mahjong.meld import Meld from mahjong.tile import TilesConverter -from mahjong.utils import is_man, is_pin, is_sou, is_pon, is_chi +from mahjong.utils import is_man, is_pin, is_sou, is_pon, is_chi, plus_dora, is_aka_dora, is_honor, is_terminal + +from utils.decisions_logger import DecisionsLogger class BaseStrategy(object): YAKUHAI = 0 HONITSU = 1 TANYAO = 2 + FORMAL_TEMPAI = 3 + CHINITSU = 4 + CHIITOITSU = 5 TYPES = { YAKUHAI: 'Yakuhai', HONITSU: 'Honitsu', TANYAO: 'Tanyao', + FORMAL_TEMPAI: 'Formal Tempai', + CHINITSU: 'Chinitsu', + CHIITOITSU: 'Chiitoitsu' } + 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 should_activate_strategy(self): + def should_activate_strategy(self, tiles_136): """ Based on player hand and table situation we can determine should we use this strategy or not. - For now default rule for all strategies: don't open hand with 5+ pairs + :param: tiles_136 :return: boolean """ - if self.player.is_open_hand: - return True + self.calculate_dora_count(tiles_136) - tiles_34 = TilesConverter.to_34_array(self.player.tiles) - count_of_pairs = len([x for x in range(0, 34) if tiles_34[x] >= 2]) + return True + + def can_meld_into_agari(self): + """ + Is melding into agari allowed with this strategy + :return: boolean + """ + # 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 count_of_pairs < 5 + return False def is_tile_suitable(self, tile): """ @@ -50,45 +80,29 @@ def is_tile_suitable(self, tile): """ raise NotImplemented() - def determine_what_to_discard(self, closed_hand, outs_results, shanten, for_open_hand, tile_for_open_hand, - hand_was_open=False): - """ - - "for_open_hand" and "tile_for_open_hand" we had to use when we want to - determine what melds will be open - - "hand_was_open" we will use in rare cases - when we open hand and before meld was added to the player - it happens between we send a message to tenhou and tenhou send confirmation message to us - sometimes we failed to call a meld because of other player - - :param closed_hand: array of 136 tiles format - :param outs_results: dict - :param shanten: number of shanten - :param for_open_hand: boolean - :param tile_for_open_hand: 136 tile format - :param hand_was_open: boolean - :return: array of DiscardOption - """ + 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 outs_results + return discard_options # mark all not suitable tiles as ready to discard # even if they not should be discarded by uke-ire - for j in outs_results: - if not self.is_tile_suitable(j.tile_to_discard * 4): - j.had_to_be_discarded = True + for x in discard_options: + if not self.is_tile_suitable(x.tile_to_discard * 4): + x.had_to_be_discarded = True - return outs_results + return discard_options - def try_to_call_meld(self, tile, is_kamicha_discard): + 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 Meld object and tile to discard :param tile: 136 format tile :param is_kamicha_discard: boolean + :param new_tiles: :return: Meld and DiscardOption objects """ if self.player.in_riichi: @@ -108,7 +122,6 @@ def try_to_call_meld(self, tile, is_kamicha_discard): return None, None discarded_tile = tile // 4 - new_tiles = self.player.tiles[:] + [tile] closed_hand_34 = TilesConverter.to_34_array(closed_hand + [tile]) combinations = [] @@ -139,9 +152,12 @@ def try_to_call_meld(self, tile, is_kamicha_discard): 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) + combinations = self.player.ai.hand_divider.find_valid_combinations( + closed_hand_34, + first_limit, + second_limit, + True + ) if combinations: combinations = combinations[0] @@ -170,93 +186,106 @@ def try_to_call_meld(self, tile, is_kamicha_discard): if not possible_melds: return None, None - best_meld_34 = self._find_best_meld_to_open(possible_melds, new_tiles) - if best_meld_34: - # we need to calculate count of shanten with supposed meld - # to prevent bad hand openings - melds = self.player.open_hand_34_tiles + [best_meld_34] - outs_results, shanten = self.player.ai.calculate_outs(new_tiles, closed_hand, melds) + chosen_meld = self._find_best_meld_to_open(possible_melds, new_tiles, closed_hand, tile) + selected_tile = chosen_meld['discard_tile'] + meld = chosen_meld['meld'] - # each strategy can use their own value to min shanten number - if shanten > self.min_shanten: - return None, None + 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 - # we can't improve hand, so we don't need to open it - if not outs_results: - return None, None + # each strategy can use their own value to min shanten number + if shanten > self.min_shanten: + 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: + return None, None + + return meld, selected_tile + + 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 - # 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 self.meld_had_to_be_called(tile) and shanten >= self.player.ai.previous_shanten: - return None, None + def calculate_dora_count(self, tiles_136): + self.dora_count_central = 0 + self.dora_count_not_central = 0 + self.aka_dora_count = 0 - meld_type = is_chi(best_meld_34) and Meld.CHI or Meld.PON - best_meld_34.remove(discarded_tile) + for tile_136 in tiles_136: + tile_34 = tile_136 // 4 - first_tile = TilesConverter.find_34_tile_in_136_array(best_meld_34[0], closed_hand) - closed_hand.remove(first_tile) + dora_count = plus_dora(tile_136, self.player.table.dora_indicators) - second_tile = TilesConverter.find_34_tile_in_136_array(best_meld_34[1], closed_hand) - closed_hand.remove(second_tile) + 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 _find_best_meld_to_open(self, possible_melds, new_tiles, closed_hand, discarded_tile): + discarded_tile_34 = discarded_tile // 4 + + final_results = [] + for meld_34 in possible_melds: + meld_34_copy = meld_34.copy() + closed_hand_copy = closed_hand.copy() + + meld_type = is_chi(meld_34_copy) and Meld.CHI or Meld.PON + 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, - tile + discarded_tile ] meld = Meld() meld.type = meld_type meld.tiles = sorted(tiles) - # we had to be sure that all our discard results exists in the closed hand - filtered_results = [] - for result in outs_results: - if result.find_tile_in_hand(closed_hand): - filtered_results.append(result) - - # we can't discard anything, so let's not open our hand - if not filtered_results: - return None, None + melds = self.player.melds + [meld] - selected_tile = self.player.ai.process_discard_options_and_select_tile_to_discard( - filtered_results, - shanten, - had_was_open=True + selected_tile = self.player.ai.hand_builder.choose_tile_to_discard( + new_tiles, + closed_hand_copy, + melds, + print_log=False ) - return meld, selected_tile + 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 + }) - return None, None + final_results = sorted(final_results, key=lambda x: (x['discard_tile'].shanten, + -x['discard_tile'].ukeire, + x['discard_tile'].valuation)) - 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 _find_best_meld_to_open(self, possible_melds, completed_hand): - """ - :param possible_melds: - :param completed_hand: - :return: - """ - - if len(possible_melds) == 1: - return possible_melds[0] - - # We will replace possible set with one completed pon set - # and we will calculate remaining shanten in the hand - # and chose the hand with min shanten count - completed_hand_34 = TilesConverter.to_34_array(completed_hand) - - results = [] - for meld in possible_melds: - melds = self.player.open_hand_34_tiles + [meld] - shanten = self.player.ai.shanten.calculate_shanten(completed_hand_34, melds) - results.append({'shanten': shanten, 'meld': meld}) + DecisionsLogger.debug(log.MELD_PREPARE, 'Options with meld calling', context=final_results) - results = sorted(results, key=lambda i: i['shanten']) - return results[0]['meld'] + return final_results[0] diff --git a/project/game/ai/first_version/strategies/tanyao.py b/project/game/ai/first_version/strategies/tanyao.py index ec88af1b..0c6e1d50 100644 --- a/project/game/ai/first_version/strategies/tanyao.py +++ b/project/game/ai/first_version/strategies/tanyao.py @@ -1,6 +1,8 @@ # -*- coding: utf-8 -*- from mahjong.constants import TERMINAL_INDICES, HONOR_INDICES from mahjong.tile import TilesConverter +from mahjong.utils import is_honor +from mahjong.utils import is_tile_strictly_isolated from game.ai.first_version.strategies.main import BaseStrategy @@ -9,21 +11,28 @@ class TanyaoStrategy(BaseStrategy): min_shanten = 3 not_suitable_tiles = TERMINAL_INDICES + HONOR_INDICES - def should_activate_strategy(self): + def should_activate_strategy(self, tiles_136): """ 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() + 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: @@ -38,6 +47,16 @@ def should_activate_strategy(self): 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: @@ -53,6 +72,17 @@ def should_activate_strategy(self): 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 + # 123 and 789 indices indices = [ [0, 1, 2], [6, 7, 8], @@ -67,36 +97,66 @@ def should_activate_strategy(self): if first >= 1 and second >= 1 and third >= 1: return False - return True + # 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 - def determine_what_to_discard(self, closed_hand, outs_results, shanten, for_open_hand, tile_for_open_hand, - hand_was_open=False): - if tile_for_open_hand: - tile_for_open_hand //= 4 + return True - is_open_hand = self.player.is_open_hand or hand_was_open + def determine_what_to_discard(self, discard_options, hand, open_melds): + is_open_hand = len(open_melds) > 0 + + # 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 * 4): + item.had_to_be_discarded = True + not_suitable_tiles.append(item) + continue - if shanten == 0 and is_open_hand: - results = [] # there is no sense to wait 1-4 if we have open hand - for item in outs_results: - all_waiting_are_fine = all([self.is_tile_suitable(x * 4) for x in item.waiting]) + # 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) - # we don't have a choice - # we had to have on bad wait - if not results: - return outs_results - - return results - else: - return super(TanyaoStrategy, self).determine_what_to_discard(closed_hand, - outs_results, - shanten, - for_open_hand, - tile_for_open_hand, - hand_was_open) + 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): """ diff --git a/project/game/ai/first_version/strategies/yakuhai.py b/project/game/ai/first_version/strategies/yakuhai.py index 6d9d941f..a6a10a92 100644 --- a/project/game/ai/first_version/strategies/yakuhai.py +++ b/project/game/ai/first_version/strategies/yakuhai.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +from mahjong.constants import EAST, SOUTH from mahjong.meld import Meld from mahjong.tile import TilesConverter @@ -6,27 +7,139 @@ class YakuhaiStrategy(BaseStrategy): + valued_pairs = None + has_valued_pon = None - def should_activate_strategy(self): + def __init__(self, strategy_type, player): + super().__init__(strategy_type, player) + + self.valued_pairs = [] + self.has_valued_pon = False + self.last_chance_calls = [] + + def should_activate_strategy(self, tiles_136): """ 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() + result = super(YakuhaiStrategy, self).should_activate_strategy(tiles_136) if not result: return False - tiles_34 = TilesConverter.to_34_array(self.player.tiles) - valued_pairs = [x for x in self.player.valued_honors if tiles_34[x] >= 2] + 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.has_valued_pon = len([x for x in self.player.valued_honors if player_hand_tiles_34[x] >= 3]) >= 1 + + opportunity_to_meld_yakuhai = False - for pair in valued_pairs: - # we have valued pair in the hand and there is enough tiles + 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 self.player.total_tiles(pair, tiles_34) < 4: + if opportunity_to_meld_yakuhai or self.player.total_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_pon: + 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 + + # If we have 1+ dora in the hand and there are 2+ valuable pairs let's open hand + if len(self.valued_pairs) >= 2 and 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.total_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 return False + def determine_what_to_discard(self, discard_options, hand, open_melds): + is_open_hand = len(open_melds) > 0 + + 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 == Meld.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 in valued_pairs: + continue + elif len(valued_pons) == 1: + # don't destroy our only yakuhai pon + if item.tile_to_discard 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: + if 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 @@ -35,66 +148,49 @@ def is_tile_suitable(self, tile): """ return True - def determine_what_to_discard(self, closed_hand, outs_results, shanten, for_open_hand, tile_for_open_hand, - hand_was_open=False): - if tile_for_open_hand: - tile_for_open_hand //= 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] - - # when we trying to open hand with tempai state, we need to chose a valued pair waiting - if shanten == 0 and valued_pairs and for_open_hand and tile_for_open_hand not in valued_pairs: - valued_pair = valued_pairs[0] - - results = [] - for item in outs_results: - if valued_pair in item.waiting: - results.append(item) - return results - - if self.player.is_open_hand: - has_yakuhai_pon = any([self._is_yakuhai_pon(meld) for meld in self.player.melds]) - # we opened our hand for atodzuke - if not has_yakuhai_pon: - for item in outs_results: - for valued_pair in valued_pairs: - if valued_pair == item.tile_to_discard: - item.had_to_be_saved = True - - return super(YakuhaiStrategy, self).determine_what_to_discard(closed_hand, - outs_results, - shanten, - for_open_hand, - tile_for_open_hand, - hand_was_open) - def meld_had_to_be_called(self, tile): - # for closed hand we don't need to open hand with special conditions - if not self.player.is_open_hand: - return False - 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 meld in self.player.melds: - # for big shanten number we don't need to check already opened pon set, - # because it will improve pur hand anyway - if self.player.ai.previous_shanten >= 1: - break + # 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 - # 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 - # open hand for valued pon + # 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_pon: + 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 _is_yakuhai_pon(self, meld): return meld.type == Meld.PON and meld.tiles[0] // 4 in self.player.valued_honors diff --git a/project/game/ai/first_version/tests/strategies/__init__.py b/project/game/ai/first_version/tests/strategies/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/project/game/ai/first_version/tests/strategies/tests_chiitoitsu.py b/project/game/ai/first_version/tests/strategies/tests_chiitoitsu.py new file mode 100644 index 00000000..07a4e23f --- /dev/null +++ b/project/game/ai/first_version/tests/strategies/tests_chiitoitsu.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +import unittest + +from mahjong.tests_mixin import TestMixin + +from game.ai.first_version.strategies.chiitoitsu import ChiitoitsuStrategy +from game.ai.first_version.strategies.main import BaseStrategy +from game.table import Table + + +class ChiitoitsuStrategyTestCase(unittest.TestCase, TestMixin): + + def test_should_activate_strategy(self): + table = Table() + player = table.player + strategy = ChiitoitsuStrategy(BaseStrategy.CHIITOITSU, player) + + # obvious chiitoitsu, let's activate + tiles = self._string_to_136_array(sou='2266', man='3399', pin='289', honors='11') + player.init_hand(tiles) + player.draw_tile(self._string_to_136_tile(honors='6')) + self.assertEqual(strategy.should_activate_strategy(player.tiles), True) + + # less than 5 pairs, don't activate + tiles = self._string_to_136_array(sou='2266', man='3389', pin='289', honors='11') + player.draw_tile(self._string_to_136_tile(honors='6')) + player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(player.tiles), False) + + # 5 pairs, but we are already tempai, let's no consider this hand as chiitoitsu + tiles = self._string_to_136_array(sou='234', man='223344', pin='5669') + player.init_hand(tiles) + player.draw_tile(self._string_to_136_tile(pin='5')) + player.discard_tile() + self.assertEqual(strategy.should_activate_strategy(player.tiles), False) + + tiles = self._string_to_136_array(sou='234', man='22334455669') + player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(player.tiles), False) + + def test_dont_call_meld(self): + table = Table() + player = table.player + strategy = ChiitoitsuStrategy(BaseStrategy.CHIITOITSU, player) + + tiles = self._string_to_136_array(sou='112234', man='2334499') + player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(player.tiles), True) + + tile = self._string_to_136_tile(man='9') + meld, _ = player.try_to_call_meld(tile, True) + self.assertEqual(meld, None) + + def test_keep_chiitoitsu_tempai(self): + table = Table() + player = table.player + + tiles = self._string_to_136_array(sou='113355', man='22669', pin='99') + player.init_hand(tiles) + + player.draw_tile(self._string_to_136_tile(man='6')) + + discard = player.discard_tile() + self.assertEqual(self._to_string([discard]), '6m') + + def test_5_pairs_yakuhai_not_chiitoitsu(self): + table = Table() + player = table.player + + table.add_dora_indicator(self._string_to_136_tile(sou='9')) + table.add_dora_indicator(self._string_to_136_tile(sou='1')) + + tiles = self._string_to_136_array(sou='112233', pin='16678', honors='66') + player.init_hand(tiles) + + tile = self._string_to_136_tile(honors='6') + meld, _ = player.try_to_call_meld(tile, True) + + self.assertNotEqual(player.ai.current_strategy.type, BaseStrategy.CHIITOITSU) + + self.assertEqual(player.ai.current_strategy.type, BaseStrategy.YAKUHAI) + + self.assertNotEqual(meld, None) + + def chiitoitsu_tanyao_tempai(self): + table = Table() + player = table.player + + tiles = self._string_to_136_array(sou='223344', pin='788', man='4577') + player.init_hand(tiles) + + player.draw_tile(self._string_to_136_tile(man='4')) + + discard = player.discard_tile() + discard_correct = self._to_string([discard]) == '7p' or self._to_string([discard]) == '5m' + self.assertEqual(discard_correct, True) diff --git a/project/game/ai/first_version/tests/strategies/tests_chinitsu.py b/project/game/ai/first_version/tests/strategies/tests_chinitsu.py new file mode 100644 index 00000000..d571b4f5 --- /dev/null +++ b/project/game/ai/first_version/tests/strategies/tests_chinitsu.py @@ -0,0 +1,178 @@ +# -*- coding: utf-8 -*- +import unittest + +from mahjong.tests_mixin import TestMixin +from mahjong.meld import Meld + +from game.ai.first_version.strategies.chinitsu import ChinitsuStrategy +from game.ai.first_version.strategies.main import BaseStrategy +from game.table import Table + + +class ChinitsuStrategyTestCase(unittest.TestCase, TestMixin): + + def test_should_activate_strategy(self): + table = Table() + player = table.player + strategy = ChinitsuStrategy(BaseStrategy.CHINITSU, player) + + table.add_dora_indicator(self._string_to_136_tile(pin='1')) + table.add_dora_indicator(self._string_to_136_tile(man='1')) + table.add_dora_indicator(self._string_to_136_tile(sou='8')) + + tiles = self._string_to_136_array(sou='12355', man='34589', honors='1234') + player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(player.tiles), False) + + tiles = self._string_to_136_array(sou='12355', man='458', honors='112345') + player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(player.tiles), False) + + # we shouldn't go for chinitsu if we have a valued pair or pon + tiles = self._string_to_136_array(sou='111222578', man='8', honors='5556') + player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(player.tiles), False) + + tiles = self._string_to_136_array(sou='1112227788', man='7', honors='556') + player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(player.tiles), False) + + # if we have a pon of non-valued honors, this is not chinitsu + tiles = self._string_to_136_array(sou='1112224688', honors='2224') + player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(player.tiles), 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 = self._string_to_136_array(sou='11122234688', honors='224') + player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(player.tiles), True) + + # if we have a complete set with dora, we shouldn't go for chinitsu + tiles = self._string_to_136_array(sou='1112223688', pin='1239') + player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(player.tiles), False) + + # even if the set may be interpreted as two forms + tiles = self._string_to_136_array(sou='111223688', pin='23349') + player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(player.tiles), False) + + # even if the set may be interpreted as two forms v2 + tiles = self._string_to_136_array(sou='111223688', pin='23459') + player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(player.tiles), False) + + # if we have a long form with dora, we shouldn't go for chinitsu + tiles = self._string_to_136_array(sou='111223688', pin='23339') + player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(player.tiles), False) + + # buf it it's just a ryanmen - no problem + tiles = self._string_to_136_array(sou='1112223688', pin='238', man='9') + player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(player.tiles), True) + + # we have three non-isolated doras in other suits, this is not chinitsu + tiles = self._string_to_136_array(sou='111223688', man='22', pin='239') + player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(player.tiles), False) + + # we have two non-isolated doras in other suits and no doras in our suit + # this is not chinitsu + tiles = self._string_to_136_array(sou='111223688', man='24', pin='249') + player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(player.tiles), False) + + # we have two non-isolated doras in other suits and 1 shanten, not chinitsu + tiles = self._string_to_136_array(sou='111222789', man='23', pin='239') + player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(player.tiles), 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 = self._string_to_136_array(sou='11223578', man='57', pin='4669') + player.init_hand(tiles) + # plus one tile to open hand + tiles = self._string_to_136_array(sou='112223578', man='57', pin='466') + self.assertEqual(strategy.should_activate_strategy(tiles), False) + # but now let's init hand with these tiles, we can now slowly move to chinitsu + tiles = self._string_to_136_array(sou='112223578', man='57', pin='466') + player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(tiles), True) + + def test_suitable_tiles(self): + table = Table() + player = table.player + strategy = ChinitsuStrategy(BaseStrategy.CHINITSU, player) + + tiles = self._string_to_136_array(sou='111222479', man='78', honors='12') + player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(player.tiles), True) + + tile = self._string_to_136_tile(man='1') + self.assertEqual(strategy.is_tile_suitable(tile), False) + + tile = self._string_to_136_tile(pin='1') + self.assertEqual(strategy.is_tile_suitable(tile), False) + + tile = self._string_to_136_tile(sou='1') + self.assertEqual(strategy.is_tile_suitable(tile), True) + + tile = self._string_to_136_tile(honors='1') + self.assertEqual(strategy.is_tile_suitable(tile), False) + + def test_open_suit_same_shanten(self): + table = Table() + player = table.player + player.scores = 25000 + table.count_of_remaining_tiles = 100 + + tiles = self._string_to_136_array(man='1134556999', pin='3', sou='78') + player.init_hand(tiles) + + meld = self._make_meld(Meld.CHI, man='345') + player.add_called_meld(meld) + + strategy = ChinitsuStrategy(BaseStrategy.CHINITSU, player) + self.assertEqual(strategy.should_activate_strategy(player.tiles), True) + + tile = self._string_to_136_tile(man='1') + meld, _ = player.try_to_call_meld(tile, True) + self.assertNotEqual(meld, None) + self.assertEqual(self._to_string(meld.tiles), '111m') + + def test_correct_discard_agari_no_yaku(self): + table = Table() + player = table.player + + tiles = self._string_to_136_array(man='111234677889', sou='1', pin='') + player.init_hand(tiles) + + meld = self._make_meld(Meld.CHI, man='789') + player.add_called_meld(meld) + + tile = self._string_to_136_tile(sou='1') + player.draw_tile(tile) + discard = player.discard_tile() + self.assertEqual(self._to_string([discard]), '1s') + + def test_open_suit_agari_no_yaku(self): + table = Table() + player = table.player + player.scores = 25000 + table.count_of_remaining_tiles = 100 + + tiles = self._string_to_136_array(man='11123455589', pin='22') + player.init_hand(tiles) + + meld = self._make_meld(Meld.CHI, man='234') + player.add_called_meld(meld) + + strategy = ChinitsuStrategy(BaseStrategy.CHINITSU, player) + self.assertEqual(strategy.should_activate_strategy(player.tiles), True) + + tile = self._string_to_136_tile(man='7') + meld, _ = player.try_to_call_meld(tile, True) + self.assertNotEqual(meld, None) + self.assertEqual(self._to_string(meld.tiles), '789m') diff --git a/project/game/ai/first_version/tests/strategies/tests_formal_tempai.py b/project/game/ai/first_version/tests/strategies/tests_formal_tempai.py new file mode 100644 index 00000000..197c5244 --- /dev/null +++ b/project/game/ai/first_version/tests/strategies/tests_formal_tempai.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +import unittest + +from mahjong.tests_mixin import TestMixin +from mahjong.tile import Tile +from mahjong.meld import Meld + +from game.ai.first_version.strategies.formal_tempai import FormalTempaiStrategy +from game.ai.first_version.strategies.main import BaseStrategy +from game.table import Table + + +class FormalTempaiStrategyTestCase(unittest.TestCase, TestMixin): + + def setUp(self): + self.table = Table() + self.player = self.table.player + self.player.dealer_seat = 3 + + def test_should_activate_strategy(self): + strategy = FormalTempaiStrategy(BaseStrategy.FORMAL_TEMPAI, self.player) + + tiles = self._string_to_136_array(sou='12355689', man='89', pin='339') + self.player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(self.player.tiles), False) + + # Let's move to 10th round step + for _ in range(0, 10): + self.player.add_discarded_tile(Tile(0, False)) + + self.assertEqual(strategy.should_activate_strategy(self.player.tiles), False) + + # Now we move to 11th turn, we have 2 shanten and no doras, + # we should go for formal tempai + self.player.add_discarded_tile(Tile(0, True)) + self.assertEqual(strategy.should_activate_strategy(self.player.tiles), True) + + def test_get_tempai(self): + tiles = self._string_to_136_array(man='2379', sou='4568', pin='22299') + self.player.init_hand(tiles) + + # Let's move to 15th round step + for _ in range(0, 15): + self.player.add_discarded_tile(Tile(0, False)) + + tile = self._string_to_136_tile(man='8') + meld, _ = self.player.try_to_call_meld(tile, True) + self.assertNotEqual(meld, None) + self.assertEqual(self._to_string(meld.tiles), '789m') + + tile_to_discard = self.player.discard_tile() + self.assertEqual(self._to_string([tile_to_discard]), '8s') + + # we shouldn't open when we are already in tempai expect for some + # special cases + def test_dont_meld_agari(self): + strategy = FormalTempaiStrategy(BaseStrategy.FORMAL_TEMPAI, self.player) + + tiles = self._string_to_136_array(man='2379', sou='4568', pin='22299') + self.player.init_hand(tiles) + + # Let's move to 15th round step + for _ in range(0, 15): + self.player.add_discarded_tile(Tile(0, False)) + + self.assertEqual(strategy.should_activate_strategy(self.player.tiles), True) + + tiles = self._string_to_136_array(man='23789', sou='456', pin='22299') + self.player.init_hand(tiles) + + meld = self._make_meld(Meld.CHI, man='789') + self.player.add_called_meld(meld) + + tile = self._string_to_136_tile(man='4') + meld, _ = self.player.try_to_call_meld(tile, True) + self.assertEqual(meld, None) diff --git a/project/game/ai/first_version/tests/strategies/tests_honitsu.py b/project/game/ai/first_version/tests/strategies/tests_honitsu.py new file mode 100644 index 00000000..16873f6d --- /dev/null +++ b/project/game/ai/first_version/tests/strategies/tests_honitsu.py @@ -0,0 +1,219 @@ +# -*- coding: utf-8 -*- +import unittest + +from mahjong.tests_mixin import TestMixin +from mahjong.meld import Meld + +from game.ai.first_version.strategies.honitsu import HonitsuStrategy +from game.ai.first_version.strategies.main import BaseStrategy +from game.table import Table + + +class HonitsuStrategyTestCase(unittest.TestCase, TestMixin): + + def test_should_activate_strategy(self): + table = Table() + player = table.player + strategy = HonitsuStrategy(BaseStrategy.HONITSU, player) + + table.add_dora_indicator(self._string_to_136_tile(pin='1')) + table.add_dora_indicator(self._string_to_136_tile(honors='5')) + + tiles = self._string_to_136_array(sou='12355', man='12389', honors='123') + player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(player.tiles), False) + + # many tiles in one suit and yakuhai pair, but still many useless winds + tiles = self._string_to_136_array(sou='12355', man='23', pin='68', honors='2355') + player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(player.tiles), False) + + # many tiles in one suit and yakuhai pair and another honor pair, so + # now this is honitsu + tiles = self._string_to_136_array(sou='12355', man='238', honors='22355') + player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(player.tiles), True) + + # same conditions, but ready suit with dora in another suit, so no honitsu + tiles = self._string_to_136_array(sou='12355', pin='234', honors='22355') + player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(player.tiles), False) + + # same conditions, but we have a pon of yakuhai doras, we shouldn't + # force honitsu with this hand + tiles = self._string_to_136_array(sou='12355', pin='238', honors='22666') + player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(player.tiles), False) + + # if we have a complete set with dora, we shouldn't go for honitsu + tiles = self._string_to_136_array(sou='11123688', pin='123', honors='55') + player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(player.tiles), False) + + # even if the set may be interpreted as two forms + tiles = self._string_to_136_array(sou='1223688', pin='2334', honors='55') + player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(player.tiles), False) + + # even if the set may be interpreted as two forms v2 + tiles = self._string_to_136_array(sou='1223688', pin='2345', honors='55') + player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(player.tiles), False) + + # if we have a long form with dora, we shouldn't go for honitsu + tiles = self._string_to_136_array(sou='1223688', pin='2333', honors='55') + player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(player.tiles), False) + + def test_suitable_tiles(self): + table = Table() + player = table.player + strategy = HonitsuStrategy(BaseStrategy.HONITSU, player) + + tiles = self._string_to_136_array(sou='12355', man='238', honors='23455') + player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(player.tiles), True) + + tile = self._string_to_136_tile(man='1') + self.assertEqual(strategy.is_tile_suitable(tile), False) + + tile = self._string_to_136_tile(pin='1') + self.assertEqual(strategy.is_tile_suitable(tile), False) + + tile = self._string_to_136_tile(sou='1') + self.assertEqual(strategy.is_tile_suitable(tile), True) + + tile = self._string_to_136_tile(honors='1') + self.assertEqual(strategy.is_tile_suitable(tile), True) + + def test_open_hand_and_discard_tiles_logic(self): + table = Table() + player = table.player + + tiles = self._string_to_136_array(sou='112235589', man='23', honors='22') + player.init_hand(tiles) + + # we don't need to call meld even if it improves our hand, + # because we are aim for honitsu + tile = self._string_to_136_tile(man='1') + meld, _ = player.try_to_call_meld(tile, False) + self.assertEqual(meld, None) + + # any honor tile is suitable + tile = self._string_to_136_tile(honors='2') + meld, discard_option = player.try_to_call_meld(tile, False) + self.assertNotEqual(meld, None) + self.assertEqual(self._to_string([discard_option.tile_to_discard * 4]), '2m') + + player.discard_tile(discard_option) + + tile = self._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 + self.assertEqual(self._to_string([tile_to_discard]), '1m') + + def test_riichi_and_tiles_from_another_suit_in_the_hand(self): + table = Table() + player = table.player + player.scores = 25000 + table.count_of_remaining_tiles = 100 + + tiles = self._string_to_136_array(man='33345678', pin='22', honors='155') + player.init_hand(tiles) + + player.draw_tile(self._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 + self.assertEqual(self._to_string([tile_to_discard]), '1z') + + def test_discard_not_needed_winds(self): + table = Table() + player = table.player + player.scores = 25000 + table.count_of_remaining_tiles = 100 + + tiles = self._string_to_136_array(man='24', pin='4', sou='12344668', honors='36') + player.init_hand(tiles) + player.draw_tile(self._string_to_136_tile(sou='5')) + + table.add_discarded_tile(1, self._string_to_136_tile(honors='3'), False) + table.add_discarded_tile(1, self._string_to_136_tile(honors='3'), False) + table.add_discarded_tile(1, self._string_to_136_tile(honors='3'), False) + + tile_to_discard = player.discard_tile() + + # west was discarded three times, we don't need it + self.assertEqual(self._to_string([tile_to_discard]), '3z') + + def test_discard_not_effective_tiles_first(self): + table = Table() + player = table.player + player.scores = 25000 + table.count_of_remaining_tiles = 100 + + tiles = self._string_to_136_array(man='33', pin='12788999', sou='5', honors='23') + player.init_hand(tiles) + player.draw_tile(self._string_to_136_tile(honors='6')) + tile_to_discard = player.discard_tile() + + self.assertEqual(self._to_string([tile_to_discard]), '5s') + + def test_open_yakuhai_same_shanten(self): + table = Table() + player = table.player + player.scores = 25000 + table.count_of_remaining_tiles = 100 + + tiles = self._string_to_136_array(man='34556778', pin='3', sou='78', honors='77') + player.init_hand(tiles) + + meld = self._make_meld(Meld.CHI, man='345') + player.add_called_meld(meld) + + strategy = HonitsuStrategy(BaseStrategy.HONITSU, player) + self.assertEqual(strategy.should_activate_strategy(player.tiles), True) + + tile = self._string_to_136_tile(honors='7') + meld, _ = player.try_to_call_meld(tile, True) + self.assertNotEqual(meld, None) + self.assertEqual(self._to_string(meld.tiles), '777z') + + def test_open_hand_and_not_go_for_chiitoitsu(self): + table = Table() + player = table.player + + tiles = self._string_to_136_array(sou='1122559', honors='134557') + player.init_hand(tiles) + player.draw_tile(self._string_to_136_tile(pin='4')) + + tile = player.discard_tile() + self.assertEqual(self._to_string([tile]), '4p') + + tile = self._string_to_136_tile(honors='5') + meld, _ = player.try_to_call_meld(tile, False) + self.assertNotEqual(meld, None) + self.assertEqual(self._to_string(meld.tiles), '555z') + + def test_open_suit_same_shanten(self): + table = Table() + player = table.player + player.scores = 25000 + table.count_of_remaining_tiles = 100 + + tiles = self._string_to_136_array(man='1134556', pin='3', sou='78', honors='777') + player.init_hand(tiles) + + meld = self._make_meld(Meld.CHI, man='345') + player.add_called_meld(meld) + + strategy = HonitsuStrategy(BaseStrategy.HONITSU, player) + self.assertEqual(strategy.should_activate_strategy(player.tiles), True) + + tile = self._string_to_136_tile(man='1') + meld, _ = player.try_to_call_meld(tile, True) + self.assertNotEqual(meld, None) + self.assertEqual(self._to_string(meld.tiles), '111m') diff --git a/project/game/ai/first_version/tests/strategies/tests_tanyao.py b/project/game/ai/first_version/tests/strategies/tests_tanyao.py new file mode 100644 index 00000000..6884c81f --- /dev/null +++ b/project/game/ai/first_version/tests/strategies/tests_tanyao.py @@ -0,0 +1,380 @@ +# -*- coding: utf-8 -*- +import unittest + +from mahjong.constants import FIVE_RED_PIN +from mahjong.meld import Meld +from mahjong.tests_mixin import TestMixin + +from game.ai.first_version.strategies.main import BaseStrategy +from game.ai.first_version.strategies.tanyao import TanyaoStrategy +from game.table import Table + + +class TanyaoStrategyTestCase(unittest.TestCase, TestMixin): + + def setUp(self): + self.table = self._make_table() + self.player = self.table.player + + def test_should_activate_strategy_and_terminal_pon_sets(self): + strategy = TanyaoStrategy(BaseStrategy.TANYAO, self.player) + + tiles = self._string_to_136_array(sou='222', man='3459', pin='233', honors='111') + self.player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(self.player.tiles), False) + + tiles = self._string_to_136_array(sou='222', man='3459', pin='233999') + self.player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(self.player.tiles), False) + + tiles = self._string_to_136_array(sou='222', man='3459', pin='233444') + self.player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(self.player.tiles), True) + + def test_should_activate_strategy_and_terminal_pairs(self): + strategy = TanyaoStrategy(BaseStrategy.TANYAO, self.player) + + tiles = self._string_to_136_array(sou='222', man='3459', pin='2399', honors='11') + self.player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(self.player.tiles), False) + + tiles = self._string_to_136_array(sou='22258', man='3566', pin='2399') + self.player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(self.player.tiles), True) + + def test_should_activate_strategy_and_valued_pair(self): + strategy = TanyaoStrategy(BaseStrategy.TANYAO, self.player) + + tiles = self._string_to_136_array(man='23446679', sou='222', honors='55') + self.player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(self.player.tiles), False) + + tiles = self._string_to_136_array(man='23446679', sou='222', honors='22') + self.player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(self.player.tiles), True) + + def test_should_activate_strategy_and_chitoitsu_like_hand(self): + strategy = TanyaoStrategy(BaseStrategy.TANYAO, self.player) + + tiles = self._string_to_136_array(sou='223388', man='2244', pin='6687') + self.player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(self.player.tiles), True) + + def test_should_activate_strategy_and_already_completed_sided_set(self): + strategy = TanyaoStrategy(BaseStrategy.TANYAO, self.player) + + tiles = self._string_to_136_array(sou='123234', man='2349', pin='234') + self.player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(self.player.tiles), False) + + tiles = self._string_to_136_array(sou='234789', man='2349', pin='234') + self.player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(self.player.tiles), False) + + tiles = self._string_to_136_array(sou='234', man='1233459', pin='234') + self.player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(self.player.tiles), False) + + tiles = self._string_to_136_array(sou='234', man='2227899', pin='234') + self.player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(self.player.tiles), False) + + tiles = self._string_to_136_array(sou='234', man='2229', pin='122334') + self.player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(self.player.tiles), False) + + tiles = self._string_to_136_array(sou='234', man='2229', pin='234789') + self.player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(self.player.tiles), False) + + tiles = self._string_to_136_array(sou='223344', man='2229', pin='234') + self.player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(self.player.tiles), True) + + def test_suitable_tiles(self): + strategy = TanyaoStrategy(BaseStrategy.TANYAO, self.player) + + tile = self._string_to_136_tile(man='1') + self.assertEqual(strategy.is_tile_suitable(tile), False) + + tile = self._string_to_136_tile(pin='1') + self.assertEqual(strategy.is_tile_suitable(tile), False) + + tile = self._string_to_136_tile(sou='9') + self.assertEqual(strategy.is_tile_suitable(tile), False) + + tile = self._string_to_136_tile(honors='1') + self.assertEqual(strategy.is_tile_suitable(tile), False) + + tile = self._string_to_136_tile(honors='6') + self.assertEqual(strategy.is_tile_suitable(tile), False) + + tile = self._string_to_136_tile(man='2') + self.assertEqual(strategy.is_tile_suitable(tile), True) + + tile = self._string_to_136_tile(pin='5') + self.assertEqual(strategy.is_tile_suitable(tile), True) + + tile = self._string_to_136_tile(sou='8') + self.assertEqual(strategy.is_tile_suitable(tile), True) + + def test_dont_open_hand_with_high_shanten(self): + # with 4 shanten we don't need to aim for open tanyao + tiles = self._string_to_136_array(man='269', pin='247', sou='2488', honors='123') + tile = self._string_to_136_tile(sou='3') + self.player.init_hand(tiles) + meld, _ = self.player.try_to_call_meld(tile, False) + self.assertEqual(meld, None) + + # with 3 shanten we can open a hand + tiles = self._string_to_136_array(man='236', pin='247', sou='2488', honors='123') + tile = self._string_to_136_tile(sou='3') + self.player.init_hand(tiles) + meld, _ = self.player.try_to_call_meld(tile, True) + self.assertNotEqual(meld, None) + + def test_dont_open_hand_with_not_suitable_melds(self): + tiles = self._string_to_136_array(man='22255788', sou='3479', honors='3') + tile = self._string_to_136_tile(sou='8') + self.player.init_hand(tiles) + meld, _ = self.player.try_to_call_meld(tile, False) + self.assertEqual(meld, None) + + def test_open_hand_and_discard_tiles_logic(self): + tiles = self._string_to_136_array(man='22234', sou='238', pin='256', honors='44') + self.player.init_hand(tiles) + + tile = self._string_to_136_tile(sou='4') + meld, discard_option = self.player.try_to_call_meld(tile, True) + self.assertNotEqual(meld, None) + self.assertEqual(self._to_string([discard_option.tile_to_discard * 4]), '4z') + + self.player.discard_tile(discard_option) + + tile = self._string_to_136_tile(pin='5') + self.player.draw_tile(tile) + tile_to_discard = self.player.discard_tile() + + self.assertEqual(self._to_string([tile_to_discard]), '4z') + + def test_dont_count_pairs_in_already_opened_hand(self): + tiles = self._string_to_136_array(man='33556788', sou='22266') + self.player.init_hand(tiles) + + meld = self._make_meld(Meld.PON, sou='222') + self.player.add_called_meld(meld) + + tile = self._string_to_136_tile(sou='6') + meld, _ = self.player.try_to_call_meld(tile, False) + # even if it looks like chitoitsu we can open hand and get tempai here + self.assertNotEqual(meld, None) + + def test_we_cant_win_with_this_hand(self): + tiles = self._string_to_136_array(man='22277', sou='23', pin='233445') + self.player.init_hand(tiles) + meld = self._make_meld(Meld.CHI, pin='234') + self.player.add_called_meld(meld) + + self.player.draw_tile(self._string_to_136_tile(sou='1')) + discard = self.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 + self.assertEqual(self.player.ai.shanten, 0) + self.assertEqual(self._to_string([discard]), '1s') + + def test_choose_correct_waiting(self): + tiles = self._string_to_136_array(man='222678', sou='234', pin='3588') + self.player.init_hand(tiles) + self.player.draw_tile(self._string_to_136_tile(pin='2')) + + self._assert_tanyao(self.player) + + # discard 5p and riichi + discard = self.player.discard_tile() + self.assertEqual(self._to_string([discard]), '5p') + + meld = self._make_meld(Meld.CHI, man='234') + self.player.add_called_meld(meld) + + tiles = self._string_to_136_array(man='234888', sou='234', pin='3588') + self.player.init_hand(tiles) + self.player.draw_tile(self._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 = self.player.discard_tile() + self.assertEqual(self._to_string([discard]), '2p') + + table = self._make_table() + player = table.player + + meld = self._make_meld(Meld.CHI, man='678') + player.add_called_meld(meld) + + tiles = self._string_to_136_array(man='222678', sou='234', pin='2388') + player.init_hand(tiles) + player.draw_tile(self._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() + self.assertEqual(self._to_string([discard]), '7s') + + def test_choose_balanced_ukeire_in_1_shanten(self): + table = self._make_table() + player = table.player + + meld = self._make_meld(Meld.CHI, man='678') + player.add_called_meld(meld) + + tiles = self._string_to_136_array(man='22678', sou='234568', pin='45') + player.init_hand(tiles) + player.draw_tile(self._string_to_136_tile(man='2')) + + self._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() + self.assertEqual(self._to_string([discard]), '8s') + + def test_choose_pseudo_atodzuke(self): + table = self._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, self._string_to_136_tile(pin='1'), False) + + meld = self._make_meld(Meld.CHI, man='678') + player.add_called_meld(meld) + + tiles = self._string_to_136_array(man='222678', sou='23488', pin='35') + player.init_hand(tiles) + player.draw_tile(self._string_to_136_tile(pin='2')) + + self._assert_tanyao(player) + + discard = player.discard_tile() + self.assertEqual(self._to_string([discard]), '5p') + + def test_choose_correct_waiting_and_first_opened_meld(self): + tiles = self._string_to_136_array(man='2337788', sou='222', pin='234') + self.player.init_hand(tiles) + + tile = self._string_to_136_tile(man='8') + meld, tile_to_discard = self.player.try_to_call_meld(tile, False) + + self._assert_tanyao(self.player) + + discard = self.player.discard_tile(tile_to_discard) + self.assertEqual(self._to_string([discard]), '2m') + + def test_we_dont_need_to_discard_terminals_from_closed_hand(self): + tiles = self._string_to_136_array(man='22234', sou='13588', pin='558') + self.player.init_hand(tiles) + + tile = self._string_to_136_tile(pin='5') + self.player.draw_tile(tile) + tile_to_discard = self.player.discard_tile() + + # our hand is closed, let's keep terminal for now + self.assertEqual(self._to_string([tile_to_discard]), '8p') + + def test_dont_open_tanyao_with_two_non_central_doras(self): + self.table.add_dora_indicator(self._string_to_136_tile(pin='8')) + + tiles = self._string_to_136_array(man='22234', sou='6888', pin='5599') + self.table.player.init_hand(tiles) + + tile = self._string_to_136_tile(pin='5') + meld, _ = self.table.player.try_to_call_meld(tile, False) + self.assertEqual(meld, None) + + def test_dont_open_tanyao_with_three_not_isolated_terminals(self): + tiles = self._string_to_136_array(man='22256', sou='2799', pin='5579') + self.player.init_hand(tiles) + + tile = self._string_to_136_tile(pin='5') + meld, _ = self.player.try_to_call_meld(tile, False) + self.assertEqual(meld, None) + + def test_dont_open_tanyao_with_two_not_isolated_terminals_one_shanten(self): + tiles = self._string_to_136_array(man='22234', sou='379', pin='55579') + self.player.init_hand(tiles) + + tile = self._string_to_136_tile(man='5') + meld, _ = self.player.try_to_call_meld(tile, False) + self.assertEqual(meld, None) + + def test_dont_count_terminal_tiles_in_ukeire(self): + # for closed hand let's chose tile with best ukeire + tiles = self._string_to_136_array(man='234578', sou='235', pin='2246') + self.player.init_hand(tiles) + self.player.draw_tile(self._string_to_136_tile(pin='5')) + discard = self.player.discard_tile() + self.assertEqual(self._to_string([discard]), '5m') + + # but with opened hand we don't need to count not suitable tiles as ukeire + tiles = self._string_to_136_array(man='234578', sou='235', pin='2246') + self.player.init_hand(tiles) + self.player.add_called_meld(self._make_meld(Meld.CHI, man='234')) + self.player.draw_tile(self._string_to_136_tile(pin='5')) + discard = self.player.discard_tile() + self.assertEqual(self._to_string([discard]), '8m') + + def test_determine_strategy_when_we_try_to_call_meld(self): + self.table.has_aka_dora = True + + self.table.add_dora_indicator(self._string_to_136_tile(sou='5')) + tiles = self._string_to_136_array(man='66678', sou='6888', pin='5588') + self.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, _ = self.table.player.try_to_call_meld(FIVE_RED_PIN, False) + self.assertNotEqual(meld, None) + + self._assert_tanyao(self.player) + + def test_correct_discard_agari_no_yaku(self): + tiles = self._string_to_136_array(man='23567', sou='456', pin='22244') + self.player.init_hand(tiles) + + meld = self._make_meld(Meld.CHI, man='567') + self.player.add_called_meld(meld) + + tile = self._string_to_136_tile(man='1') + self.player.draw_tile(tile) + discard = self.player.discard_tile() + self.assertEqual(self._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(self): + tiles = self._string_to_136_array(man='23567', sou='456', pin='22244') + self.player.init_hand(tiles) + + meld = self._make_meld(Meld.CHI, man='567') + self.player.add_called_meld(meld) + + tile = self._string_to_136_tile(man='4') + meld, _ = self.player.try_to_call_meld(tile, True) + self.assertEqual(meld, None) + + def _make_table(self): + table = Table() + table.has_open_tanyao = True + + # add doras so we are sure to go for tanyao + table.add_dora_indicator(self._string_to_136_tile(sou='1')) + table.add_dora_indicator(self._string_to_136_tile(man='1')) + table.add_dora_indicator(self._string_to_136_tile(pin='1')) + + return table + + def _assert_tanyao(self, player): + self.assertNotEqual(player.ai.current_strategy, None) + self.assertEqual(player.ai.current_strategy.type, BaseStrategy.TANYAO) diff --git a/project/game/ai/first_version/tests/strategies/tests_yakuhai.py b/project/game/ai/first_version/tests/strategies/tests_yakuhai.py new file mode 100644 index 00000000..543dc2a9 --- /dev/null +++ b/project/game/ai/first_version/tests/strategies/tests_yakuhai.py @@ -0,0 +1,545 @@ +# -*- coding: utf-8 -*- +import unittest + +from mahjong.constants import WEST, EAST, SOUTH +from mahjong.meld import Meld +from mahjong.tests_mixin import TestMixin +from mahjong.tile import Tile + +from game.ai.first_version.strategies.main import BaseStrategy +from game.ai.first_version.strategies.yakuhai import YakuhaiStrategy +from game.table import Table + + +class YakuhaiStrategyTestCase(unittest.TestCase, TestMixin): + + def setUp(self): + self.table = Table() + self.player = self.table.player + self.player.dealer_seat = 3 + + def test_should_activate_strategy(self): + strategy = YakuhaiStrategy(BaseStrategy.YAKUHAI, self.player) + + tiles = self._string_to_136_array(sou='12355689', man='89', honors='123') + self.player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(self.player.tiles), False) + + self.table.dora_indicators.append(self._string_to_136_tile(honors='7')) + tiles = self._string_to_136_array(sou='12355689', man='899', honors='55') + self.player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(self.player.tiles), True) + + # with chitoitsu-like hand we don't need to go for yakuhai + tiles = self._string_to_136_array(sou='1235566', man='8899', honors='66') + self.player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(self.player.tiles), False) + + # don't count tile discarded by other player as our pair + tiles = self._string_to_136_array(sou='12355689', man='899', honors='25') + self.player.init_hand(tiles) + tiles = self._string_to_136_array(sou='12355689', man='899', honors='255') + self.assertEqual(strategy.should_activate_strategy(tiles), False) + + def test_dont_activate_strategy_if_we_dont_have_enough_tiles_in_the_wall(self): + strategy = YakuhaiStrategy(BaseStrategy.YAKUHAI, self.player) + + self.table.dora_indicators.append(self._string_to_136_tile(honors='7')) + tiles = self._string_to_136_array(man='59', sou='1235', pin='12789', honors='55') + self.player.init_hand(tiles) + + self.assertEqual(strategy.should_activate_strategy(self.player.tiles), True) + + self.table.add_discarded_tile(3, self._string_to_136_tile(honors='5'), False) + self.table.add_discarded_tile(3, self._string_to_136_tile(honors='5'), False) + + # we can't complete yakuhai, because there is not enough honor tiles + self.assertEqual(strategy.should_activate_strategy(self.player.tiles), False) + + def test_suitable_tiles(self): + strategy = YakuhaiStrategy(BaseStrategy.YAKUHAI, self.player) + + # for yakuhai we can use any tile + for tile in range(0, 136): + self.assertEqual(strategy.is_tile_suitable(tile), True) + + def test_force_yakuhai_pair_waiting_for_tempai_hand(self): + """ + If hand shanten = 1 don't open hand except the situation where is + we have tempai on yakuhai tile after open set + """ + self.table.dora_indicators.append(self._string_to_136_tile(man='3')) + tiles = self._string_to_136_array(sou='123', pin='678', man='34468', honors='66') + self.player.init_hand(tiles) + + # we will not get tempai on yakuhai pair with this hand, so let's skip this call + tile = self._string_to_136_tile(man='5') + meld, _ = self.player.try_to_call_meld(tile, False) + self.assertEqual(meld, None) + + # but here we will have atodzuke tempai + tile = self._string_to_136_tile(man='7') + meld, _ = self.player.try_to_call_meld(tile, True) + self.assertNotEqual(meld, None) + self.assertEqual(meld.type, Meld.CHI) + self.assertEqual(self._to_string(meld.tiles), '678m') + + self.table = Table() + self.player = self.table.player + + # we can open hand in that case + self.table.dora_indicators.append(self._string_to_136_tile(sou='5')) + tiles = self._string_to_136_array(man='44556', sou='366789', honors='77') + self.player.init_hand(tiles) + + strategy = YakuhaiStrategy(BaseStrategy.YAKUHAI, self.player) + self.assertEqual(strategy.should_activate_strategy(self.player.tiles), True) + + tile = self._string_to_136_tile(honors='7') + meld, _ = self.player.try_to_call_meld(tile, True) + self.assertNotEqual(meld, None) + self.assertEqual(self._to_string(meld.tiles), '777z') + + def test_tempai_without_yaku(self): + tiles = self._string_to_136_array(sou='678', pin='12355', man='456', honors='77') + self.player.init_hand(tiles) + + tile = self._string_to_136_tile(pin='5') + self.player.draw_tile(tile) + meld = self._make_meld(Meld.CHI, sou='678') + self.player.add_called_meld(meld) + + discard = self.player.discard_tile() + self.assertNotEqual(self._to_string([discard]), '7z') + self.assertNotEqual(self._to_string([discard]), '5p') + + def test_wrong_shanten_improvements_detection(self): + """ + 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: + """ + tiles = self._string_to_136_array(sou='2345999', honors='114446') + self.player.init_hand(tiles) + + meld = self._make_meld(Meld.PON, sou='999') + self.player.add_called_meld(meld) + meld = self._make_meld(Meld.PON, honors='444') + self.player.add_called_meld(meld) + + tile = self._string_to_136_tile(sou='2') + meld, _ = self.table.player.try_to_call_meld(tile, True) + self.assertEqual(meld, None) + + def test_open_hand_with_doras_in_the_hand(self): + """ + If we have valuable pair in the hand, and 2+ dora let's open on this + valuable pair + """ + + tiles = self._string_to_136_array(man='59', sou='1235', pin='12789', honors='11') + self.player.init_hand(tiles) + + tile = self._string_to_136_tile(honors='1') + meld, _ = self.player.try_to_call_meld(tile, True) + self.assertEqual(meld, None) + + # add doras to the hand + self.table.dora_indicators.append(self._string_to_136_tile(pin='7')) + self.table.dora_indicators.append(self._string_to_136_tile(pin='8')) + self.player.init_hand(tiles) + + # and now we can open hand on the valuable pair + tile = self._string_to_136_tile(honors='1') + meld, _ = self.player.try_to_call_meld(tile, True) + self.assertNotEqual(meld, None) + + # but we don't need to open hand for atodzuke here + tile = self._string_to_136_tile(pin='3') + meld, _ = self.player.try_to_call_meld(tile, True) + self.assertEqual(meld, None) + + def test_open_hand_with_doras_in_the_hand_and_atodzuke(self): + """ + 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 + """ + + tiles = self._string_to_136_array(man='59', sou='1235', pin='12788', honors='11') + self.player.init_hand(tiles) + + tile = self._string_to_136_tile(pin='3') + meld, _ = self.player.try_to_call_meld(tile, True) + self.assertEqual(meld, None) + + # add doras to the hand + self.table.dora_indicators.append(self._string_to_136_tile(pin='7')) + self.player.init_hand(tiles) + + # we have other pair in the hand, so we can open atodzuke here + tile = self._string_to_136_tile(pin='3') + meld, _ = self.player.try_to_call_meld(tile, True) + self.assertNotEqual(meld, None) + + def test_open_hand_on_fifth_round_step(self): + """ + If we have valuable pair in the hand, 1+ dora and 5+ round step + let's open on this valuable pair + """ + + tiles = self._string_to_136_array(man='59', sou='1235', pin='12789', honors='11') + self.player.init_hand(tiles) + + tile = self._string_to_136_tile(honors='1') + meld, _ = self.player.try_to_call_meld(tile, True) + self.assertEqual(meld, None) + + # add doras to the hand + self.table.dora_indicators.append(self._string_to_136_tile(pin='7')) + self.player.init_hand(tiles) + + tile = self._string_to_136_tile(honors='1') + meld, _ = self.player.try_to_call_meld(tile, True) + self.assertEqual(meld, None) + + # one discard == one round step + self.player.add_discarded_tile(Tile(0, False)) + self.player.add_discarded_tile(Tile(0, False)) + self.player.add_discarded_tile(Tile(0, False)) + self.player.add_discarded_tile(Tile(0, False)) + self.player.add_discarded_tile(Tile(0, False)) + self.player.add_discarded_tile(Tile(0, False)) + self.player.init_hand(tiles) + + # after 5 round step we can open hand + tile = self._string_to_136_tile(honors='1') + meld, _ = self.player.try_to_call_meld(tile, True) + self.assertNotEqual(meld, None) + + # but we don't need to open hand for atodzuke here + tile = self._string_to_136_tile(pin='3') + meld, _ = self.player.try_to_call_meld(tile, True) + self.assertEqual(meld, None) + + def test_open_hand_with_two_valuable_pairs(self): + """ + If we have two valuable pairs in the hand and 1+ dora + let's open on one of this valuable pairs + """ + + tiles = self._string_to_136_array(man='159', sou='128', pin='789', honors='5566') + self.player.init_hand(tiles) + + tile = self._string_to_136_tile(honors='5') + meld, _ = self.player.try_to_call_meld(tile, True) + self.assertEqual(meld, None) + + # add doras to the hand + self.table.dora_indicators.append(self._string_to_136_tile(pin='7')) + self.player.init_hand(tiles) + + tile = self._string_to_136_tile(honors='5') + meld, _ = self.player.try_to_call_meld(tile, True) + self.assertNotEqual(meld, None) + + tile = self._string_to_136_tile(honors='6') + meld, _ = self.player.try_to_call_meld(tile, True) + self.assertNotEqual(meld, None) + + # but we don't need to open hand for atodzuke here + tile = self._string_to_136_tile(pin='3') + meld, _ = self.player.try_to_call_meld(tile, True) + self.assertEqual(meld, None) + + def test_open_hand_and_once_discarded_tile(self): + """ + 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 + """ + + strategy = YakuhaiStrategy(BaseStrategy.YAKUHAI, self.player) + + tiles = self._string_to_136_array(sou='678', pin='14689', man='456', honors='77') + self.player.init_hand(tiles) + + # we don't activate strategy yet + self.assertEqual(strategy.should_activate_strategy(self.player.tiles), False) + + # let's skip first yakuhai early in the game + tile = self._string_to_136_tile(honors='7') + meld, _ = self.player.try_to_call_meld(tile, True) + self.assertEqual(meld, None) + + # now one is out + self.table.add_discarded_tile(1, tile, False) + + meld, _ = self.player.try_to_call_meld(tile, True) + self.assertNotEqual(meld, None) + self.assertEqual(self._to_string(meld.tiles), '777z') + + # but we don't need to open hand for atodzuke here + tile = self._string_to_136_tile(pin='7') + meld, _ = self.player.try_to_call_meld(tile, True) + self.assertEqual(meld, None) + + def test_open_hand_when_yakuhai_already_in_the_hand(self): + # make sure yakuhai strategy is activated by adding 3 doras to the hand + table = Table() + player = table.player + table.add_dora_indicator(self._string_to_136_tile(honors='5')) + + tiles = self._string_to_136_array(man='46', pin='4679', sou='1348', honors='666') + player.init_hand(tiles) + + strategy = YakuhaiStrategy(BaseStrategy.YAKUHAI, player) + self.assertEqual(strategy.should_activate_strategy(player.tiles), True) + + tile = self._string_to_136_tile(sou='2') + meld, _ = player.try_to_call_meld(tile, True) + self.assertNotEqual(meld, None) + + def test_always_open_double_east_wind(self): + tiles = self._string_to_136_array(man='59', sou='1235', pin='12788', honors='11') + self.player.init_hand(tiles) + + # player is is not east + self.player.dealer_seat = 2 + self.assertEqual(self.player.player_wind, WEST) + + self.player.init_hand(tiles) + tile = self._string_to_136_tile(honors='1') + meld, _ = self.player.try_to_call_meld(tile, True) + self.assertEqual(meld, None) + + # player is is east + self.player.dealer_seat = 0 + self.assertEqual(self.player.player_wind, EAST) + + self.player.init_hand(tiles) + tile = self._string_to_136_tile(honors='1') + meld, _ = self.player.try_to_call_meld(tile, True) + self.assertNotEqual(meld, None) + + def test_open_double_south_wind(self): + tiles = self._string_to_136_array(man='59', sou='1235', pin='12788', honors='22') + self.player.init_hand(tiles) + + tile = self._string_to_136_tile(honors='2') + meld, _ = self.player.try_to_call_meld(tile, True) + self.assertEqual(meld, None) + + # player is south and round is south + self.table.round_wind_number = 5 + self.player.dealer_seat = 3 + self.assertEqual(self.player.player_wind, SOUTH) + + self.player.init_hand(tiles) + tile = self._string_to_136_tile(honors='2') + meld, _ = self.player.try_to_call_meld(tile, True) + self.assertEqual(meld, None) + + # add dora in the hand and after that we can open a hand + self.table.dora_indicators.append(self._string_to_136_tile(pin='6')) + + self.player.init_hand(tiles) + tile = self._string_to_136_tile(honors='2') + meld, _ = self.player.try_to_call_meld(tile, True) + self.assertNotEqual(meld, None) + + def test_keep_yakuhai_in_closed_hand(self): + tiles = self._string_to_136_array(man='14', sou='15', pin='113347', honors='777') + self.player.init_hand(tiles) + + tile = self._string_to_136_tile(honors='3') + self.player.draw_tile(tile) + + discard = self.player.discard_tile() + self.assertNotEqual(self._to_string([discard]), '7z') + + def test_keep_only_yakuhai_pon(self): + # make sure yakuhai strategy is activated by adding 3 doras to the hand + table = Table() + player = table.player + table.add_dora_indicator(self._string_to_136_tile(man='9')) + table.add_dora_indicator(self._string_to_136_tile(man='3')) + + tiles = self._string_to_136_array(man='11144', sou='567', pin='56', honors='777') + player.init_hand(tiles) + + meld = self._make_meld(Meld.PON, man='111') + player.add_called_meld(meld) + + strategy = YakuhaiStrategy(BaseStrategy.YAKUHAI, player) + self.assertEqual(strategy.should_activate_strategy(player.tiles), True) + + player.draw_tile(self._string_to_136_tile(man='4')) + discarded_tile = player.discard_tile() + self.assertNotEqual(self._to_string([discarded_tile]), '7z') + + def test_keep_only_yakuhai_pair(self): + # make sure yakuhai strategy is activated by adding 3 doras to the hand + table = Table() + player = table.player + table.add_dora_indicator(self._string_to_136_tile(man='9')) + table.add_dora_indicator(self._string_to_136_tile(man='3')) + + table.add_discarded_tile(1, self._string_to_136_tile(honors='7'), False) + + tiles = self._string_to_136_array(man='11144', sou='567', pin='156', honors='77') + player.init_hand(tiles) + + meld = self._make_meld(Meld.PON, man='111') + player.add_called_meld(meld) + + strategy = YakuhaiStrategy(BaseStrategy.YAKUHAI, player) + self.assertEqual(strategy.should_activate_strategy(player.tiles), True) + + player.draw_tile(self._string_to_136_tile(pin='1')) + discarded_tile = player.discard_tile() + self.assertNotEqual(self._to_string([discarded_tile]), '7z') + + def test_atodzuke_keep_yakuhai_wait(self): + # make sure yakuhai strategy is activated by adding 3 doras to the hand + table = Table() + player = table.player + table.add_dora_indicator(self._string_to_136_tile(man='9')) + + tiles = self._string_to_136_array(man='11144', sou='567', pin='567', honors='77') + player.init_hand(tiles) + + meld = self._make_meld(Meld.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, self._string_to_136_tile(man='4'), False) + table.add_discarded_tile(1, self._string_to_136_tile(man='4'), False) + + strategy = YakuhaiStrategy(BaseStrategy.YAKUHAI, player) + self.assertEqual(strategy.should_activate_strategy(player.tiles), True) + + player.draw_tile(self._string_to_136_tile(man='2')) + discarded_tile = player.discard_tile() + self.assertEqual(self._to_string([discarded_tile]), '2m') + + # issue #98 + @unittest.expectedFailure + def test_atodzuke_dont_destroy_second_pair(self): + # make sure yakuhai strategy is activated by adding 3 doras to the hand + table = Table() + player = table.player + table.add_dora_indicator(self._string_to_136_tile(man='9')) + + tiles = self._string_to_136_array(man='111445', sou='468', pin='56', honors='77') + player.init_hand(tiles) + + meld = self._make_meld(Meld.PON, man='111') + player.add_called_meld(meld) + + strategy = YakuhaiStrategy(BaseStrategy.YAKUHAI, player) + self.assertEqual(strategy.should_activate_strategy(player.tiles), True) + + # 6 man is bad meld, we lose our second pair and so is 4 man + tile = self._string_to_136_tile(man='6') + meld, _ = player.try_to_call_meld(tile, True) + self.assertEqual(meld, None) + + tile = self._string_to_136_tile(man='4') + meld, _ = player.try_to_call_meld(tile, True) + self.assertEqual(meld, None) + + # but if we have backup pair it's ok + tiles = self._string_to_136_array(man='111445', sou='468', pin='88', honors='77') + player.init_hand(tiles) + + meld = self._make_meld(Meld.PON, man='111') + player.add_called_meld(meld) + + strategy = YakuhaiStrategy(BaseStrategy.YAKUHAI, player) + self.assertEqual(strategy.should_activate_strategy(player.tiles), True) + + # 6 man is bad meld, we lose our second pair and so is 4 man + tile = self._string_to_136_tile(man='6') + meld, _ = player.try_to_call_meld(tile, True) + self.assertNotEqual(meld, None) + + tile = self._string_to_136_tile(man='4') + meld, _ = player.try_to_call_meld(tile, True) + self.assertNotEqual(meld, None) + + def test_atodzuke_dont_open_no_yaku_tempai(self): + # make sure yakuhai strategy is activated by adding 3 doras to the hand + table = Table() + player = table.player + table.add_dora_indicator(self._string_to_136_tile(man='9')) + + tiles = self._string_to_136_array(man='111445', sou='567', pin='56', honors='77') + player.init_hand(tiles) + + meld = self._make_meld(Meld.PON, man='111') + player.add_called_meld(meld) + + # 6 man is bad meld, we lose our second pair and so is 4 man + tile = self._string_to_136_tile(man='6') + meld, _ = player.try_to_call_meld(tile, True) + self.assertEqual(meld, None) + + strategy = YakuhaiStrategy(BaseStrategy.YAKUHAI, player) + self.assertEqual(strategy.should_activate_strategy(player.tiles), True) + + tile = self._string_to_136_tile(man='4') + meld, _ = player.try_to_call_meld(tile, True) + self.assertEqual(meld, None) + + strategy = YakuhaiStrategy(BaseStrategy.YAKUHAI, player) + self.assertEqual(strategy.should_activate_strategy(player.tiles), True) + + # 7 pin is a good meld, we get to tempai keeping yakuhai wait + tile = self._string_to_136_tile(pin='7') + meld, _ = player.try_to_call_meld(tile, True) + self.assertNotEqual(meld, None) + + strategy = YakuhaiStrategy(BaseStrategy.YAKUHAI, player) + self.assertEqual(strategy.should_activate_strategy(player.tiles), True) + + def test_atodzuke_choose_hidden_syanpon(self): + # make sure yakuhai strategy is activated by adding 3 doras to the hand + table = Table() + player = table.player + table.add_dora_indicator(self._string_to_136_tile(man='9')) + + tiles = self._string_to_136_array(man='111678', sou='56678', honors='77') + player.init_hand(tiles) + + meld = self._make_meld(Meld.PON, man='111') + player.add_called_meld(meld) + + strategy = YakuhaiStrategy(BaseStrategy.YAKUHAI, player) + self.assertEqual(strategy.should_activate_strategy(player.tiles), True) + + for _ in range(0, 4): + table.add_discarded_tile(1, self._string_to_136_tile(sou='9'), False) + + player.draw_tile(self._string_to_136_tile(man='6')) + discarded_tile = player.discard_tile() + self.assertNotEqual(self._to_string([discarded_tile]), '6m') + + def test_tempai_with_open_yakuhai_meld_and_yakuhai_pair_in_the_hand(self): + """ + there was a bug where bot didn't handle tempai properly + with opened yakuhai pon and pair in the hand + 56m555p6678s55z + [777z] + """ + table = Table() + player = table.player + + tiles = self._string_to_136_array(man='56', pin='555', sou='667', honors='55777') + player.init_hand(tiles) + player.add_called_meld(self._make_meld(Meld.PON, honors='777')) + player.draw_tile(self._string_to_136_tile(sou='8')) + + player.ai.current_strategy = YakuhaiStrategy(BaseStrategy.YAKUHAI, player) + + discarded_tile = player.discard_tile() + self.assertEqual(self._to_string([discarded_tile]), '6s') diff --git a/project/game/ai/first_version/tests/tests_ai.py b/project/game/ai/first_version/tests/tests_ai.py index 8bb30aa7..94ab478a 100644 --- a/project/game/ai/first_version/tests/tests_ai.py +++ b/project/game/ai/first_version/tests/tests_ai.py @@ -18,16 +18,16 @@ def test_set_is_tempai_flag_to_the_player(self): tile = self._string_to_136_array(man='9')[0] player.init_hand(tiles) player.draw_tile(tile) - player.discard_tile() + self.assertEqual(player.in_tempai, False) tiles = self._string_to_136_array(sou='11145677', pin='345', man='56') tile = self._string_to_136_array(man='9')[0] player.init_hand(tiles) player.draw_tile(tile) - player.discard_tile() + self.assertEqual(player.in_tempai, True) def test_not_open_hand_in_riichi(self): @@ -64,6 +64,10 @@ def test_chose_right_set_to_open_hand(self): table.has_open_tanyao = True player = table.player + # add 3 doras so we are sure to go for tanyao + table.add_dora_indicator(self._string_to_136_tile(pin='2')) + table.add_dora_indicator(self._string_to_136_tile(pin='3')) + tiles = self._string_to_136_array(man='23455', pin='3445678', honors='1') tile = self._string_to_136_tile(man='5') player.init_hand(tiles) @@ -75,6 +79,8 @@ def test_chose_right_set_to_open_hand(self): table = Table() player = table.player + # add 3 doras so we are sure to go for tanyao + table.add_dora_indicator(self._string_to_136_tile(man='5')) tiles = self._string_to_136_array(man='335666', pin='22', sou='345', honors='55') player.init_hand(tiles) @@ -87,6 +93,9 @@ def test_chose_right_set_to_open_hand(self): table = Table() table.has_open_tanyao = True player = table.player + # add 3 doras so we are sure to go for tanyao + table.add_dora_indicator(self._string_to_136_tile(man='1')) + table.add_dora_indicator(self._string_to_136_tile(man='4')) tiles = self._string_to_136_array(man='23557', pin='556788', honors='22') player.init_hand(tiles) @@ -96,6 +105,137 @@ def test_chose_right_set_to_open_hand(self): self.assertEqual(meld.type, Meld.PON) self.assertEqual(self._to_string(meld.tiles), '555p') + table = Table() + table.has_open_tanyao = True + player = table.player + # add 3 doras so we are sure to go for tanyao + table.add_dora_indicator(self._string_to_136_tile(man='4')) + table.add_dora_indicator(self._string_to_136_tile(pin='5')) + tiles = self._string_to_136_array(man='3556', pin='234668', sou='248') + player.init_hand(tiles) + + tile = self._string_to_136_tile(man='4') + meld, _ = player.try_to_call_meld(tile, True) + self.assertNotEqual(meld, None) + self.assertEqual(meld.type, Meld.CHI) + self.assertEqual(self._to_string(meld.tiles), '345m') + + table = Table() + table.has_open_tanyao = True + player = table.player + # add 3 doras so we are sure to go for tanyao + table.add_dora_indicator(self._string_to_136_tile(man='4')) + table.add_dora_indicator(self._string_to_136_tile(pin='5')) + tiles = self._string_to_136_array(man='3445', pin='234668', sou='248') + player.init_hand(tiles) + + tile = self._string_to_136_tile(man='4') + meld, _ = player.try_to_call_meld(tile, True) + self.assertNotEqual(meld, None) + self.assertEqual(meld.type, Meld.CHI) + self.assertEqual(self._to_string(meld.tiles), '345m') + + table = Table() + table.has_open_tanyao = True + player = table.player + # add 3 doras so we are sure to go for tanyao + table.add_dora_indicator(self._string_to_136_tile(man='7')) + tiles = self._string_to_136_array(man='567888', pin='788', sou='3456') + player.init_hand(tiles) + + tile = self._string_to_136_tile(sou='4') + meld, _ = player.try_to_call_meld(tile, True) + self.assertNotEqual(meld, None) + self.assertEqual(meld.type, Meld.CHI) + self.assertEqual(self._to_string(meld.tiles), '456s') + + tile = self._string_to_136_tile(sou='5') + meld, _ = player.try_to_call_meld(tile, True) + self.assertNotEqual(meld, None) + self.assertEqual(meld.type, Meld.CHI) + self.assertEqual(self._to_string(meld.tiles), '345s') + + table = Table() + table.has_open_tanyao = True + player = table.player + # add 3 doras so we are sure to go for tanyao + table.add_dora_indicator(self._string_to_136_tile(man='7')) + tiles = self._string_to_136_array(man='567888', pin='788', sou='2345') + player.init_hand(tiles) + + tile = self._string_to_136_tile(sou='4') + meld, _ = player.try_to_call_meld(tile, True) + self.assertNotEqual(meld, None) + self.assertEqual(meld.type, Meld.CHI) + self.assertEqual(self._to_string(meld.tiles), '234s') + + def test_chose_right_set_to_open_hand_dora(self): + table = Table() + table.has_open_tanyao = True + table.has_aka_dora = False + player = table.player + # add 3 doras so we are sure to go for tanyao + table.add_dora_indicator(self._string_to_136_tile(man='7')) + table.add_dora_indicator(self._string_to_136_tile(sou='1')) + tiles = self._string_to_136_array(man='3456788', sou='245888') + player.init_hand(tiles) + + tile = self._string_to_136_tile(sou='3') + meld, _ = player.try_to_call_meld(tile, True) + self.assertNotEqual(meld, None) + self.assertEqual(meld.type, Meld.CHI) + self.assertEqual(self._to_string(meld.tiles), '234s') + + table = Table() + table.has_open_tanyao = True + table.has_aka_dora = False + player = table.player + # add 3 doras so we are sure to go for tanyao + table.add_dora_indicator(self._string_to_136_tile(man='7')) + table.add_dora_indicator(self._string_to_136_tile(sou='4')) + tiles = self._string_to_136_array(man='3456788', sou='245888') + player.init_hand(tiles) + + tile = self._string_to_136_tile(sou='3') + meld, _ = player.try_to_call_meld(tile, True) + self.assertNotEqual(meld, None) + self.assertEqual(meld.type, Meld.CHI) + self.assertEqual(self._to_string(meld.tiles), '345s') + + table = Table() + table.has_open_tanyao = True + table.has_aka_dora = True + player = table.player + # add 3 doras so we are sure to go for tanyao + table.add_dora_indicator(self._string_to_136_tile(man='7')) + # 5 from string is always aka + tiles = self._string_to_136_array(man='3456788', sou='245888') + player.init_hand(tiles) + + tile = self._string_to_136_tile(sou='3') + meld, _ = player.try_to_call_meld(tile, True) + self.assertNotEqual(meld, None) + self.assertEqual(meld.type, Meld.CHI) + self.assertEqual(self._to_string(meld.tiles), '345s') + + table = Table() + table.has_open_tanyao = True + table.has_aka_dora = True + player = table.player + # add 3 doras so we are sure to go for tanyao + table.add_dora_indicator(self._string_to_136_tile(man='7')) + table.add_dora_indicator(self._string_to_136_tile(sou='1')) + table.add_dora_indicator(self._string_to_136_tile(sou='4')) + # double dora versus regular dora, we should keep double dora + tiles = self._string_to_136_array(man='3456788', sou='245888') + player.init_hand(tiles) + + tile = self._string_to_136_tile(sou='3') + meld, _ = player.try_to_call_meld(tile, True) + self.assertNotEqual(meld, None) + self.assertEqual(meld.type, Meld.CHI) + self.assertEqual(self._to_string(meld.tiles), '345s') + def test_not_open_hand_for_not_needed_set(self): """ We don't need to open hand if it is not improve the hand. @@ -104,6 +244,7 @@ def test_not_open_hand_for_not_needed_set(self): table = Table() player = table.player + table.dora_indicators.append(self._string_to_136_tile(honors='7')) tiles = self._string_to_136_array(man='22457', sou='12234', pin='9', honors='55') player.init_hand(tiles) @@ -123,25 +264,44 @@ def test_chose_strategy_and_reset_strategy(self): table.has_open_tanyao = True player = table.player + # add 3 doras so we are sure to go for tanyao + table.add_dora_indicator(self._string_to_136_tile(man='2')) + + # we draw a tile that will set tanyao as our selected strategy tiles = self._string_to_136_array(man='33355788', sou='3479', honors='3') player.init_hand(tiles) + + tile = self._string_to_136_tile(sou='7') + player.draw_tile(tile) + self.assertNotEqual(player.ai.current_strategy, None) self.assertEqual(player.ai.current_strategy.type, BaseStrategy.TANYAO) # we draw a tile that will change our selected strategy + tiles = self._string_to_136_array(man='33355788', sou='3479', honors='3') + player.init_hand(tiles) + + tile = self._string_to_136_tile(sou='2') + meld, _ = player.try_to_call_meld(tile, False) + self.assertNotEqual(player.ai.current_strategy, None) + self.assertEqual(player.ai.current_strategy.type, BaseStrategy.TANYAO) + self.assertEqual(meld, None) + tile = self._string_to_136_tile(sou='8') player.draw_tile(tile) self.assertEqual(player.ai.current_strategy, None) + # for already opened hand we don't need to give up on selected strategy tiles = self._string_to_136_array(man='33355788', sou='3479', honors='3') player.init_hand(tiles) - self.assertEqual(player.ai.current_strategy.type, BaseStrategy.TANYAO) + player.draw_tile(self._string_to_136_tile(honors='5')) + player.discard_tile() - # for already opened hand we don't need to give up on selected strategy - meld = Meld() - meld.tiles = [1, 2, 3] + meld = self._make_meld(Meld.PON, man='333') player.add_called_meld(meld) tile = self._string_to_136_tile(sou='8') player.draw_tile(tile) + + self.assertNotEqual(player.ai.current_strategy, None) self.assertEqual(player.ai.current_strategy.type, BaseStrategy.TANYAO) def test_remaining_tiles_and_enemy_discard(self): @@ -151,22 +311,22 @@ def test_remaining_tiles_and_enemy_discard(self): tiles = self._string_to_136_array(man='123456789', sou='167', honors='77') player.init_hand(tiles) - results, shanten = player.ai.calculate_outs(tiles, tiles) + results, shanten = player.ai.hand_builder.find_discard_options(tiles, tiles) result = [x for x in results if x.tile_to_discard == self._string_to_34_tile(sou='1')][0] - self.assertEqual(result.tiles_count, 8) + self.assertEqual(result.ukeire, 8) player.table.add_discarded_tile(1, self._string_to_136_tile(sou='5'), False) - results, shanten = player.ai.calculate_outs(tiles, tiles) + results, shanten = player.ai.hand_builder.find_discard_options(tiles, tiles) result = [x for x in results if x.tile_to_discard == self._string_to_34_tile(sou='1')][0] - self.assertEqual(result.tiles_count, 7) + self.assertEqual(result.ukeire, 7) player.table.add_discarded_tile(2, self._string_to_136_tile(sou='5'), False) player.table.add_discarded_tile(3, self._string_to_136_tile(sou='8'), False) - results, shanten = player.ai.calculate_outs(tiles, tiles) + results, shanten = player.ai.hand_builder.find_discard_options(tiles, tiles) result = [x for x in results if x.tile_to_discard == self._string_to_34_tile(sou='1')][0] - self.assertEqual(result.tiles_count, 5) + self.assertEqual(result.ukeire, 5) def test_remaining_tiles_and_opened_meld(self): table = Table() @@ -175,9 +335,9 @@ def test_remaining_tiles_and_opened_meld(self): tiles = self._string_to_136_array(man='123456789', sou='167', honors='77') player.init_hand(tiles) - results, shanten = player.ai.calculate_outs(tiles, tiles) + results, shanten = player.ai.hand_builder.find_discard_options(tiles, tiles) result = [x for x in results if x.tile_to_discard == self._string_to_34_tile(sou='1')][0] - self.assertEqual(result.tiles_count, 8) + self.assertEqual(result.ukeire, 8) # was discard and set was opened tile = self._string_to_136_tile(sou='8') @@ -186,9 +346,9 @@ def test_remaining_tiles_and_opened_meld(self): meld.called_tile = tile player.table.add_called_meld(3, meld) - results, shanten = player.ai.calculate_outs(tiles, tiles) + results, shanten = player.ai.hand_builder.find_discard_options(tiles, tiles) result = [x for x in results if x.tile_to_discard == self._string_to_34_tile(sou='1')][0] - self.assertEqual(result.tiles_count, 5) + self.assertEqual(result.ukeire, 5) # was discard and set was opened tile = self._string_to_136_tile(sou='3') @@ -197,9 +357,9 @@ def test_remaining_tiles_and_opened_meld(self): meld.called_tile = tile player.table.add_called_meld(2, meld) - results, shanten = player.ai.calculate_outs(tiles, tiles) + results, shanten = player.ai.hand_builder.find_discard_options(tiles, tiles) result = [x for x in results if x.tile_to_discard == self._string_to_34_tile(sou='1')][0] - self.assertEqual(result.tiles_count, 4) + self.assertEqual(result.ukeire, 4) def test_remaining_tiles_and_dora_indicators(self): table = Table() @@ -208,15 +368,15 @@ def test_remaining_tiles_and_dora_indicators(self): tiles = self._string_to_136_array(man='123456789', sou='167', honors='77') player.init_hand(tiles) - results, shanten = player.ai.calculate_outs(tiles, tiles) + results, shanten = player.ai.hand_builder.find_discard_options(tiles, tiles) result = [x for x in results if x.tile_to_discard == self._string_to_34_tile(sou='1')][0] - self.assertEqual(result.tiles_count, 8) + self.assertEqual(result.ukeire, 8) table.add_dora_indicator(self._string_to_136_tile(sou='8')) - results, shanten = player.ai.calculate_outs(tiles, tiles) + results, shanten = player.ai.hand_builder.find_discard_options(tiles, tiles) result = [x for x in results if x.tile_to_discard == self._string_to_34_tile(sou='1')][0] - self.assertEqual(result.tiles_count, 7) + self.assertEqual(result.ukeire, 7) def test_using_tiles_of_different_suit_for_chi(self): """ @@ -226,6 +386,7 @@ def test_using_tiles_of_different_suit_for_chi(self): player = table.player # 16m2679p1348s111z + table.dora_indicators.append(self._string_to_136_tile(honors='4')) tiles = [0, 21, 41, 56, 61, 70, 74, 80, 84, 102, 108, 110, 111] player.init_hand(tiles) @@ -234,132 +395,238 @@ def test_using_tiles_of_different_suit_for_chi(self): meld, _ = player.try_to_call_meld(tile, True) self.assertIsNotNone(meld) - def test_upgrade_opened_pon_to_kan(self): + def test_call_upgrade_pon_and_bad_ukeire_after_call(self): table = Table() - player = table.player + table.count_of_remaining_tiles = 10 tiles = self._string_to_136_array(man='34445', sou='123456', pin='89') - player.init_hand(tiles) + table.player.init_hand(tiles) tile = self._string_to_136_tile(man='4') - player.draw_tile(tile) + + self.assertEqual(table.player.should_call_kan(tile, False), None) + + table.player.add_called_meld(self._make_meld(Meld.PON, man='444')) + + self.assertEqual(len(table.player.melds), 1) + self.assertEqual(len(table.player.tiles), 13) + self.assertEqual(table.player.should_call_kan(tile, False), None) + + def test_call_upgrade_pon_and_bad_ukeire_after_call_second_case(self): + table = Table() + table.add_dora_indicator(self._string_to_136_tile(honors='5')) + table.count_of_remaining_tiles = 10 + player = table.player + + tiles = self._string_to_136_array(man='3455567', sou='222', honors='666') + player.init_hand(tiles) + player.add_called_meld(self._make_meld(Meld.PON, man='555')) + player.add_called_meld(self._make_meld(Meld.PON, honors='666')) + + tile = self._string_to_136_tile(man='5') self.assertEqual(player.should_call_kan(tile, False), None) - player.add_called_meld(self._make_meld(Meld.PON, man='444')) + player.draw_tile(tile) + discarded_tile = player.discard_tile() - self.assertEqual(len(player.melds), 1) - self.assertEqual(len(player.tiles), 14) - self.assertEqual(player.should_call_kan(tile, False), Meld.CHANKAN) + self.assertEqual(self._to_string([discarded_tile]), '2s') + + def test_call_upgrade_pon_and_bad_ukeire_after_call_third_case(self): + table = Table() + table.count_of_remaining_tiles = 10 + player = table.player + + tiles = self._string_to_136_array(man='67', pin='6', sou='1344478999') + table.player.init_hand(tiles) + table.player.add_called_meld(self._make_meld(Meld.PON, sou='444')) + + tile = self._string_to_136_tile(sou='4') + + # we don't want to call shouminkan here + self.assertEqual(table.player.should_call_kan(tile, False), None) - player.discard_tile() player.draw_tile(tile) - player.add_called_meld(self._make_meld(Meld.CHANKAN, man='4444')) + discarded_tile = player.discard_tile() - self.assertEqual(len(player.melds), 1) - self.assertEqual(player.melds[0].type, Meld.CHANKAN) - self.assertEqual(len(player.tiles), 13) + self.assertEqual(self._to_string([discarded_tile]), '6p') + + def test_call_shouminkan(self): + table = Table() + table.count_of_remaining_tiles = 10 + + tiles = self._string_to_136_array(man='3455567', sou='222', honors='666') + table.player.init_hand(tiles) + table.player.add_called_meld(self._make_meld(Meld.PON, honors='666')) + + tile = self._string_to_136_tile(honors='6') + + self.assertEqual(table.player.should_call_kan(tile, False), Meld.CHANKAN) def test_call_closed_kan(self): table = Table() - player = table.player + table.count_of_remaining_tiles = 10 tiles = self._string_to_136_array(man='12223', sou='111456', pin='12') - player.init_hand(tiles) + table.player.init_hand(tiles) tile = self._string_to_136_tile(man='2') - player.draw_tile(tile) # it is pretty stupid to call closed kan with 2m - self.assertEqual(player.should_call_kan(tile, False), None) + self.assertEqual(table.player.should_call_kan(tile, False), None) tiles = self._string_to_136_array(man='12223', sou='111456', pin='12') - player.init_hand(tiles) + table.player.init_hand(tiles) tile = self._string_to_136_tile(sou='1') - player.draw_tile(tile) # call closed kan with 1s is fine - self.assertEqual(player.should_call_kan(tile, False), Meld.KAN) + self.assertEqual(table.player.should_call_kan(tile, False), Meld.KAN) def test_opened_kan(self): table = Table() - player = table.player + table.count_of_remaining_tiles = 10 tiles = self._string_to_136_array(man='299', sou='111456', pin='1', honors='111') - player.init_hand(tiles) + table.player.init_hand(tiles) # to rebuild all caches - player.draw_tile(self._string_to_136_tile(pin='9')) - player.discard_tile() + table.player.draw_tile(self._string_to_136_tile(pin='9')) + table.player.discard_tile() # our hand is closed, we don't need to call opened kan here tile = self._string_to_136_tile(sou='1') - self.assertEqual(player.should_call_kan(tile, True), None) + self.assertEqual(table.player.should_call_kan(tile, True), None) - player.add_called_meld(self._make_meld(Meld.PON, honors='111')) + table.player.add_called_meld(self._make_meld(Meld.PON, honors='111')) # our hand is open, but it is not tempai # we don't need to open kan here tile = self._string_to_136_tile(sou='1') - self.assertEqual(player.should_call_kan(tile, True), None) + self.assertEqual(table.player.should_call_kan(tile, True), None) + def test_opened_kan_second_case(self): table = Table() - player = table.player + table.count_of_remaining_tiles = 10 tiles = self._string_to_136_array(man='2399', sou='111456', honors='111') - player.init_hand(tiles) - player.add_called_meld(self._make_meld(Meld.PON, honors='111')) + table.player.init_hand(tiles) + table.player.add_called_meld(self._make_meld(Meld.PON, honors='111')) # to rebuild all caches - player.draw_tile(self._string_to_136_tile(pin='9')) - player.discard_tile() + table.player.draw_tile(self._string_to_136_tile(pin='9')) + table.player.discard_tile() # our hand is open, in tempai and with a good wait tile = self._string_to_136_tile(sou='1') - self.assertEqual(player.should_call_kan(tile, True), Meld.KAN) + self.assertEqual(table.player.should_call_kan(tile, True), Meld.KAN) + + def test_opened_kan_third_case(self): + # we are in tempai already and there was a crash on 5s meld suggestion + + table = Table() + table.count_of_remaining_tiles = 10 + table.add_dora_indicator(self._string_to_136_tile(honors='5')) + + tiles = self._string_to_136_array(man='456', sou='55567678', honors='66') + table.player.init_hand(tiles) + table.player.add_called_meld(self._make_meld(Meld.CHI, sou='678')) + + # to rebuild all caches + table.player.draw_tile(self._string_to_136_tile(pin='9')) + table.player.discard_tile() + + tile = self._string_to_136_tile(sou='5') + self.assertEqual(table.player.should_call_kan(tile, True), None) + self.assertEqual(table.player.try_to_call_meld(tile, True), (None, None)) + + def test_dont_call_kan_in_defence_mode(self): + table = Table() + + tiles = self._string_to_136_array(man='12589', sou='111459', pin='12') + table.player.init_hand(tiles) + + table.add_called_riichi(1) + + tile = self._string_to_136_tile(sou='1') + self.assertEqual(table.player.should_call_kan(tile, False), None) - def test_closed_kan_and_riichi(self): + def test_closed_kan_and_wrong_shanten_number_calculation(self): + """ + Bot tried to call riichi with 567m666p14578s + [9999s] hand + """ table = Table() - table.count_of_remaining_tiles = 60 player = table.player - player.scores = 25000 - kan_tiles = self._string_to_136_array(pin='7777') - tiles = self._string_to_136_array(pin='568', sou='1235788') + kan_tiles[:3] + tiles = self._string_to_136_array(man='56', sou='14578999', pin='666') player.init_hand(tiles) - - # +3 to avoid tile duplication of 7 pin - tile = kan_tiles[3] + tile = self._string_to_136_tile(man='7') + player.melds.append(self._make_meld(Meld.KAN, False, sou='9999')) player.draw_tile(tile) + player.discard_tile() - kan_type = player.should_call_kan(tile, False) - self.assertEqual(kan_type, Meld.KAN) + # bot not in the tempai, because all 9s in the closed kan + self.assertEqual(player.ai.shanten, 1) - meld = Meld() - meld.type = Meld.KAN - meld.tiles = kan_tiles - meld.called_tile = tile - meld.who = 0 - meld.from_who = 0 - meld.opened = False + def test_closed_kan_and_not_necessary_call(self): + """ + Bot tried to call closed kan with 568m669p1478999s + 9s hand + """ + table = Table() + player = table.player - # replacement from the dead wall - player.draw_tile(self._string_to_136_tile(pin='4')) - table.add_called_meld(meld.who, meld) - discard = player.discard_tile() + tiles = self._string_to_136_array(man='568', sou='1478999', pin='669') + player.init_hand(tiles) + tile = self._string_to_136_tile(sou='9') - self.assertEqual(self._to_string([discard]), '8p') - self.assertEqual(player.can_call_riichi(), True) + self.assertEqual(player.should_call_kan(tile, False), None) - # with closed kan we can't call riichi - player.melds[0].opened = True - self.assertEqual(player.can_call_riichi(), False) + def test_closed_kan_same_shanten_bad_ukeire(self): + """ + Bot tried to call closed kan with 4557888899m2z + 333m melded hand + Shanten number is the same, but ukeire becomes much worse + """ + table = Table() + player = table.player - def test_dont_call_kan_in_defence_mode(self): + table.add_dora_indicator(self._string_to_136_tile(honors='2')) + table.add_dora_indicator(self._string_to_136_tile(honors='4')) + + table.count_of_remaining_tiles = 10 + + tiles = self._string_to_136_array(man='333455788899', honors='3') + player.init_hand(tiles) + player.melds.append(self._make_meld(Meld.PON, man='333')) + + tile = self._string_to_136_tile(man='8') + + self.assertEqual(player.should_call_kan(tile, False), None) + + def test_closed_kan_same_shanten_same_ukeire(self): table = Table() + player = table.player - tiles = self._string_to_136_array(man='12589', sou='111459', pin='12') - table.player.init_hand(tiles) + table.add_dora_indicator(self._string_to_136_tile(honors='2')) + table.add_dora_indicator(self._string_to_136_tile(honors='4')) - table.add_called_riichi(1) + table.count_of_remaining_tiles = 10 + + tiles = self._string_to_136_array(man='3334557889', honors='333') + player.init_hand(tiles) + player.melds.append(self._make_meld(Meld.PON, man='333')) + + tile = self._string_to_136_tile(honors='3') + + self.assertEqual(player.should_call_kan(tile, False), Meld.KAN) + + def test_kan_crash(self): + """ + This was a crash in real game + related with open kan logic and agari without yaku state + """ + table = Table() + table.count_of_remaining_tiles = 10 + + tiles = self._string_to_136_array(man='456', pin='78999', sou='666', honors='33') + table.player.init_hand(tiles) + table.player.add_called_meld(self._make_meld(Meld.PON, sou='666')) + tile = self._string_to_136_tile(pin='9') - tile = self._string_to_136_tile(sou='1') self.assertEqual(table.player.should_call_kan(tile, False), None) diff --git a/project/game/ai/first_version/tests/tests_defence.py b/project/game/ai/first_version/tests/tests_defence.py index 17ce0e02..8bbe4a0c 100644 --- a/project/game/ai/first_version/tests/tests_defence.py +++ b/project/game/ai/first_version/tests/tests_defence.py @@ -150,12 +150,6 @@ def test_should_go_for_defence_and_good_hand_with_drawn_tile(self): table.add_called_riichi(3) - results, shanten = table.player.ai.calculate_outs(table.player.tiles, - table.player.closed_hand, - table.player.open_hand_34_tiles) - selected_tile = table.player.ai.process_discard_options_and_select_tile_to_discard(results, shanten) - - self.assertEqual(table.player.ai.defence.should_go_to_defence_mode(selected_tile), False) result = table.player.discard_tile() self.assertEqual(self._to_string([result]), '8m') @@ -251,9 +245,13 @@ def test_try_to_discard_less_valuable_tiles_first_in_defence_mode(self): def test_find_impossible_waits_and_honor_tiles(self): table = Table() - tiles = self._string_to_136_array(honors='1133') + tiles = self._string_to_136_array(honors='1133', man='123', sou='456', pin='999') table.player.init_hand(tiles) + table.player.add_called_meld(self._make_meld(Meld.CHI, man='123')) + table.player.add_called_meld(self._make_meld(Meld.CHI, sou='456')) + table.player.add_called_meld(self._make_meld(Meld.PON, pin='999')) + table.add_discarded_tile(1, self._string_to_136_tile(honors='1'), False) table.add_discarded_tile(1, self._string_to_136_tile(honors='3'), False) @@ -268,9 +266,11 @@ def test_find_impossible_waits_and_honor_tiles(self): def test_find_impossible_waits_and_kabe_technique(self): table = Table() - tiles = self._string_to_136_array(pin='11122777799') + tiles = self._string_to_136_array(pin='11122777799', man='999') table.player.init_hand(tiles) + table.player.add_called_meld(self._make_meld(Meld.PON, man='999')) + table.add_discarded_tile(1, self._string_to_136_tile(pin='2'), False) table.add_discarded_tile(1, self._string_to_136_tile(pin='2'), False) table.add_discarded_tile(1, self._string_to_136_tile(pin='9'), False) @@ -284,9 +284,12 @@ def test_find_impossible_waits_and_kabe_technique(self): self.assertEqual(self._to_string([x.value * 4 for x in result]), '19p') table = Table() - tiles = self._string_to_136_array(pin='33337777') + tiles = self._string_to_136_array(pin='33337777', man='888999') table.player.init_hand(tiles) + table.player.add_called_meld(self._make_meld(Meld.PON, man='888')) + table.player.add_called_meld(self._make_meld(Meld.PON, man='999')) + table.add_discarded_tile(1, self._string_to_136_tile(pin='5'), False) table.add_discarded_tile(1, self._string_to_136_tile(pin='5'), False) table.add_discarded_tile(1, self._string_to_136_tile(pin='5'), False) @@ -300,9 +303,11 @@ def test_find_impossible_waits_and_kabe_technique(self): self.assertEqual(self._to_string([x.value * 4 for x in result]), '5p') table = Table() - tiles = self._string_to_136_array(pin='33334446666') + tiles = self._string_to_136_array(pin='33334446666', man='999') table.player.init_hand(tiles) + table.player.add_called_meld(self._make_meld(Meld.PON, man='999')) + table.add_discarded_tile(1, self._string_to_136_tile(pin='5'), False) table.add_discarded_tile(1, self._string_to_136_tile(pin='5'), False) table.add_discarded_tile(1, self._string_to_136_tile(pin='5'), False) @@ -423,7 +428,7 @@ def test_defence_against_honitsu_first_case(self): def test_defence_against_honitsu_second_case(self): table = Table() - tiles = self._string_to_136_array(sou='4', pin='223456', man='678', honors='66') + tiles = self._string_to_136_array(sou='4', pin='2223456', man='678', honors='66') table.player.init_hand(tiles) table.add_called_meld(1, self._make_meld(Meld.CHI, sou='789')) diff --git a/project/game/ai/first_version/tests/tests_discards.py b/project/game/ai/first_version/tests/tests_discards.py index 88aa1d52..ea29fbbf 100644 --- a/project/game/ai/first_version/tests/tests_discards.py +++ b/project/game/ai/first_version/tests/tests_discards.py @@ -3,8 +3,12 @@ from mahjong.constants import EAST, SOUTH, WEST, NORTH, HAKU, HATSU, CHUN, FIVE_RED_SOU, FIVE_RED_PIN from mahjong.tests_mixin import TestMixin +from mahjong.meld import Meld +from mahjong.tile import Tile from game.ai.discard import DiscardOption +from game.ai.first_version.strategies.main import BaseStrategy +from game.ai.first_version.strategies.tanyao import TanyaoStrategy from game.table import Table @@ -21,22 +25,22 @@ def test_discard_tile(self): discarded_tile = player.discard_tile() self.assertEqual(self._to_string([discarded_tile]), '9m') - self.assertEqual(player.ai.previous_shanten, 2) + self.assertEqual(player.ai.shanten, 2) player.draw_tile(self._string_to_136_tile(pin='4')) discarded_tile = player.discard_tile() self.assertEqual(self._to_string([discarded_tile]), '1p') - self.assertEqual(player.ai.previous_shanten, 2) + self.assertEqual(player.ai.shanten, 2) player.draw_tile(self._string_to_136_tile(pin='3')) discarded_tile = player.discard_tile() self.assertEqual(self._to_string([discarded_tile]), '9p') - self.assertEqual(player.ai.previous_shanten, 1) + self.assertEqual(player.ai.shanten, 1) player.draw_tile(self._string_to_136_tile(man='4')) discarded_tile = player.discard_tile() self.assertEqual(self._to_string([discarded_tile]), '5m') - self.assertEqual(player.ai.previous_shanten, 0) + self.assertEqual(player.ai.shanten, 0) def test_discard_tile_force_tsumogiri(self): table = Table() @@ -44,7 +48,8 @@ def test_discard_tile_force_tsumogiri(self): player = table.player tiles = self._string_to_136_array(sou='11134567', pin='456', man='45') - tile = 57 # 6p + # 6p + tile = 57 player.init_hand(tiles) player.draw_tile(tile) @@ -52,7 +57,8 @@ def test_discard_tile_force_tsumogiri(self): discarded_tile = player.discard_tile() self.assertEqual(discarded_tile, tile) - tiles = self._string_to_136_array(sou='11134567', pin='46', man='45') + [53] # simple five pin + # add not red five pin + tiles = self._string_to_136_array(sou='11134567', pin='46', man='45') + [53] tile = FIVE_RED_PIN player.init_hand(tiles) @@ -66,6 +72,32 @@ def test_calculate_suit_tiles_value(self): table = Table() player = table.player + # 0 - 8 man + # 9 - 17 pin + # 18 - 26 sou + results = [ + [0, 110], [9, 110], [18, 110], + [1, 120], [10, 120], [19, 120], + [2, 140], [11, 140], [20, 140], + [3, 150], [12, 150], [21, 150], + [4, 130], [13, 130], [22, 130], + [5, 150], [14, 150], [23, 150], + [6, 140], [15, 140], [24, 140], + [7, 120], [16, 120], [25, 120], + [8, 110], [17, 110], [26, 110] + ] + + for item in results: + tile = item[0] + value = item[1] + option = DiscardOption(player, tile, 0, [], 0) + self.assertEqual(option.valuation, value) + + def test_calculate_suit_tiles_value_and_tanyao_hand(self): + table = Table() + player = table.player + player.ai.current_strategy = TanyaoStrategy(BaseStrategy.TANYAO, player) + # 0 - 8 man # 9 - 17 pin # 18 - 26 sou @@ -73,9 +105,9 @@ def test_calculate_suit_tiles_value(self): [0, 110], [9, 110], [18, 110], [1, 120], [10, 120], [19, 120], [2, 130], [11, 130], [20, 130], - [3, 140], [12, 140], [21, 140], - [4, 150], [13, 150], [22, 150], - [5, 140], [14, 140], [23, 140], + [3, 150], [12, 150], [21, 150], + [4, 140], [13, 140], [22, 140], + [5, 150], [14, 150], [23, 150], [6, 130], [15, 130], [24, 130], [7, 120], [16, 120], [25, 120], [8, 110], [17, 110], [26, 110] @@ -133,19 +165,37 @@ def test_calculate_suit_tiles_value_and_dora(self): tile = self._string_to_34_tile(sou='1') option = DiscardOption(player, tile, 0, [], 0) - self.assertEqual(option.valuation, 160) + self.assertEqual(option.valuation, DiscardOption.DORA_VALUE + 110) # double dora table.dora_indicators = [self._string_to_136_tile(sou='9'), self._string_to_136_tile(sou='9')] tile = self._string_to_34_tile(sou='1') option = DiscardOption(player, tile, 0, [], 0) - self.assertEqual(option.valuation, 210) + self.assertEqual(option.valuation, DiscardOption.DORA_VALUE * 2 + 110) + + # tile close to dora + table.dora_indicators = [self._string_to_136_tile(sou='9')] + tile = self._string_to_34_tile(sou='2') + option = DiscardOption(player, tile, 0, [], 0) + self.assertEqual(option.valuation, DiscardOption.DORA_FIRST_NEIGHBOUR + 120) + + # tile not far away from dora + table.dora_indicators = [self._string_to_136_tile(sou='9')] + tile = self._string_to_34_tile(sou='3') + option = DiscardOption(player, tile, 0, [], 0) + self.assertEqual(option.valuation, DiscardOption.DORA_SECOND_NEIGHBOUR + 140) + + # tile from other suit + table.dora_indicators = [self._string_to_136_tile(sou='9')] + tile = self._string_to_34_tile(man='3') + option = DiscardOption(player, tile, 0, [], 0) + self.assertEqual(option.valuation, 140) def test_discard_not_valuable_honor_first(self): table = Table() player = table.player - tiles = self._string_to_136_array(sou='123456', pin='123456', man='9', honors='2') + tiles = self._string_to_136_array(sou='123456', pin='123455', man='9', honors='2') player.init_hand(tiles) discarded_tile = player.discard_tile() @@ -192,7 +242,7 @@ def test_dont_keep_honor_with_small_number_of_shanten(self): discarded_tile = player.discard_tile() self.assertEqual(self._to_string([discarded_tile]), '7z') - def test_prefer_valuable_tiles_with_almost_same_tiles_count(self): + def test_prefer_valuable_tiles_with_almost_same_ukeire(self): table = Table() player = table.player table.add_dora_indicator(self._string_to_136_tile(sou='4')) @@ -203,3 +253,581 @@ def test_prefer_valuable_tiles_with_almost_same_tiles_count(self): discarded_tile = player.discard_tile() self.assertEqual(self._to_string([discarded_tile]), '1s') + + def test_discard_less_valuable_isolated_tile_first(self): + table = Table() + player = table.player + table.add_dora_indicator(self._string_to_136_tile(sou='4')) + + tiles = self._string_to_136_array(sou='2456', pin='129', man='234458') + player.init_hand(tiles) + player.draw_tile(self._string_to_136_tile(sou='7')) + + discarded_tile = player.discard_tile() + # we have a choice what to discard: 9p or 8m + # 9p is less valuable + self.assertEqual(self._to_string([discarded_tile]), '9p') + + table.dora_indicators.append(self._string_to_136_tile(pin='8')) + tiles = self._string_to_136_array(sou='2456', pin='129', man='234458') + player.init_hand(tiles) + player.draw_tile(self._string_to_136_tile(sou='7')) + discarded_tile = player.discard_tile() + # but if 9p is dora + # let's discard 8m instead + self.assertEqual(self._to_string([discarded_tile]), '8m') + + def test_discard_tile_with_max_ukeire_second_level(self): + table = Table() + player = table.player + table.add_dora_indicator(self._string_to_136_tile(sou='4')) + + tiles = self._string_to_136_array(sou='11367', pin='45', man='344778') + player.init_hand(tiles) + player.draw_tile(self._string_to_136_tile(pin='6')) + + discarded_tile = player.discard_tile() + self.assertEqual(self._to_string([discarded_tile]), '3s') + + # There was a bug with count of live tiles that are used in melds, + # hence this test + def test_choose_best_option_with_melds(self): + table = Table() + player = table.player + table.has_aka_dora = False + + tiles = self._string_to_136_array(sou='245666789', honors='2266') + player.init_hand(tiles) + + meld = self._make_meld(Meld.PON, sou='666') + player.add_called_meld(meld) + meld = self._make_meld(Meld.CHI, sou='789') + player.add_called_meld(meld) + + player.draw_tile(self._string_to_136_tile(sou='5')) + + discarded_tile = player.discard_tile() + # we should discard best ukeire option here - 2s + self.assertEqual(self._to_string([discarded_tile]), '2s') + + def test_choose_best_wait_with_melds(self): + table = Table() + player = table.player + table.has_aka_dora = False + + tiles = self._string_to_136_array(sou='1222233455599') + player.init_hand(tiles) + + meld = self._make_meld(Meld.CHI, sou='123') + player.add_called_meld(meld) + meld = self._make_meld(Meld.PON, sou='222') + player.add_called_meld(meld) + meld = self._make_meld(Meld.PON, sou='555') + player.add_called_meld(meld) + + player.draw_tile(self._string_to_136_tile(sou='4')) + + discarded_tile = player.discard_tile() + # double-pairs wait becomes better, because it has 4 tiles to wait for + # against just 1 in ryanmen + self.assertEqual(self._to_string([discarded_tile]), '3s') + + def test_discard_tile_with_better_wait_in_iishanten(self): + table = Table() + player = table.player + table.add_dora_indicator(self._string_to_136_tile(sou='4')) + + tiles = self._string_to_136_array(man='123567', pin='113788', sou='99') + player.init_hand(tiles) + + discarded_tile = player.discard_tile() + self.assertEqual(self._to_string([discarded_tile]), '8p') + + def test_discard_tile_and_wrong_tiles_valuation(self): + """ + Bot wanted to discard 5m from the first hand, + because valuation for 2p was miscalculated (too high) + + Same issue with wrong valuation was with second hand + """ + table = Table() + player = table.player + table.add_dora_indicator(self._string_to_136_tile(honors='2')) + + tiles = self._string_to_136_array(man='445567', pin='245678', sou='67') + player.init_hand(tiles) + + discarded_tile = player.discard_tile() + self.assertEqual(self._to_string([discarded_tile]), '2p') + + table = Table() + player = table.player + table.add_dora_indicator(self._string_to_136_tile(man='5')) + + tiles = self._string_to_136_array(man='45667', pin='34677', sou='38', honors='22') + player.init_hand(tiles) + + discarded_tile = player.discard_tile() + self.assertEqual(self._to_string([discarded_tile]), '8s') + + def test_choose_correct_wait_finished_yaku(self): + table = Table() + player = table.player + player.round_step = 2 + + tiles = self._string_to_136_array(man='23478', sou='23488', pin='235') + player.init_hand(tiles) + player.draw_tile(self._string_to_136_tile(pin='4')) + discarded_tile = player.discard_tile() + self.assertEqual(self._to_string([discarded_tile]), '5p') + + tiles = self._string_to_136_array(man='34578', sou='34588', pin='235') + player.init_hand(tiles) + player.draw_tile(self._string_to_136_tile(pin='4')) + discarded_tile = player.discard_tile() + self.assertEqual(self._to_string([discarded_tile]), '2p') + + tiles = self._string_to_136_array(man='34578', sou='34588', pin='235') + player.init_hand(tiles) + player.draw_tile(self._string_to_136_tile(pin='4')) + discarded_tile = player.discard_tile() + self.assertEqual(self._to_string([discarded_tile]), '2p') + + tiles = self._string_to_136_array(man='3457', sou='233445588') + player.init_hand(tiles) + player.draw_tile(self._string_to_136_tile(man='8')) + discarded_tile = player.discard_tile() + self.assertEqual(self._to_string([discarded_tile]), '2s') + + tiles = self._string_to_136_array(man='3457', sou='223344588') + player.init_hand(tiles) + player.draw_tile(self._string_to_136_tile(man='8')) + discarded_tile = player.discard_tile() + self.assertEqual(self._to_string([discarded_tile]), '5s') + + def test_choose_correct_wait_yaku_versus_dora(self): + table = Table() + player = table.player + player.round_step = 2 + + table.add_dora_indicator(self._string_to_136_tile(pin='4')) + + tiles = self._string_to_136_array(man='23478', sou='23488', pin='235') + player.init_hand(tiles) + player.draw_tile(self._string_to_136_tile(pin='4')) + discarded_tile = player.discard_tile() + self.assertEqual(self._to_string([discarded_tile]), '5p') + + table = Table() + player = table.player + player.round_step = 2 + + table.add_dora_indicator(self._string_to_136_tile(pin='1')) + + tiles = self._string_to_136_array(man='34578', sou='34588', pin='235') + player.init_hand(tiles) + player.draw_tile(self._string_to_136_tile(pin='4')) + discarded_tile = player.discard_tile() + self.assertEqual(self._to_string([discarded_tile]), '2p') + + def test_choose_correct_wait_yaku_potentially(self): + table = Table() + player = table.player + player.round_step = 2 + + tiles = self._string_to_136_array(man='1134578', sou='567788') + player.init_hand(tiles) + player.draw_tile(self._string_to_136_tile(man='9')) + discarded_tile = player.discard_tile() + self.assertEqual(self._to_string([discarded_tile]), '5s') + + tiles = self._string_to_136_array(man='1134578', sou='556678') + player.init_hand(tiles) + player.draw_tile(self._string_to_136_tile(man='9')) + discarded_tile = player.discard_tile() + self.assertEqual(self._to_string([discarded_tile]), '8s') + + def test_choose_better_tanki_honor(self): + table = Table() + player = table.player + player.round_step = 2 + player.dealer_seat = 3 + + table.add_dora_indicator(self._string_to_136_tile(man='8')) + + tiles = self._string_to_136_array(man='11447799', sou='556', honors='45') + player.init_hand(tiles) + player.draw_tile(self._string_to_136_tile(honors='4')) + discarded_tile = player.discard_tile() + self.assertEqual(self._to_string([discarded_tile]), '6s') + + tiles = self._string_to_136_array(man='11447799', sou='556', honors='45') + player.init_hand(tiles) + player.draw_tile(self._string_to_136_tile(honors='5')) + discarded_tile = player.discard_tile() + self.assertEqual(self._to_string([discarded_tile]), '6s') + + tiles = self._string_to_136_array(man='11447799', sou='556', honors='45') + player.init_hand(tiles) + player.draw_tile(self._string_to_136_tile(sou='6')) + discarded_tile = player.discard_tile() + self.assertEqual(self._to_string([discarded_tile]), '5z') + + tiles = self._string_to_136_array(man='11447799', sou='556', honors='34') + player.init_hand(tiles) + player.draw_tile(self._string_to_136_tile(sou='6')) + discarded_tile = player.discard_tile() + self.assertEqual(self._to_string([discarded_tile]), '3z') + + def _choose_tanki_with_kabe_helper(self, tiles, kabe_tiles, tile_to_draw, tile_to_discard_str): + table = Table() + player = table.player + player.round_step = 2 + player.dealer_seat = 3 + + for tile in kabe_tiles: + for _ in range(0, 4): + table.add_discarded_tile(1, tile, False) + + player.init_hand(tiles) + player.draw_tile(tile_to_draw) + discarded_tile = player.discard_tile() + self.assertEqual(self._to_string([discarded_tile]), tile_to_discard_str) + + def test_choose_tanki_with_kabe(self): + self._choose_tanki_with_kabe_helper( + self._string_to_136_array(sou='119', pin='224477', man='5669'), + [self._string_to_136_tile(sou='8')], + self._string_to_136_tile(man='5'), + '9m' + ) + + self._choose_tanki_with_kabe_helper( + self._string_to_136_array(sou='119', pin='224477', man='5669'), + [self._string_to_136_tile(man='8')], + self._string_to_136_tile(man='5'), + '9s' + ) + + self._choose_tanki_with_kabe_helper( + self._string_to_136_array(sou='118', pin='224477', man='5668'), + [self._string_to_136_tile(sou='7')], + self._string_to_136_tile(man='5'), + '8m' + ) + + self._choose_tanki_with_kabe_helper( + self._string_to_136_array(sou='118', pin='224477', man='5668'), + [self._string_to_136_tile(man='7')], + self._string_to_136_tile(man='5'), + '8s' + ) + + self._choose_tanki_with_kabe_helper( + self._string_to_136_array(sou='117', pin='224477', man='1157'), + [self._string_to_136_tile(sou='6'), self._string_to_136_tile(sou='9')], + self._string_to_136_tile(man='5'), + '7m' + ) + + self._choose_tanki_with_kabe_helper( + self._string_to_136_array(sou='117', pin='224477', man='1157'), + [self._string_to_136_tile(man='6'), self._string_to_136_tile(man='9')], + self._string_to_136_tile(man='5'), + '7s' + ) + + self._choose_tanki_with_kabe_helper( + self._string_to_136_array(sou='116', pin='224477', man='1126'), + [self._string_to_136_tile(sou='5'), self._string_to_136_tile(sou='7')], + self._string_to_136_tile(man='2'), + '6m' + ) + + self._choose_tanki_with_kabe_helper( + self._string_to_136_array(sou='116', pin='224477', man='1126'), + [self._string_to_136_tile(man='5'), self._string_to_136_tile(man='7')], + self._string_to_136_tile(man='2'), + '6s' + ) + + self._choose_tanki_with_kabe_helper( + self._string_to_136_array(sou='115', pin='224477', man='1125'), + [self._string_to_136_tile(sou='4'), self._string_to_136_tile(sou='6')], + self._string_to_136_tile(man='2'), + '5m' + ) + + self._choose_tanki_with_kabe_helper( + self._string_to_136_array(sou='115', pin='224477', man='1125'), + [self._string_to_136_tile(man='4'), self._string_to_136_tile(man='6')], + self._string_to_136_tile(man='2'), + '5s' + ) + + def _choose_tanki_with_suji_helper(self, tiles, suji_tiles, tile_to_draw, tile_to_discard_str): + table = Table() + player = table.player + player.round_step = 2 + player.dealer_seat = 3 + + player.init_hand(tiles) + + for tile in suji_tiles: + player.add_discarded_tile(Tile(tile, True)) + + player.draw_tile(tile_to_draw) + discarded_tile = player.discard_tile() + self.assertEqual(self._to_string([discarded_tile]), tile_to_discard_str) + + def test_choose_tanki_with_suji(self): + self._choose_tanki_with_suji_helper( + self._string_to_136_array(man='22336688', sou='19', pin='99', honors='2'), + [self._string_to_136_tile(sou='6')], + self._string_to_136_tile(honors='2'), + '1s' + ) + + self._choose_tanki_with_suji_helper( + self._string_to_136_array(man='22336688', sou='19', pin='99', honors='2'), + [self._string_to_136_tile(sou='4')], + self._string_to_136_tile(honors='2'), + '9s' + ) + + self._choose_tanki_with_suji_helper( + self._string_to_136_array(man='22336688', sou='2', pin='299', honors='2'), + [self._string_to_136_tile(sou='5')], + self._string_to_136_tile(honors='2'), + '2p' + ) + + self._choose_tanki_with_suji_helper( + self._string_to_136_array(man='22336688', sou='2', pin='299', honors='2'), + [self._string_to_136_tile(pin='5')], + self._string_to_136_tile(honors='2'), + '2s' + ) + + self._choose_tanki_with_suji_helper( + self._string_to_136_array(man='22336688', sou='3', pin='399', honors='2'), + [self._string_to_136_tile(sou='6')], + self._string_to_136_tile(honors='2'), + '3p' + ) + + self._choose_tanki_with_suji_helper( + self._string_to_136_array(man='22336688', sou='3', pin='399', honors='2'), + [self._string_to_136_tile(pin='6')], + self._string_to_136_tile(honors='2'), + '3s' + ) + + self._choose_tanki_with_suji_helper( + self._string_to_136_array(man='22336688', sou='4', pin='499', honors='2'), + [self._string_to_136_tile(sou='1'), self._string_to_136_tile(sou='7')], + self._string_to_136_tile(honors='2'), + '4p' + ) + + self._choose_tanki_with_suji_helper( + self._string_to_136_array(man='22336688', sou='4', pin='499', honors='2'), + [self._string_to_136_tile(pin='1'), self._string_to_136_tile(pin='7')], + self._string_to_136_tile(honors='2'), + '4s' + ) + + self._choose_tanki_with_suji_helper( + self._string_to_136_array(man='22336688', sou='5', pin='599', honors='2'), + [self._string_to_136_tile(sou='2'), self._string_to_136_tile(sou='8')], + self._string_to_136_tile(honors='2'), + '5p' + ) + + self._choose_tanki_with_suji_helper( + self._string_to_136_array(man='22336688', sou='5', pin='599', honors='2'), + [self._string_to_136_tile(pin='2'), self._string_to_136_tile(pin='8')], + self._string_to_136_tile(honors='2'), + '5s' + ) + + def _avoid_furiten_helper(self, tiles, furiten_tile, other_tile, tile_to_draw, tile_to_discard_str): + table = Table() + player = table.player + player.round_step = 2 + player.dealer_seat = 3 + + player.init_hand(tiles) + + player.add_discarded_tile(Tile(furiten_tile, True)) + + for _ in range(0, 2): + table.add_discarded_tile(1, other_tile, False) + + player.draw_tile(tile_to_draw) + discarded_tile = player.discard_tile() + self.assertEqual(self._to_string([discarded_tile]), tile_to_discard_str) + + def test_avoid_furiten(self): + self._avoid_furiten_helper( + self._string_to_136_array(man='22336688', pin='99', honors='267'), + self._string_to_136_tile(honors='6'), + self._string_to_136_tile(honors='7'), + self._string_to_136_tile(honors='2'), + '6z' + ) + + self._avoid_furiten_helper( + self._string_to_136_array(man='22336688', pin='99', honors='267'), + self._string_to_136_tile(honors='7'), + self._string_to_136_tile(honors='6'), + self._string_to_136_tile(honors='2'), + '7z' + ) + + def _choose_furiten_over_karaten_helper(self, tiles, furiten_tile, karaten_tile, tile_to_draw, tile_to_discard_str): + table = Table() + player = table.player + player.round_step = 2 + player.dealer_seat = 3 + + player.init_hand(tiles) + + player.add_discarded_tile(Tile(furiten_tile, True)) + + for _ in range(0, 3): + table.add_discarded_tile(1, karaten_tile, False) + + player.draw_tile(tile_to_draw) + discarded_tile = player.discard_tile() + self.assertEqual(self._to_string([discarded_tile]), tile_to_discard_str) + + def test_choose_furiten_over_karaten(self): + self._choose_furiten_over_karaten_helper( + self._string_to_136_array(man='22336688', pin='99', honors='267'), + self._string_to_136_tile(honors='6'), + self._string_to_136_tile(honors='7'), + self._string_to_136_tile(honors='2'), + '7z' + ) + + self._choose_furiten_over_karaten_helper( + self._string_to_136_array(man='22336688', pin='99', honors='267'), + self._string_to_136_tile(honors='7'), + self._string_to_136_tile(honors='6'), + self._string_to_136_tile(honors='2'), + '6z' + ) + + def test_discard_tile_based_on_second_level_ukeire_and_cost(self): + table = Table() + player = table.player + + table.add_dora_indicator(self._string_to_136_tile(man='2')) + table.add_discarded_tile(1, self._string_to_136_tile(man='2'), False) + + tiles = self._string_to_136_array(man='34678', pin='2356', sou='4467') + tile = self._string_to_136_tile(sou='8') + + player.init_hand(tiles) + player.draw_tile(tile) + + discarded_tile = player.discard_tile() + discard_correct = self._to_string([discarded_tile]) == '2p' or self._to_string([discarded_tile]) == '3p' + self.assertEqual(discard_correct, True) + + def test_calculate_second_level_ukeire(self): + """ + There was a bug with 2356 form and second level ukeire + """ + table = Table() + player = table.player + + table.add_dora_indicator(self._string_to_136_tile(man='2')) + table.add_discarded_tile(1, self._string_to_136_tile(man='2'), False) + table.add_discarded_tile(1, self._string_to_136_tile(pin='3'), False) + table.add_discarded_tile(1, self._string_to_136_tile(pin='3'), False) + + tiles = self._string_to_136_array(man='34678', pin='2356', sou='4467') + tile = self._string_to_136_tile(sou='8') + + player.init_hand(tiles) + player.draw_tile(tile) + + discard_options, _ = player.ai.hand_builder.find_discard_options( + player.tiles, + player.closed_hand, + player.melds + ) + + tile = self._string_to_136_tile(man='4') + discard_option = [x for x in discard_options if x.tile_to_discard == tile // 4][0] + player.ai.hand_builder.calculate_second_level_ukeire(discard_option, player.tiles, player.melds) + self.assertEqual(discard_option.ukeire_second, 108) + + tile = self._string_to_136_tile(man='3') + discard_option = [x for x in discard_options if x.tile_to_discard == tile // 4][0] + player.ai.hand_builder.calculate_second_level_ukeire(discard_option, player.tiles, player.melds) + self.assertEqual(discard_option.ukeire_second, 108) + + tile = self._string_to_136_tile(pin='2') + discard_option = [x for x in discard_options if x.tile_to_discard == tile // 4][0] + player.ai.hand_builder.calculate_second_level_ukeire(discard_option, player.tiles, player.melds) + self.assertEqual(discard_option.ukeire_second, 96) + + tile = self._string_to_136_tile(pin='3') + discard_option = [x for x in discard_options if x.tile_to_discard == tile // 4][0] + player.ai.hand_builder.calculate_second_level_ukeire(discard_option, player.tiles, player.melds) + self.assertEqual(discard_option.ukeire_second, 96) + + tile = self._string_to_136_tile(pin='5') + discard_option = [x for x in discard_options if x.tile_to_discard == tile // 4][0] + player.ai.hand_builder.calculate_second_level_ukeire(discard_option, player.tiles, player.melds) + self.assertEqual(discard_option.ukeire_second, 96) + + tile = self._string_to_136_tile(pin='6') + discard_option = [x for x in discard_options if x.tile_to_discard == tile // 4][0] + player.ai.hand_builder.calculate_second_level_ukeire(discard_option, player.tiles, player.melds) + self.assertEqual(discard_option.ukeire_second, 96) + + def test_choose_1_shanten_with_cost_possibility_draw(self): + table = Table() + player = table.player + table.add_dora_indicator(self._string_to_136_tile(sou='4')) + + tiles = self._string_to_136_array(man='557', pin='468', sou='55577', honors='66') + player.init_hand(tiles) + + meld = self._make_meld(Meld.PON, sou='555') + player.add_called_meld(meld) + + tile = self._string_to_136_tile(sou='7') + player.draw_tile(tile) + discarded_tile = player.discard_tile() + self.assertNotEqual(player.ai.current_strategy, None) + self.assertEqual(player.ai.current_strategy.type, BaseStrategy.YAKUHAI) + self.assertEqual(self._to_string([discarded_tile]), '7m') + + def test_choose_1_shanten_with_cost_possibility_meld(self): + table = Table() + player = table.player + table.add_dora_indicator(self._string_to_136_tile(sou='4')) + + tiles = self._string_to_136_array(man='557', pin='468', sou='55577', honors='66') + player.init_hand(tiles) + + meld = self._make_meld(Meld.PON, sou='555') + player.add_called_meld(meld) + + tile = self._string_to_136_tile(sou='7') + meld, discard_option = player.try_to_call_meld(tile, False) + self.assertNotEqual(meld, None) + self.assertEqual(meld.type, Meld.PON) + self.assertEqual(self._to_string(meld.tiles), '777s') + + self.assertNotEqual(player.ai.current_strategy, None) + self.assertEqual(player.ai.current_strategy.type, BaseStrategy.YAKUHAI) + + discarded_tile = discard_option.find_tile_in_hand(player.closed_hand) + + self.assertEqual(self._to_string([discarded_tile]), '7m') diff --git a/project/game/ai/first_version/tests/tests_riichi.py b/project/game/ai/first_version/tests/tests_riichi.py index bdf65a73..574e0f25 100644 --- a/project/game/ai/first_version/tests/tests_riichi.py +++ b/project/game/ai/first_version/tests/tests_riichi.py @@ -2,50 +2,199 @@ import unittest from mahjong.tests_mixin import TestMixin +from mahjong.tile import Tile from game.table import Table class CallRiichiTestCase(unittest.TestCase, TestMixin): + def _make_table(self, dora_indicators=None): + self.table = Table() + self.table.count_of_remaining_tiles = 60 + self.player = self.table.player + self.player.scores = 25000 - def test_dont_call_riichi_with_tanki_wait(self): - table = Table() - table.count_of_remaining_tiles = 60 - player = table.player - player.scores = 25000 + # with that we don't have daburi anymore + self.player.round_step = 1 - tiles = self._string_to_136_array(sou='123456', pin='123456', man='3') - player.init_hand(tiles) + if dora_indicators: + for x in dora_indicators: + self.table.add_dora_indicator(x) - player.draw_tile(self._string_to_136_tile(man='4')) - player.discard_tile() + def test_dont_call_riichi_with_yaku_and_central_tanki_wait(self): + self._make_table() - self.assertEqual(player.can_call_riichi(), False) + tiles = self._string_to_136_array(sou='234567', pin='234567', man='4') + self.player.init_hand(tiles) + self.player.draw_tile(self._string_to_136_tile(man='5')) + self.player.discard_tile() - table = Table() - table.count_of_remaining_tiles = 60 - player = table.player - player.scores = 25000 + self.assertEqual(self.player.can_call_riichi(), False) - tiles = self._string_to_136_array(sou='1133557799', pin='113') - tile = self._string_to_136_tile(pin='6') - player.init_hand(tiles) - player.draw_tile(tile) - player.discard_tile() + def test_dont_call_riichi_expensive_damaten_with_yaku(self): + self._make_table(dora_indicators=[ + self._string_to_136_tile(man='7'), + self._string_to_136_tile(man='5'), + self._string_to_136_tile(sou='1'), + ]) - # for chitoitsu it is ok to have a pair wait - self.assertEqual(player.can_call_riichi(), True) + # tanyao pinfu sanshoku dora 4 - this is damaten baiman, let's not riichi it + tiles = self._string_to_136_array(man='67888', sou='678', pin='34678') + self.player.init_hand(tiles) + self.player.draw_tile(self._string_to_136_tile(honors='3')) + self.player.discard_tile() + self.assertEqual(self.player.can_call_riichi(), False) - def test_call_riichi_and_penchan_wait(self): - table = Table() - table.count_of_remaining_tiles = 60 - player = table.player - player.scores = 25000 + # let's test lots of doras hand, tanyao dora 8, also damaten baiman + tiles = self._string_to_136_array(man='666888', sou='22', pin='34678') + self.player.init_hand(tiles) + self.player.draw_tile(self._string_to_136_tile(honors='3')) + self.player.discard_tile() + self.assertEqual(self.player.can_call_riichi(), False) + + # chuuren + tiles = self._string_to_136_array(man='1112345678999') + self.player.init_hand(tiles) + self.player.draw_tile(self._string_to_136_tile(honors='3')) + self.player.discard_tile() + self.assertEqual(self.player.can_call_riichi(), False) + + def test_riichi_expensive_hand_without_yaku(self): + self._make_table(dora_indicators=[ + self._string_to_136_tile(man='1'), + self._string_to_136_tile(sou='1'), + self._string_to_136_tile(pin='1') + ]) + + tiles = self._string_to_136_array(man='222', sou='22278', pin='22789') + self.player.init_hand(tiles) + self.player.draw_tile(self._string_to_136_tile(honors='3')) + self.player.discard_tile() + self.assertEqual(self.player.can_call_riichi(), True) + + def test_riichi_tanki_honor_without_yaku(self): + self._make_table( + dora_indicators=[ + self._string_to_136_tile(man='2'), + self._string_to_136_tile(sou='6') + ] + ) + + tiles = self._string_to_136_array(man='345678', sou='789', pin='123', honors='2') + self.player.init_hand(tiles) + self.player.draw_tile(self._string_to_136_tile(honors='3')) + self.player.discard_tile() + self.assertEqual(self.player.can_call_riichi(), True) + + def test_riichi_tanki_honor_chiitoitsu(self): + self._make_table() + + tiles = self._string_to_136_array(man='22336688', sou='99', pin='99', honors='2') + self.player.init_hand(tiles) + self.player.draw_tile(self._string_to_136_tile(honors='3')) + self.player.discard_tile() + self.assertEqual(self.player.can_call_riichi(), True) + + def test_always_call_daburi(self): + self._make_table() + self.player.round_step = 0 + + tiles = self._string_to_136_array(sou='234567', pin='234567', man='4') + self.player.init_hand(tiles) + self.player.draw_tile(self._string_to_136_tile(man='5')) + self.player.discard_tile() + + self.assertEqual(self.player.can_call_riichi(), True) + + def test_dont_call_karaten_tanki_riichi(self): + self._make_table() + + tiles = self._string_to_136_array(man='22336688', sou='99', pin='99', honors='2') + self.player.init_hand(tiles) + + for _ in range(0, 3): + self.table.add_discarded_tile(1, self._string_to_136_tile(honors='2'), False) + self.table.add_discarded_tile(1, self._string_to_136_tile(honors='3'), False) + + self.player.draw_tile(self._string_to_136_tile(honors='3')) + self.player.discard_tile() + self.assertEqual(self.player.can_call_riichi(), False) + + def test_dont_call_karaten_ryanmen_riichi(self): + self._make_table(dora_indicators=[ + self._string_to_136_tile(man='1'), + self._string_to_136_tile(sou='1'), + self._string_to_136_tile(pin='1') + ]) + + tiles = self._string_to_136_array(man='222', sou='22278', pin='22789') + self.player.init_hand(tiles) + + for _ in range(0, 4): + self.table.add_discarded_tile(1, self._string_to_136_tile(sou='6'), False) + self.table.add_discarded_tile(1, self._string_to_136_tile(sou='9'), False) + + self.player.draw_tile(self._string_to_136_tile(honors='3')) + self.player.discard_tile() + self.assertEqual(self.player.can_call_riichi(), False) + + def test_call_riichi_penchan_with_suji(self): + self._make_table(dora_indicators=[ + self._string_to_136_tile(pin='1'), + ]) tiles = self._string_to_136_array(sou='11223', pin='234567', man='66') - tile = self._string_to_136_tile(man='9') - player.init_hand(tiles) - player.draw_tile(tile) - player.discard_tile() + self.player.init_hand(tiles) + self.player.draw_tile(self._string_to_136_tile(sou='6')) + self.player.discard_tile() + + self.assertEqual(self.player.can_call_riichi(), True) + + def test_call_riichi_tanki_with_kabe(self): + self._make_table(dora_indicators=[ + self._string_to_136_tile(pin='1'), + ]) + + for _ in range(0, 3): + self.table.add_discarded_tile(1, self._string_to_136_tile(honors='1'), False) + + for _ in range(0, 4): + self.table.add_discarded_tile(1, self._string_to_136_tile(sou='8'), False) + + tiles = self._string_to_136_array(sou='1119', pin='234567', man='666') + self.player.init_hand(tiles) + self.player.draw_tile(self._string_to_136_tile(honors='1')) + self.player.discard_tile() + + self.assertEqual(self.player.can_call_riichi(), True) + + def test_call_riichi_chiitoitsu_with_suji(self): + self._make_table(dora_indicators=[ + self._string_to_136_tile(man='1'), + ]) + + for _ in range(0, 3): + self.table.add_discarded_tile(1, self._string_to_136_tile(honors='3'), False) + + tiles = self._string_to_136_array(man='22336688', sou='9', pin='99', honors='22') + self.player.init_hand(tiles) + self.player.add_discarded_tile(Tile(self._string_to_136_tile(sou='6'), True)) + + self.player.draw_tile(self._string_to_136_tile(honors='3')) + self.player.discard_tile() + self.assertEqual(self.player.can_call_riichi(), True) + + def test_dont_call_riichi_chiitoitsu_bad_wait(self): + self._make_table(dora_indicators=[ + self._string_to_136_tile(man='1'), + ]) + + for _ in range(0, 3): + self.table.add_discarded_tile(1, self._string_to_136_tile(honors='3'), False) + + tiles = self._string_to_136_array(man='22336688', sou='4', pin='99', honors='22') + self.player.init_hand(tiles) - self.assertEqual(player.can_call_riichi(), True) + self.player.draw_tile(self._string_to_136_tile(honors='3')) + self.player.discard_tile() + self.assertEqual(self.player.can_call_riichi(), False) diff --git a/project/game/ai/first_version/tests/tests_strategies.py b/project/game/ai/first_version/tests/tests_strategies.py deleted file mode 100644 index d9bae4b7..00000000 --- a/project/game/ai/first_version/tests/tests_strategies.py +++ /dev/null @@ -1,650 +0,0 @@ -# -*- coding: utf-8 -*- -import unittest - -from mahjong.meld import Meld -from mahjong.tests_mixin import TestMixin - -from game.ai.first_version.strategies.honitsu import HonitsuStrategy -from game.ai.first_version.strategies.main import BaseStrategy -from game.ai.first_version.strategies.tanyao import TanyaoStrategy -from game.ai.first_version.strategies.yakuhai import YakuhaiStrategy -from game.table import Table - - -class YakuhaiStrategyTestCase(unittest.TestCase, TestMixin): - - def test_should_activate_strategy(self): - table = Table() - player = table.player - strategy = YakuhaiStrategy(BaseStrategy.YAKUHAI, player) - - tiles = self._string_to_136_array(sou='12355689', man='89', honors='123') - player.init_hand(tiles) - self.assertEqual(strategy.should_activate_strategy(), False) - - tiles = self._string_to_136_array(sou='12355689', man='89', honors='44') - player.init_hand(tiles) - player.dealer_seat = 1 - self.assertEqual(strategy.should_activate_strategy(), True) - - tiles = self._string_to_136_array(sou='12355689', man='89', honors='666') - player.init_hand(tiles) - self.assertEqual(strategy.should_activate_strategy(), True) - - # with chitoitsu-like hand we don't need to go for yakuhai - tiles = self._string_to_136_array(sou='1235566', man='8899', honors='66') - player.init_hand(tiles) - self.assertEqual(strategy.should_activate_strategy(), False) - - def test_dont_activate_strategy_if_we_dont_have_enough_tiles_in_the_wall(self): - table = Table() - player = table.player - strategy = YakuhaiStrategy(BaseStrategy.YAKUHAI, player) - - tiles = self._string_to_136_array(sou='12355689', man='89', honors='44') - player.init_hand(tiles) - player.dealer_seat = 1 - self.assertEqual(strategy.should_activate_strategy(), True) - - table.add_discarded_tile(3, self._string_to_136_tile(honors='4'), False) - table.add_discarded_tile(3, self._string_to_136_tile(honors='4'), False) - - # we can't complete yakuhai, because there is not enough honor tiles - self.assertEqual(strategy.should_activate_strategy(), False) - - def test_suitable_tiles(self): - table = Table() - player = table.player - strategy = YakuhaiStrategy(BaseStrategy.YAKUHAI, player) - - # for yakuhai we can use any tile - for tile in range(0, 136): - self.assertEqual(strategy.is_tile_suitable(tile), True) - - def test_open_hand_with_yakuhai_pair_in_hand(self): - table = Table() - player = table.player - - tiles = self._string_to_136_array(sou='123678', pin='25899', honors='44') - tile = self._string_to_136_tile(honors='4') - player.init_hand(tiles) - - # we don't need to open hand with not our wind - meld, _ = player.try_to_call_meld(tile, False) - self.assertEqual(meld, None) - - # with dragon pair in hand let's open our hand - tiles = self._string_to_136_array(sou='1689', pin='2358', man='1', honors='4455') - tile = self._string_to_136_tile(honors='4') - player.init_hand(tiles) - meld, _ = player.try_to_call_meld(tile, False) - self.assertNotEqual(meld, None) - player.add_called_meld(meld) - player.tiles.append(tile) - - self.assertEqual(meld.type, Meld.PON) - self.assertEqual(self._to_string(meld.tiles), '444z') - self.assertEqual(len(player.closed_hand), 11) - self.assertEqual(len(player.tiles), 14) - player.discard_tile() - - tile = self._string_to_136_tile(honors='5') - meld, _ = player.try_to_call_meld(tile, False) - self.assertNotEqual(meld, None) - player.add_called_meld(meld) - player.tiles.append(tile) - - self.assertEqual(meld.type, Meld.PON) - self.assertEqual(self._to_string(meld.tiles), '555z') - self.assertEqual(len(player.closed_hand), 8) - self.assertEqual(len(player.tiles), 14) - player.discard_tile() - - tile = self._string_to_136_tile(sou='7') - # we can call chi only from left player - meld, _ = player.try_to_call_meld(tile, False) - self.assertEqual(meld, None) - - meld, _ = player.try_to_call_meld(tile, True) - self.assertNotEqual(meld, None) - player.add_called_meld(meld) - player.tiles.append(tile) - - self.assertEqual(meld.type, Meld.CHI) - self.assertEqual(self._to_string(meld.tiles), '678s') - self.assertEqual(len(player.closed_hand), 5) - self.assertEqual(len(player.tiles), 14) - - def test_force_yakuhai_pair_waiting_for_tempai_hand(self): - """ - If hand shanten = 1 don't open hand except the situation where is - we have tempai on yakuhai tile after open set - """ - table = Table() - player = table.player - - tiles = self._string_to_136_array(sou='123', pin='678', man='34468', honors='66') - player.init_hand(tiles) - - # we will not get tempai on yakuhai pair with this hand, so let's skip this call - tile = self._string_to_136_tile(man='5') - meld, _ = player.try_to_call_meld(tile, False) - self.assertEqual(meld, None) - - # but here we will have atodzuke tempai - tile = self._string_to_136_tile(man='7') - meld, _ = player.try_to_call_meld(tile, True) - self.assertNotEqual(meld, None) - self.assertEqual(meld.type, Meld.CHI) - self.assertEqual(self._to_string(meld.tiles), '678m') - - table = Table() - player = table.player - - # we can open hand in that case - tiles = self._string_to_136_array(man='44556', sou='366789', honors='77') - player.init_hand(tiles) - - tile = self._string_to_136_tile(honors='7') - meld, _ = player.try_to_call_meld(tile, True) - self.assertNotEqual(meld, None) - self.assertEqual(self._to_string(meld.tiles), '777z') - - def test_call_yakuhai_pair_and_special_conditions(self): - table = Table() - player = table.player - - tiles = self._string_to_136_array(man='56', sou='1235', pin='12888', honors='11') - player.init_hand(tiles) - - meld = self._make_meld(Meld.PON, pin='888') - player.add_called_meld(meld) - - # to update previous_shanten attribute - player.draw_tile(self._string_to_136_tile(honors='3')) - player.discard_tile() - - tile = self._string_to_136_tile(honors='1') - meld, _ = player.try_to_call_meld(tile, True) - self.assertNotEqual(meld, None) - - def test_tempai_without_yaku(self): - table = Table() - player = table.player - - tiles = self._string_to_136_array(sou='678', pin='12355', man='456', honors='77') - player.init_hand(tiles) - - tile = self._string_to_136_tile(pin='5') - player.draw_tile(tile) - meld = self._make_meld(Meld.CHI, sou='678') - player.add_called_meld(meld) - - discard = player.discard_tile() - self.assertEqual(self._to_string([discard]), '1p') - - def test_get_more_yakuhai_sets_in_hand(self): - table = Table() - - tiles = self._string_to_136_array(sou='1378', pin='67', man='68', honors='5566') - table.player.init_hand(tiles) - - tile = self._string_to_136_tile(honors='5') - meld, discard_option = table.player.try_to_call_meld(tile, False) - self.assertNotEqual(meld, None) - - table.add_called_meld(0, meld) - table.player.tiles.append(tile) - table.player.discard_tile(discard_option) - - tile = self._string_to_136_tile(honors='6') - meld, _ = table.player.try_to_call_meld(tile, False) - self.assertNotEqual(meld, None) - - table = Table() - - tiles = self._string_to_136_array(sou='234', pin='788', man='567', honors='5566') - table.player.init_hand(tiles) - - tile = self._string_to_136_tile(honors='5') - meld, discard_option = table.player.try_to_call_meld(tile, False) - self.assertNotEqual(meld, None) - - table.add_called_meld(0, meld) - table.player.tiles.append(tile) - table.player.discard_tile(discard_option) - - tile = self._string_to_136_tile(honors='6') - meld, _ = table.player.try_to_call_meld(tile, False) - self.assertEqual(meld, None) - - def test_atodzuke_opened_hand(self): - table = Table() - player = table.player - - # 456m12355p22z + 5p [678s] - tiles = self._string_to_136_array(sou='4589', pin='123', man='1236', honors='66') - player.init_hand(tiles) - - tile = self._string_to_136_tile(man='6') - player.draw_tile(tile) - meld = self._make_meld(Meld.CHI, pin='123') - player.add_called_meld(meld) - - discard = player.discard_tile() - self.assertEqual(self._to_string([discard]), '9s') - - def test_wrong_shanten_improvements_detection(self): - """ - 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 = Table() - player = table.player - - tiles = self._string_to_136_array(sou='2345999', honors='114446') - player.init_hand(tiles) - - meld = self._make_meld(Meld.PON, sou='999') - player.add_called_meld(meld) - meld = self._make_meld(Meld.PON, honors='444') - player.add_called_meld(meld) - - # to rebuild all caches - player.draw_tile(self._string_to_136_tile(pin='2')) - player.discard_tile() - - tile = self._string_to_136_tile(sou='2') - meld, _ = table.player.try_to_call_meld(tile, True) - self.assertEqual(meld, None) - - -class HonitsuStrategyTestCase(unittest.TestCase, TestMixin): - - def test_should_activate_strategy(self): - table = Table() - player = table.player - strategy = HonitsuStrategy(BaseStrategy.HONITSU, player) - - tiles = self._string_to_136_array(sou='12355', man='12389', honors='123') - player.init_hand(tiles) - self.assertEqual(strategy.should_activate_strategy(), False) - - tiles = self._string_to_136_array(sou='12355', man='238', honors='11234') - player.init_hand(tiles) - self.assertEqual(strategy.should_activate_strategy(), True) - - # with hand without pairs we not should go for honitsu, - # because it is far away from tempai - tiles = self._string_to_136_array(sou='12358', man='238', honors='12345') - player.init_hand(tiles) - self.assertEqual(strategy.should_activate_strategy(), False) - - # with chitoitsu-like hand we don't need to go for honitsu - tiles = self._string_to_136_array(pin='77', man='3355677899', sou='11') - player.init_hand(tiles) - self.assertEqual(strategy.should_activate_strategy(), False) - - def test_suitable_tiles(self): - table = Table() - player = table.player - strategy = HonitsuStrategy(BaseStrategy.HONITSU, player) - - tiles = self._string_to_136_array(sou='12355', man='238', honors='11234') - player.init_hand(tiles) - self.assertEqual(strategy.should_activate_strategy(), True) - - tile = self._string_to_136_tile(man='1') - self.assertEqual(strategy.is_tile_suitable(tile), False) - - tile = self._string_to_136_tile(pin='1') - self.assertEqual(strategy.is_tile_suitable(tile), False) - - tile = self._string_to_136_tile(sou='1') - self.assertEqual(strategy.is_tile_suitable(tile), True) - - tile = self._string_to_136_tile(honors='1') - self.assertEqual(strategy.is_tile_suitable(tile), True) - - def test_open_hand_and_discard_tiles_logic(self): - table = Table() - player = table.player - - tiles = self._string_to_136_array(sou='112235589', man='24', honors='22') - player.init_hand(tiles) - - # we don't need to call meld even if it improves our hand, - # because we are collecting honitsu - tile = self._string_to_136_tile(man='1') - meld, _ = player.try_to_call_meld(tile, False) - self.assertEqual(meld, None) - - # any honor tile is suitable - tile = self._string_to_136_tile(honors='2') - meld, _ = player.try_to_call_meld(tile, False) - self.assertNotEqual(meld, None) - - tile = self._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 - self.assertEqual(self._to_string([tile_to_discard]), '1m') - - def test_riichi_and_tiles_from_another_suit_in_the_hand(self): - table = Table() - player = table.player - player.scores = 25000 - table.count_of_remaining_tiles = 100 - - tiles = self._string_to_136_array(man='33345678', pin='22', honors='155') - player.init_hand(tiles) - - player.draw_tile(self._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 - self.assertEqual(self._to_string([tile_to_discard]), '1z') - - def test_discard_not_needed_winds(self): - table = Table() - player = table.player - player.scores = 25000 - table.count_of_remaining_tiles = 100 - - tiles = self._string_to_136_array(man='24', pin='4', sou='12344668', honors='36') - player.init_hand(tiles) - player.draw_tile(self._string_to_136_tile(sou='5')) - - table.add_discarded_tile(1, self._string_to_136_tile(honors='3'), False) - table.add_discarded_tile(1, self._string_to_136_tile(honors='3'), False) - table.add_discarded_tile(1, self._string_to_136_tile(honors='3'), False) - - tile_to_discard = player.discard_tile() - - # west was discarded three times, we don't need it - self.assertEqual(self._to_string([tile_to_discard]), '3z') - - def test_discard_not_effective_tiles_first(self): - table = Table() - player = table.player - player.scores = 25000 - table.count_of_remaining_tiles = 100 - - tiles = self._string_to_136_array(man='33', pin='12788999', sou='5', honors='23') - player.init_hand(tiles) - player.draw_tile(self._string_to_136_tile(honors='6')) - tile_to_discard = player.discard_tile() - - self.assertEqual(self._to_string([tile_to_discard]), '5s') - - def test_dont_go_for_honitsu_with_ryanmen_in_other_suit(self): - table = Table() - player = table.player - strategy = HonitsuStrategy(BaseStrategy.HONITSU, player) - - tiles = self._string_to_136_array(man='14489', sou='45', pin='67', honors='44456') - player.init_hand(tiles) - - self.assertEqual(strategy.should_activate_strategy(), False) - - -class TanyaoStrategyTestCase(unittest.TestCase, TestMixin): - - def _make_table(self): - table = Table() - table.has_open_tanyao = True - return table - - def test_should_activate_strategy_and_terminal_pon_sets(self): - table = self._make_table() - player = table.player - strategy = TanyaoStrategy(BaseStrategy.TANYAO, player) - - tiles = self._string_to_136_array(sou='234', man='3459', pin='233', honors='111') - player.init_hand(tiles) - self.assertEqual(strategy.should_activate_strategy(), False) - - tiles = self._string_to_136_array(sou='234', man='3459', pin='233999') - player.init_hand(tiles) - self.assertEqual(strategy.should_activate_strategy(), False) - - tiles = self._string_to_136_array(sou='234', man='3459', pin='233444') - player.init_hand(tiles) - self.assertEqual(strategy.should_activate_strategy(), True) - - def test_should_activate_strategy_and_terminal_pairs(self): - table = self._make_table() - player = table.player - strategy = TanyaoStrategy(BaseStrategy.TANYAO, player) - - tiles = self._string_to_136_array(sou='234', man='3459', pin='2399', honors='11') - player.init_hand(tiles) - self.assertEqual(strategy.should_activate_strategy(), False) - - tiles = self._string_to_136_array(sou='234', man='345669', pin='2399') - player.init_hand(tiles) - self.assertEqual(strategy.should_activate_strategy(), True) - - def test_should_activate_strategy_and_valued_pair(self): - table = self._make_table() - player = table.player - strategy = TanyaoStrategy(BaseStrategy.TANYAO, player) - - tiles = self._string_to_136_array(man='23446679', sou='345', honors='55') - player.init_hand(tiles) - self.assertEqual(strategy.should_activate_strategy(), False) - - tiles = self._string_to_136_array(man='23446679', sou='345', honors='22') - player.init_hand(tiles) - self.assertEqual(strategy.should_activate_strategy(), True) - - def test_should_activate_strategy_and_chitoitsu_like_hand(self): - table = self._make_table() - player = table.player - strategy = TanyaoStrategy(BaseStrategy.TANYAO, player) - - tiles = self._string_to_136_array(sou='223388', man='3344', pin='6687') - player.init_hand(tiles) - self.assertEqual(strategy.should_activate_strategy(), False) - - def test_should_activate_strategy_and_already_completed_sided_set(self): - table = self._make_table() - player = table.player - strategy = TanyaoStrategy(BaseStrategy.TANYAO, player) - - tiles = self._string_to_136_array(sou='123234', man='3459', pin='234') - player.init_hand(tiles) - self.assertEqual(strategy.should_activate_strategy(), False) - - tiles = self._string_to_136_array(sou='234789', man='3459', pin='234') - player.init_hand(tiles) - self.assertEqual(strategy.should_activate_strategy(), False) - - tiles = self._string_to_136_array(sou='234', man='1233459', pin='234') - player.init_hand(tiles) - self.assertEqual(strategy.should_activate_strategy(), False) - - tiles = self._string_to_136_array(sou='234', man='3457899', pin='234') - player.init_hand(tiles) - self.assertEqual(strategy.should_activate_strategy(), False) - - tiles = self._string_to_136_array(sou='234', man='3459', pin='122334') - player.init_hand(tiles) - self.assertEqual(strategy.should_activate_strategy(), False) - - tiles = self._string_to_136_array(sou='234', man='3459', pin='234789') - player.init_hand(tiles) - self.assertEqual(strategy.should_activate_strategy(), False) - - tiles = self._string_to_136_array(sou='223344', man='3459', pin='234') - player.init_hand(tiles) - self.assertEqual(strategy.should_activate_strategy(), True) - - def test_suitable_tiles(self): - table = self._make_table() - player = table.player - strategy = TanyaoStrategy(BaseStrategy.TANYAO, player) - - tile = self._string_to_136_tile(man='1') - self.assertEqual(strategy.is_tile_suitable(tile), False) - - tile = self._string_to_136_tile(pin='1') - self.assertEqual(strategy.is_tile_suitable(tile), False) - - tile = self._string_to_136_tile(sou='9') - self.assertEqual(strategy.is_tile_suitable(tile), False) - - tile = self._string_to_136_tile(honors='1') - self.assertEqual(strategy.is_tile_suitable(tile), False) - - tile = self._string_to_136_tile(honors='6') - self.assertEqual(strategy.is_tile_suitable(tile), False) - - tile = self._string_to_136_tile(man='2') - self.assertEqual(strategy.is_tile_suitable(tile), True) - - tile = self._string_to_136_tile(pin='5') - self.assertEqual(strategy.is_tile_suitable(tile), True) - - tile = self._string_to_136_tile(sou='8') - self.assertEqual(strategy.is_tile_suitable(tile), True) - - def test_dont_open_hand_with_high_shanten(self): - table = self._make_table() - player = table.player - - # with 4 shanten we don't need to aim for open tanyao - tiles = self._string_to_136_array(man='369', pin='378', sou='3488', honors='123') - tile = self._string_to_136_tile(sou='2') - player.init_hand(tiles) - meld, _ = player.try_to_call_meld(tile, False) - self.assertEqual(meld, None) - - # with 3 shanten we can open a hand - tiles = self._string_to_136_array(man='236', pin='378', sou='3488', honors='123') - tile = self._string_to_136_tile(sou='2') - player.init_hand(tiles) - meld, _ = player.try_to_call_meld(tile, True) - self.assertNotEqual(meld, None) - - def test_dont_open_hand_with_not_suitable_melds(self): - table = self._make_table() - player = table.player - - tiles = self._string_to_136_array(man='33355788', sou='3479', honors='3') - tile = self._string_to_136_tile(sou='8') - player.init_hand(tiles) - meld, _ = player.try_to_call_meld(tile, False) - self.assertEqual(meld, None) - - def test_open_hand_and_discard_tiles_logic(self): - table = self._make_table() - player = table.player - - # 2345779m1p256s44z - tiles = self._string_to_136_array(man='22345', sou='238', pin='256', honors='44') - player.init_hand(tiles) - - # if we are in tanyao - # we need to discard terminals and honors - tile = self._string_to_136_tile(sou='4') - meld, discard_option = player.try_to_call_meld(tile, True) - discarded_tile = table.player.discard_tile(discard_option) - self.assertNotEqual(meld, None) - self.assertEqual(self._to_string([discarded_tile]), '4z') - - tile = self._string_to_136_tile(pin='5') - player.draw_tile(tile) - tile_to_discard = player.discard_tile() - - # we are in tanyao, so we should discard honors and terminals - self.assertEqual(self._to_string([tile_to_discard]), '4z') - - def test_dont_count_pairs_in_already_opened_hand(self): - table = self._make_table() - player = table.player - - meld = self._make_meld(Meld.PON, sou='222') - player.add_called_meld(meld) - - tiles = self._string_to_136_array(man='33556788', sou='22266') - player.init_hand(tiles) - - tile = self._string_to_136_tile(sou='6') - meld, _ = player.try_to_call_meld(tile, False) - # even if it looks like chitoitsu we can open hand and get tempai here - self.assertNotEqual(meld, None) - - def test_we_cant_win_with_this_hand(self): - table = self._make_table() - - tiles = self._string_to_136_array(man='34577', sou='23', pin='233445') - table.player.init_hand(tiles) - meld = self._make_meld(Meld.CHI, pin='234') - table.player.add_called_meld(meld) - - table.player.draw_tile(self._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 - self.assertEqual(table.player.ai.previous_shanten, 0) - self.assertEqual(self._to_string([discard]), '1s') - - def test_choose_correct_waiting(self): - table = self._make_table() - player = table.player - - tiles = self._string_to_136_array(man='234678', sou='234', pin='3588') - player.init_hand(tiles) - player.draw_tile(self._string_to_136_tile(pin='2')) - - # discard 5p and riichi - discard = player.discard_tile() - self.assertEqual(self._to_string([discard]), '5p') - - table = self._make_table() - player = table.player - - meld = self._make_meld(Meld.CHI, man='234') - player.add_called_meld(meld) - - tiles = self._string_to_136_array(man='234678', sou='234', pin='3588') - player.init_hand(tiles) - player.draw_tile(self._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 = player.discard_tile() - self.assertEqual(self._to_string([discard]), '2p') - - table = table = self._make_table() - player = table.player - - meld = self._make_meld(Meld.CHI, man='234') - player.add_called_meld(meld) - - tiles = self._string_to_136_array(man='234678', sou='234', pin='2388') - player.init_hand(tiles) - player.draw_tile(self._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() - self.assertEqual(self._to_string([discard]), '7s') - - def test_choose_correct_waiting_and_fist_opened_meld(self): - table = self._make_table() - player = table.player - - tiles = self._string_to_136_array(man='2337788', sou='345', pin='234') - player.init_hand(tiles) - - tile = self._string_to_136_tile(man='8') - meld, tile_to_discard = player.try_to_call_meld(tile, False) - - discard = player.discard_tile(tile_to_discard) - self.assertEqual(self._to_string([discard]), '2m') diff --git a/project/game/player.py b/project/game/player.py index e020c8b8..41d47e59 100644 --- a/project/game/player.py +++ b/project/game/player.py @@ -1,11 +1,13 @@ # -*- coding: utf-8 -*- import logging import copy +import utils.decisions_constants as log from mahjong.constants import EAST, SOUTH, WEST, NORTH, CHUN, HAKU, HATSU from mahjong.meld import Meld from mahjong.tile import TilesConverter, Tile +from utils.decisions_logger import DecisionsLogger from utils.settings_handler import settings logger = logging.getLogger('tenhou') @@ -16,6 +18,7 @@ class PlayerInterface(object): discards = None melds = None in_riichi = None + round_step = None # current player seat seat = 0 @@ -59,13 +62,19 @@ def erase_state(self): self.position = 0 self.scores = None self.uma = 0 + self.round_step = 0 def add_called_meld(self, meld: Meld): - # we already added chankan as a pon set + # we already added shouminkan as a pon set if meld.type == Meld.CHANKAN: tile_34 = meld.tiles[0] // 4 + pon_set = [x for x in self.melds if x.type == Meld.PON and (x.tiles[0] // 4) == tile_34] - self.melds.remove(pon_set[0]) + + # when we are doing reconnect and we have called shouminkan set + # we will not have called pon set in the hand + if pon_set: + self.melds.remove(pon_set[0]) self.melds.append(meld) @@ -79,6 +88,9 @@ def add_discarded_tile(self, tile: Tile): if player.in_riichi and tile not in player.safe_tiles: player.safe_tiles.append(tile) + # one discard == one round step + self.round_step += 1 + @property def player_wind(self): position = self.dealer_seat @@ -111,6 +123,19 @@ def meld_tiles(self): result.extend(meld.tiles) return result + @property + def meld_34_tiles(self): + """ + Array of array with 34 tiles indices + :return: array + """ + melds = [x.tiles for x in self.melds] + melds = copy.deepcopy(melds) + results = [] + for meld in melds: + results.append([meld[0] // 4, meld[1] // 4, meld[2] // 4]) + return results + class Player(PlayerInterface): ai = None @@ -140,14 +165,24 @@ def init_hand(self, tiles): self.ai.init_hand() - def draw_tile(self, tile): - self.last_draw = tile - self.tiles.append(tile) + def draw_tile(self, tile_136): + DecisionsLogger.debug( + log.DRAW, + context=[ + 'Step: {}'.format(self.round_step), + 'Hand: {}'.format(self.format_hand_for_print(tile_136)), + 'In defence: {}'.format(self.ai.in_defence), + 'Current strategy: {}'.format(self.ai.current_strategy) + ] + ) + + self.last_draw = tile_136 + self.tiles.append(tile_136) # we need sort it to have a better string presentation self.tiles = sorted(self.tiles) - self.ai.draw_tile(tile) + self.ai.draw_tile(tile_136) def discard_tile(self, discard_tile=None): """ @@ -180,14 +215,8 @@ def formal_riichi_conditions(self): self.table.count_of_remaining_tiles > 4 ]) - def should_call_kan(self, tile, open_kan): - """ - Method will decide should we call a kan, - or upgrade pon to kan - :param tile: 136 tile format - :return: - """ - return self.ai.should_call_kan(tile, open_kan) + def should_call_kan(self, tile, open_kan, from_riichi=False): + return self.ai.should_call_kan(tile, open_kan, from_riichi) def should_call_win(self, tile, enemy_seat): return self.ai.should_call_win(tile, enemy_seat) @@ -205,25 +234,23 @@ def total_tiles(self, tile, tiles_34): :param tiles_34: cached list of tiles (to not build it for each iteration) :return: int """ - return tiles_34[tile] + self.table.revealed_tiles[tile] + revealed_tiles = tiles_34[tile] + self.table.revealed_tiles[tile] + assert revealed_tiles <= 4, 'we have only 4 tiles in the game' + return revealed_tiles - def add_called_meld(self, meld: Meld): - # we had to remove tile from the hand for closed kan set - if (meld.type == Meld.KAN and not meld.opened) or meld.type == Meld.CHANKAN: - self.tiles.remove(meld.called_tile) + def format_hand_for_print(self, tile_136=None): + hand_string = '{}'.format(TilesConverter.to_one_line_string(self.closed_hand)) - super().add_called_meld(meld) + if tile_136 is not None: + hand_string += ' + {}'.format(TilesConverter.to_one_line_string([tile_136])) - def format_hand_for_print(self, tile): - hand_string = '{} + {}'.format( - TilesConverter.to_one_line_string(self.closed_hand), - TilesConverter.to_one_line_string([tile]) - ) - if self.is_open_hand: - melds = [] - for item in self.melds: - melds.append('{}'.format(TilesConverter.to_one_line_string(item.tiles))) + melds = [] + for item in self.melds: + melds.append('{}'.format(TilesConverter.to_one_line_string(item.tiles))) + + if melds: hand_string += ' [{}]'.format(', '.join(melds)) + return hand_string @property @@ -231,22 +258,9 @@ def closed_hand(self): tiles = self.tiles[:] return [item for item in tiles if item not in self.meld_tiles] - @property - def open_hand_34_tiles(self): - """ - Array of array with 34 tiles indices - :return: array - """ - melds = [x.tiles for x in self.melds if x.opened] - melds = copy.deepcopy(melds) - results = [] - for meld in melds: - results.append([meld[0] // 4, meld[1] // 4, meld[2] // 4]) - return results - @property def valued_honors(self): - return [CHUN, HAKU, HATSU, self.table.round_wind, self.player_wind] + return [CHUN, HAKU, HATSU, self.table.round_wind_tile, self.player_wind] class EnemyPlayer(PlayerInterface): diff --git a/project/game/table.py b/project/game/table.py index eb20ebcd..fdbf82cb 100644 --- a/project/game/table.py +++ b/project/game/table.py @@ -16,13 +16,16 @@ class Table(object): dora_indicators = None dealer_seat = 0 - round_number = 0 + round_number = -1 + round_wind_number = 0 count_of_riichi_sticks = 0 count_of_honba_sticks = 0 count_of_remaining_tiles = 0 count_of_players = 4 + meld_was_called = False + # array of tiles in 34 format revealed_tiles = None @@ -36,14 +39,36 @@ def __init__(self): def __str__(self): dora_string = TilesConverter.to_one_line_string(self.dora_indicators) - return 'Round: {0}, Honba: {1}, Dora Indicators: {2}'.format(self.round_number, - self.count_of_honba_sticks, - dora_string) - def init_round(self, round_number, count_of_honba_sticks, count_of_riichi_sticks, - dora_indicator, dealer_seat, scores): + round_settings = { + EAST: ['e', 0], + SOUTH: ['s', 3], + WEST: ['w', 7] + }.get(self.round_wind_tile) + + round_string, round_diff = round_settings + display_round = '{}{}'.format(round_string, (self.round_wind_number + 1) - round_diff) + + return 'Round: {}, Honba: {}, Dora Indicators: {}'.format( + display_round, + self.count_of_honba_sticks, + dora_string + ) + + def init_round(self, + round_wind_number, + count_of_honba_sticks, + count_of_riichi_sticks, + dora_indicator, + dealer_seat, + scores): + + # we need it to properly display log for each round + self.round_number += 1 + + self.meld_was_called = False self.dealer_seat = dealer_seat - self.round_number = round_number + self.round_wind_number = round_wind_number self.count_of_honba_sticks = count_of_honba_sticks self.count_of_riichi_sticks = count_of_riichi_sticks @@ -63,7 +88,7 @@ def init_round(self, round_number, count_of_honba_sticks, count_of_riichi_sticks # 13 - tiles in each player hand self.count_of_remaining_tiles = 136 - 14 - self.count_of_players * 13 - if round_number == 0 and count_of_honba_sticks == 0: + if round_wind_number == 0 and count_of_honba_sticks == 0: i = 0 seats = [0, 1, 2, 3] for player in self.players: @@ -71,13 +96,19 @@ def init_round(self, round_number, count_of_honba_sticks, count_of_riichi_sticks i += 1 def add_called_meld(self, player_seat, meld): - # when opponent called meld it is means - # that he discards tile from hand, not from wall - self.count_of_remaining_tiles += 1 - - # we will decrease count of remaining tiles after called kan - # because we had to complement dead wall - if meld.type == Meld.KAN or meld.type == meld.CHANKAN: + self.meld_was_called = True + + # if meld was called from the other player, then we skip one draw from the wall + if meld.opened: + # but if it's an opened kan, player will get a tile from + # a dead wall, so total number of tiles in the wall is the same + # as if he just draws a tile + if meld.type != Meld.KAN: + self.count_of_remaining_tiles += 1 + else: + # can't have a pon or chi from the hand + assert meld.type == Meld.KAN or meld.type == meld.CHANKAN + # player draws additional tile from the wall in case of closed kan or shouminkan self.count_of_remaining_tiles -= 1 self.get_player(player_seat).add_called_meld(meld) @@ -85,10 +116,10 @@ def add_called_meld(self, player_seat, meld): tiles = meld.tiles[:] # called tile was already added to revealed array # because it was called on the discard - if meld.called_tile: + if meld.called_tile is not None: tiles.remove(meld.called_tile) - # for chankan we already added 3 tiles + # for shouminkan we already added 3 tiles if meld.type == meld.CHANKAN: tiles = [meld.tiles[0]] @@ -102,15 +133,15 @@ def add_called_riichi(self, player_seat): if player_seat != 0: self.player.enemy_called_riichi(player_seat) - def add_discarded_tile(self, player_seat, tile, is_tsumogiri): + def add_discarded_tile(self, player_seat, tile_136, is_tsumogiri): """ :param player_seat: - :param tile: 136 format tile + :param tile_136: 136 format tile :param is_tsumogiri: was tile discarded from hand or not """ self.count_of_remaining_tiles -= 1 - tile = Tile(tile, is_tsumogiri) + tile = Tile(tile_136, is_tsumogiri) self.get_player(player_seat).add_discarded_tile(tile) # cache already revealed tiles @@ -150,12 +181,12 @@ def get_players_sorted_by_scores(self): return sorted(self.players, key=lambda x: (x.scores or 0, -x.first_seat), reverse=True) @property - def round_wind(self): - if self.round_number < 4: + def round_wind_tile(self): + if self.round_wind_number < 4: return EAST - elif 4 <= self.round_number < 8: + elif 4 <= self.round_wind_number < 8: return SOUTH - elif 8 <= self.round_number < 12: + elif 8 <= self.round_wind_number < 12: return WEST else: return NORTH diff --git a/project/game/tests/tests_client.py b/project/game/tests/tests_client.py index bc01eee6..faaacb67 100644 --- a/project/game/tests/tests_client.py +++ b/project/game/tests/tests_client.py @@ -25,7 +25,7 @@ def test_discard_tile(self): self.assertFalse(tile in client.table.player.tiles) self.assertEqual(client.table.count_of_remaining_tiles, 69) - def test_call_meld(self): + def test_call_meld_closed_kan(self): client = Client() client.table.init_round(0, 0, 0, 0, 0, [0, 0, 0, 0]) @@ -40,12 +40,39 @@ def test_call_meld(self): client.player.tiles = [0] meld = Meld() meld.type = Meld.KAN + # closed kan + meld.tiles = [0, 1, 2, 3] + meld.called_tile = None + meld.opened = False + client.table.add_called_meld(0, meld) + + self.assertEqual(len(client.player.melds), 2) + # kan was closed, so -1 + self.assertEqual(client.table.count_of_remaining_tiles, 70) + + def test_call_meld_kan_from_player(self): + client = Client() + + client.table.init_round(0, 0, 0, 0, 0, [0, 0, 0, 0]) + self.assertEqual(client.table.count_of_remaining_tiles, 70) + + meld = Meld() + client.table.add_called_meld(0, meld) + + self.assertEqual(len(client.player.melds), 1) + self.assertEqual(client.table.count_of_remaining_tiles, 71) + + client.player.tiles = [0] + meld = Meld() + meld.type = Meld.KAN + # closed kan + meld.tiles = [0, 1, 2, 3] meld.called_tile = 0 + meld.opened = True client.table.add_called_meld(0, meld) self.assertEqual(len(client.player.melds), 2) - # +1 for called meld - # -1 for called kan + # kan was called from another player, total number of remaining tiles stays the same self.assertEqual(client.table.count_of_remaining_tiles, 71) def test_enemy_discard(self): diff --git a/project/game/tests/tests_player.py b/project/game/tests/tests_player.py index 019414a3..56cad7b3 100644 --- a/project/game/tests/tests_player.py +++ b/project/game/tests/tests_player.py @@ -7,7 +7,6 @@ from game.player import Player from game.table import Table -from utils.settings_handler import settings class PlayerTestCase(unittest.TestCase, TestMixin): diff --git a/project/game/tests/tests_table.py b/project/game/tests/tests_table.py index ee611fbd..70106e78 100644 --- a/project/game/tests/tests_table.py +++ b/project/game/tests/tests_table.py @@ -19,16 +19,23 @@ def test_init_hand(self): def test_init_round(self): table = Table() - round_number = 4 + round_wind_number = 4 count_of_honba_sticks = 2 count_of_riichi_sticks = 3 dora_indicator = 126 dealer = 3 scores = [250, 250, 250, 250] - table.init_round(round_number, count_of_honba_sticks, count_of_riichi_sticks, dora_indicator, dealer, scores) + table.init_round( + round_wind_number, + count_of_honba_sticks, + count_of_riichi_sticks, + dora_indicator, + dealer, + scores + ) - self.assertEqual(table.round_number, round_number) + self.assertEqual(table.round_wind_number, round_wind_number) self.assertEqual(table.count_of_honba_sticks, count_of_honba_sticks) self.assertEqual(table.count_of_riichi_sticks, count_of_riichi_sticks) self.assertEqual(table.dora_indicators[0], dora_indicator) @@ -38,7 +45,14 @@ def test_init_round(self): dealer = 2 table.player.in_tempai = True table.player.in_riichi = True - table.init_round(round_number, count_of_honba_sticks, count_of_riichi_sticks, dora_indicator, dealer, scores) + table.init_round( + round_wind_number, + count_of_honba_sticks, + count_of_riichi_sticks, + dora_indicator, + dealer, + scores + ) # test that we reinit round properly self.assertEqual(table.get_player(3).is_dealer, False) @@ -176,16 +190,16 @@ def test_round_wind(self): table = Table() table.init_round(0, 0, 0, 0, 0, []) - self.assertEqual(table.round_wind, EAST) + self.assertEqual(table.round_wind_tile, EAST) table.init_round(3, 0, 0, 0, 0, []) - self.assertEqual(table.round_wind, EAST) + self.assertEqual(table.round_wind_tile, EAST) table.init_round(7, 0, 0, 0, 0, []) - self.assertEqual(table.round_wind, SOUTH) + self.assertEqual(table.round_wind_tile, SOUTH) table.init_round(11, 0, 0, 0, 0, []) - self.assertEqual(table.round_wind, WEST) + self.assertEqual(table.round_wind_tile, WEST) table.init_round(12, 0, 0, 0, 0, []) - self.assertEqual(table.round_wind, NORTH) + self.assertEqual(table.round_wind_tile, NORTH) diff --git a/project/reproducer.py b/project/reproducer.py index 6492469b..e2f8c24f 100644 --- a/project/reproducer.py +++ b/project/reproducer.py @@ -41,8 +41,26 @@ def reproduce(self, dry_run=False): table = Table() for tag in self.round_content: + if player_draw_regex.match(tag) and 'UN' not in tag: + print('Player draw') + tile = self.decoder.parse_tile(tag) + table.player.draw_tile(tile) + if dry_run: - print(tag) + if self._is_draw(tag): + print('<-', TilesConverter.to_one_line_string([self._parse_tile(tag)]), tag) + elif self._is_discard(tag): + print('->', TilesConverter.to_one_line_string([self._parse_tile(tag)]), tag) + elif self._is_init_tag(tag): + hands = { + 0: [int(x) for x in self._get_attribute_content(tag, 'hai0').split(',')], + 1: [int(x) for x in self._get_attribute_content(tag, 'hai1').split(',')], + 2: [int(x) for x in self._get_attribute_content(tag, 'hai2').split(',')], + 3: [int(x) for x in self._get_attribute_content(tag, 'hai3').split(',')], + } + print('Initial hand:', TilesConverter.to_one_line_string(hands[self.player_position])) + else: + print(tag) if not dry_run and tag == self.stop_tag: break @@ -55,7 +73,7 @@ def reproduce(self, dry_run=False): shifted_scores.append(values['scores'][self._normalize_position(x, self.player_position)]) table.init_round( - values['round_number'], + values['round_wind_number'], values['count_of_honba_sticks'], values['count_of_riichi_sticks'], values['dora_indicator'], @@ -72,10 +90,6 @@ def reproduce(self, dry_run=False): table.player.init_hand(hands[self.player_position]) - if player_draw_regex.match(tag) and 'UN' not in tag: - tile = self.decoder.parse_tile(tag) - table.player.draw_tile(tile) - if discard_regex.match(tag) and 'DORA' not in tag: tile = self.decoder.parse_tile(tag) player_sign = tag.upper()[1] @@ -122,7 +136,7 @@ def _normalize_position(self, who, from_who): def _parse_url(self, log_url): temp = log_url.split('?')[1].split('&') - log_id, player, round_number = '', 0, 0 + log_id, player, round_wind = '', 0, 0 for item in temp: item = item.split('=') if 'log' == item[0]: @@ -130,8 +144,8 @@ def _parse_url(self, log_url): if 'tw' == item[0]: player = int(item[1]) if 'ts' == item[0]: - round_number = int(item[1]) - return log_id, player, round_number + round_wind = int(item[1]) + return log_id, player, round_wind def _download_log_content(self, log_id): """ @@ -198,6 +212,35 @@ def _parse_rounds(self, log_content): return rounds[1:] + def _is_discard(self, tag): + skip_tags = [''.format(settings.LOBBY)) - sleep(TenhouClient.SLEEP_BETWEEN_ACTIONS * 2) + self._random_sleep(1, 2) self._send_message('') else: logger.info('Go to the lobby: {}'.format(settings.LOBBY)) self._send_message(''.format(quote('/lobby {}'.format(settings.LOBBY)))) - sleep(TenhouClient.SLEEP_BETWEEN_ACTIONS * 2) + self._random_sleep(1, 2) if self.reconnected_messages: # we already in the game self.looking_for_game = False self._send_message('') - sleep(TenhouClient.SLEEP_BETWEEN_ACTIONS) + self._random_sleep(1, 2) else: selected_game_type = self._build_game_type() game_type = '{},{}'.format(settings.LOBBY, selected_game_type) @@ -144,16 +142,16 @@ def start_game(self): start_time = datetime.datetime.now() while self.looking_for_game: - sleep(TenhouClient.SLEEP_BETWEEN_ACTIONS) + self._random_sleep(1, 2) messages = self._get_multiple_messages() - for message in messages: if ''.format(game_type)) if '') self._send_message('') @@ -207,7 +205,8 @@ def start_game(self): tile_to_discard = None while self.game_is_continue: - sleep(TenhouClient.SLEEP_BETWEEN_ACTIONS) + self._random_sleep(1, 2) + messages = self._get_multiple_messages() if self.reconnected_messages: @@ -223,8 +222,9 @@ def start_game(self): for message in messages: if '') continue # Kyuushuu kyuuhai 「九種九牌」 # (9 kinds of honor or terminal tiles) if 't="64"' in message: + self._random_sleep(1, 2) # TODO aim for kokushi self._send_message('') continue drawn_tile = self.decoder.parse_tile(message) - if not main_player.in_riichi: - logger.info('Hand: {}'.format(main_player.format_hand_for_print(drawn_tile))) + logger.info('Drawn tile: {}'.format(TilesConverter.to_one_line_string([drawn_tile]))) - self.player.draw_tile(drawn_tile) - sleep(TenhouClient.SLEEP_BETWEEN_ACTIONS) - - kan_type = self.player.should_call_kan(drawn_tile, False) - if kan_type and self.table.count_of_remaining_tiles > 1: - if kan_type == Meld.CHANKAN: - meld_type = 5 - else: - meld_type = 4 - self._send_message(''.format(meld_type, drawn_tile)) - logger.info('We called a closed kan\chankan set!') - continue + kan_type = self.player.should_call_kan(drawn_tile, False, main_player.in_riichi) + if kan_type: + self._random_sleep(1, 2) - discarded_tile = self.player.discard_tile() - logger.info('Discard: {}'.format(TilesConverter.to_one_line_string([discarded_tile]))) + if kan_type == Meld.CHANKAN: + meld_type = 5 + logger.info('We upgraded pon to kan!') + else: + meld_type = 4 + logger.info('We called a closed kan set!') + self._send_message(''.format(meld_type, drawn_tile)) + + continue + + if not main_player.in_riichi: + self.player.draw_tile(drawn_tile) + discarded_tile = self.player.discard_tile() can_call_riichi = main_player.can_call_riichi() # let's call riichi if can_call_riichi: + self._random_sleep(1, 2) self._send_message(''.format(discarded_tile)) - sleep(TenhouClient.SLEEP_BETWEEN_ACTIONS) main_player.in_riichi = True else: # we had to add it to discards, to calculate remaining tiles correctly @@ -304,6 +307,7 @@ def start_game(self): # tenhou format: self._send_message(''.format(discarded_tile)) + logger.info('Discard: {}'.format(TilesConverter.to_one_line_string([discarded_tile]))) logger.info('Remaining tiles: {}'.format(self.table.count_of_remaining_tiles)) @@ -320,15 +324,11 @@ def start_game(self): # the end of round if '') # set was called - if ''.format(discarded_tile)) @@ -352,9 +347,16 @@ def start_game(self): ] # we win by other player's discard if any(i in message for i in win_suggestions): - tile = self.decoder.parse_tile(message) - enemy_seat = self.decoder.get_enemy_seat(message) - sleep(TenhouClient.SLEEP_BETWEEN_ACTIONS) + # enemy called shouminkan and we can win there + if self.decoder.is_opened_set_message(message): + meld = self.decoder.parse_meld(message) + tile = meld.called_tile + enemy_seat = meld.who + else: + tile = self.decoder.parse_tile(message) + enemy_seat = self.decoder.get_enemy_seat(message) + + self._random_sleep(1, 2) if main_player.should_call_win(tile, enemy_seat): self._send_message('') @@ -369,8 +371,6 @@ def start_game(self): if_tsumogiri = message[1].islower() player_seat = self.decoder.get_enemy_seat(message) - self.table.add_discarded_tile(player_seat, tile, if_tsumogiri) - # open hand suggestions if 't=' in message: # Possible t="" suggestions @@ -384,6 +384,8 @@ def start_game(self): # should we call a kan? if 't="3"' in message or 't="7"' in message: if self.player.should_call_kan(tile, True): + self._random_sleep(1, 2) + # 2 is open kan self._send_message('') logger.info('We called an open kan set!') @@ -396,6 +398,8 @@ def start_game(self): meld, tile_to_discard = self.player.try_to_call_meld(tile, is_kamicha_discard) if meld: + self._random_sleep(1, 2) + meld_tile = tile # 1 is pon @@ -416,9 +420,10 @@ def start_game(self): )) # this meld will not improve our hand else: - sleep(TenhouClient.SLEEP_BETWEEN_ACTIONS) self._send_message('') + self.table.add_discarded_tile(player_seat, tile, if_tsumogiri) + if 'owari' in message: values = self.decoder.parse_final_scores_and_uma(message) self.table.set_players_scores(values['scores'], values['uma']) @@ -492,7 +497,7 @@ def send_request(): # we can't use sleep(15), because we want to be able # end thread in the middle of running seconds_to_sleep = 15 - for x in range(0, seconds_to_sleep * 2): + for _ in range(0, seconds_to_sleep * 2): if self.game_is_continue: sleep(0.5) @@ -572,3 +577,6 @@ def _set_game_rules(self, game_type): logger.info('Game type: {}'.format(is_hanchan and 'hanchan' or 'tonpusen')) return True + + def _random_sleep(self, min_sleep, max_sleep): + sleep(random.randint(min_sleep, max_sleep + 1)) diff --git a/project/tenhou/decoder.py b/project/tenhou/decoder.py index f33a4f3e..156f3f04 100644 --- a/project/tenhou/decoder.py +++ b/project/tenhou/decoder.py @@ -61,7 +61,7 @@ def parse_initial_values(self, message): seed = self.get_attribute_content(message, 'seed').split(',') seed = [int(i) for i in seed] - round_number = seed[0] + round_wind_number = seed[0] count_of_honba_sticks = seed[1] count_of_riichi_sticks = seed[2] dora_indicator = seed[5] @@ -71,7 +71,7 @@ def parse_initial_values(self, message): scores = [int(i) for i in scores] return { - 'round_number': round_number, + 'round_wind_number': round_wind_number, 'count_of_honba_sticks': count_of_honba_sticks, 'count_of_riichi_sticks': count_of_riichi_sticks, 'dora_indicator': dora_indicator, @@ -167,7 +167,9 @@ def parse_meld(self, message): meld = Meld() meld.who = int(self.get_attribute_content(message, 'who')) - meld.from_who = data & 0x3 + # 'from_who' is encoded relative the the 'who', so we want + # to convert it to be relative to our player + meld.from_who = ((data & 0x3) + meld.who) % 4 if data & 0x4: self.parse_chi(data, meld) @@ -260,6 +262,9 @@ def is_discarded_tile_message(self, message): return False + def is_opened_set_message(self, message): + return ' - Get: - Get: - Get: - Get: - Get: - Get: - Get: - Get: - Get: - Get: - Get: - Get: - Get: - Get: - Get: - Get: - Get: - Get: - Get: - Get: - Get: - Get: - Get: - Get: - Get: - Get: - Get: - Get: - Get: - Get: - Get: - Get: - Get: - Get: - Get: - Get: - Get: - Get: - Get: - Get: - Get: - Get: - Get: - Get: - Get: - Get: - Get: - Get: - Get: - Get: - """ - - self.client = TenhouClient(SocketMock(None, log)) - with self.assertRaises(KeyboardInterrupt) as context: - self.client.connect() - self.client.authenticate() - self.client.start_game() - - # close all threads - self.client.end_game() - - # end of commands is correct way to end log reproducing - self.assertTrue('End of commands' in str(context.exception)) diff --git a/project/tenhou/tests/tests_decoder.py b/project/tenhou/tests/tests_decoder.py index 839ec659..660750dc 100644 --- a/project/tenhou/tests/tests_decoder.py +++ b/project/tenhou/tests/tests_decoder.py @@ -12,7 +12,7 @@ def test_parse_initial_round_values(self): 'hai="30,67,44,21,133,123,87,69,36,34,94,4,128"/>' values = decoder.parse_initial_values(message) - self.assertEqual(values['round_number'], 0) + self.assertEqual(values['round_wind_number'], 0) self.assertEqual(values['count_of_honba_sticks'], 2) self.assertEqual(values['count_of_riichi_sticks'], 3) self.assertEqual(values['dora_indicator'], 126) @@ -124,7 +124,7 @@ def test_parse_called_opened_kan(self): meld = decoder.parse_meld('') self.assertEqual(meld.who, 3) - self.assertEqual(meld.from_who, 1) + self.assertEqual(meld.from_who, 0) self.assertEqual(meld.type, Meld.KAN) self.assertEqual(meld.opened, True) self.assertEqual(meld.tiles, [52, 53, 54, 55]) @@ -174,7 +174,7 @@ def test_parse_who_called_riichi(self): def test_reconnection_information(self): message = '