From 7118551f9e6703f3afd92ab955979cb52d7702ef Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Mon, 31 Oct 2016 15:55:19 +0800 Subject: [PATCH 01/80] Somewhere in the middle with opening hand logic --- project/game/game_manager.py | 12 +- project/mahjong/ai/main.py | 169 +++++++++++++++++++++++--- project/mahjong/ai/tests/tests_ai.py | 94 ++++++++++---- project/mahjong/client.py | 14 ++- project/mahjong/hand.py | 7 +- project/mahjong/player.py | 43 +++++-- project/mahjong/table.py | 2 +- project/mahjong/tests/tests_client.py | 6 +- project/mahjong/tests/tests_player.py | 17 ++- project/mahjong/utils.py | 8 ++ project/tenhou/client.py | 4 +- project/utils/tests.py | 8 ++ 12 files changed, 320 insertions(+), 64 deletions(-) diff --git a/project/game/game_manager.py b/project/game/game_manager.py index 81584d06..d867ce26 100644 --- a/project/game/game_manager.py +++ b/project/game/game_manager.py @@ -25,7 +25,7 @@ def shuffle_seed(): class GameManager(object): """ Allow to play bots between each other - To have a metrics how new version plays agains old versions + To have a metrics how new version plays against old versions """ tiles = [] @@ -164,7 +164,7 @@ def play_round(self): continue # let's store other players discards - other_client.enemy_discard(other_client.position - client.position, tile) + other_client.enemy_discard(tile, other_client.position - client.position) # TODO support multiple ron if self.can_call_ron(other_client, tile): @@ -180,6 +180,14 @@ def play_round(self): if in_tempai and client.player.can_call_riichi(): self.call_riichi(client) + # let's check other players hand to possibility open sets + # for other_client in self.clients: + # there is no need to check the current client + # if other_client == client: + # continue + + # meld, discard_tile = other_client.player.try_to_call_meld(tile, other_client.position - client.position) + self.current_client = self._move_position(self.current_client) # retake diff --git a/project/mahjong/ai/main.py b/project/mahjong/ai/main.py index 71d1ce67..e1ef7b5e 100644 --- a/project/mahjong/ai/main.py +++ b/project/mahjong/ai/main.py @@ -3,7 +3,11 @@ from mahjong.ai.base import BaseAI from mahjong.ai.defence import Defence from mahjong.ai.shanten import Shanten +from mahjong.constants import HAKU, CHUN, HATSU +from mahjong.hand import HandDivider +from mahjong.meld import Meld from mahjong.tile import TilesConverter +from mahjong.utils import is_sou, is_pin, is_honor, is_chi, is_pon class MainAI(BaseAI): @@ -12,6 +16,10 @@ class MainAI(BaseAI): agari = None shanten = None defence = None + hand_divider = None + previous_shanten = 7 + + yakuhai_strategy = False def __init__(self, table, player): super(MainAI, self).__init__(table, player) @@ -19,9 +27,13 @@ def __init__(self, table, player): self.agari = Agari() self.shanten = Shanten() self.defence = Defence(table) + self.hand_divider = HandDivider() + self.previous_shanten = 7 + self.yakuhai_strategy = False def discard_tile(self): - results, shanten = self.calculate_outs() + results, shanten = self.calculate_outs(self.player.tiles) + self.previous_shanten = shanten if shanten == 0: self.player.in_tempai = True @@ -46,39 +58,43 @@ def discard_tile(self): return tile_in_hand - def calculate_outs(self): - tiles = TilesConverter.to_34_array(self.player.tiles) + def calculate_outs(self, tiles): + """ + :param tiles: array of tiles in 136 format + :return: + """ + tiles_34 = TilesConverter.to_34_array(tiles) + shanten = self.shanten.calculate_shanten(tiles_34) - shanten = self.shanten.calculate_shanten(tiles) # win if shanten == Shanten.AGARI_STATE: return [], shanten raw_data = {} for i in range(0, 34): - if not tiles[i]: + if not tiles_34[i]: continue - tiles[i] -= 1 + tiles_34[i] -= 1 raw_data[i] = [] for j in range(0, 34): - if i == j or tiles[j] >= 4: + if i == j or tiles_34[j] >= 4: continue - tiles[j] += 1 - if self.shanten.calculate_shanten(tiles) == shanten - 1: + tiles_34[j] += 1 + if self.shanten.calculate_shanten(tiles_34) == shanten - 1: raw_data[i].append(j) - tiles[j] -= 1 + tiles_34[j] -= 1 - tiles[i] += 1 + tiles_34[i] += 1 if raw_data[i]: - raw_data[i] = {'tile': i, 'tiles_count': self.count_tiles(raw_data[i], tiles), 'waiting': raw_data[i]} + raw_data[i] = {'tile': i, 'tiles_count': self.count_tiles(raw_data[i], tiles_34), 'waiting': raw_data[i]} results = [] - tiles = TilesConverter.to_34_array(self.player.tiles) - for tile in range(0, len(tiles)): + tiles_34 = TilesConverter.to_34_array(self.player.tiles) + for tile in range(0, len(tiles_34)): if tile in raw_data and raw_data[tile] and raw_data[tile]['tiles_count']: item = raw_data[tile] @@ -104,3 +120,128 @@ def count_tiles(self, raw_data, tiles): for i in range(0, len(raw_data)): n += 4 - tiles[raw_data[i]] return n + + def try_to_call_meld(self, tile, enemy_seat): + """ + Determine should we call a meld or not. + If yes, it will add tile to the player's hand and will return Meld object + :param tile: 136 format tile + :param enemy_seat: 1, 2, 3 + :return: meld and tile to discard after called open set + """ + player_tiles = self.player.closed_hand[:] + + valued_tiles = [CHUN, HAKU, HATSU, self.player.table.round_wind, self.player.player_wind] + + tiles_34 = TilesConverter.to_34_array(player_tiles) + discarded_tile = tile // 4 + is_kamicha_discard = enemy_seat == 3 + + new_tiles = self.player.tiles[:] + [tile] + outs_results, shanten = self.calculate_outs(new_tiles) + + # let's go for yakuhai + if discarded_tile in valued_tiles and tiles_34[discarded_tile] == 2: + first_tile = TilesConverter.find_34_tile_in_136_array(discarded_tile, player_tiles) + # we need to remove tile from array, to not find it again for second tile + player_tiles.remove(first_tile) + second_tile = TilesConverter.find_34_tile_in_136_array(discarded_tile, player_tiles) + tiles = [ + first_tile, + second_tile, + tile + ] + + meld = Meld() + meld.who = self.player.seat + meld.from_who = enemy_seat + meld.type = Meld.PON + meld.tiles = tiles + + tile_34 = outs_results[0]['discard'] + tile_to_discard = TilesConverter.find_34_tile_in_136_array(tile_34, self.player.tiles) + + self.player.tiles.append(tile) + + self.yakuhai_strategy = True + + return meld, tile_to_discard + + if self.player.is_open_hand: + # once hand was opened for yakuhai, we can open not our winds + if self.yakuhai_strategy and is_honor(discarded_tile) and tiles_34[discarded_tile] == 2: + first_tile = TilesConverter.find_34_tile_in_136_array(discarded_tile, player_tiles) + # we need to remove tile from array, to not find it again for second tile + player_tiles.remove(first_tile) + second_tile = TilesConverter.find_34_tile_in_136_array(discarded_tile, player_tiles) + tiles = [ + first_tile, + second_tile, + tile + ] + + meld = Meld() + meld.who = self.player.seat + meld.from_who = enemy_seat + meld.type = Meld.PON + meld.tiles = tiles + + tile_34 = outs_results[0]['discard'] + tile_to_discard = TilesConverter.find_34_tile_in_136_array(tile_34, self.player.tiles) + + self.player.tiles.append(tile) + + return meld, tile_to_discard + + # tile will decrease the count of shanten in hand + # so let's call opened set with it + if shanten < self.previous_shanten: + new_tiles_34 = TilesConverter.to_34_array(new_tiles) + + if is_sou(discarded_tile): + combinations = self.hand_divider.find_valid_combinations(new_tiles_34, 0, 8, True) + elif is_pin(discarded_tile): + combinations = self.hand_divider.find_valid_combinations(new_tiles_34, 9, 17, True) + else: + combinations = self.hand_divider.find_valid_combinations(new_tiles_34, 18, 26, True) + + possible_melds = [] + for combination in combinations: + # we can call pon from everyone + if is_pon(combination): + possible_melds.append(combination) + + # we can call chi only from left player + if is_chi(combination) and is_kamicha_discard: + possible_melds.append(combination) + + if len(possible_melds): + # TODO add logic to find best meld + combination = possible_melds[0] + meld_type = is_chi(combination) and Meld.CHI or Meld.PON + + combination.remove(discarded_tile) + first_tile = TilesConverter.find_34_tile_in_136_array(combination[0], player_tiles) + # we need to remove tile from array, to not find it again for second tile + player_tiles.remove(first_tile) + second_tile = TilesConverter.find_34_tile_in_136_array(combination[1], player_tiles) + tiles = [ + first_tile, + second_tile, + tile + ] + + meld = Meld() + meld.who = self.player.seat + meld.from_who = enemy_seat + meld.type = meld_type + meld.tiles = sorted(tiles) + + tile_34 = outs_results[0]['discard'] + tile_to_discard = TilesConverter.find_34_tile_in_136_array(tile_34, self.player.tiles) + + self.player.tiles.append(tile) + + return meld, tile_to_discard + + return None, None diff --git a/project/mahjong/ai/tests/tests_ai.py b/project/mahjong/ai/tests/tests_ai.py index f9d35a1f..4d8537c4 100644 --- a/project/mahjong/ai/tests/tests_ai.py +++ b/project/mahjong/ai/tests/tests_ai.py @@ -3,6 +3,7 @@ from mahjong.ai.main import MainAI from mahjong.ai.shanten import Shanten +from mahjong.meld import Meld from mahjong.player import Player from mahjong.table import Table from utils.tests import TestMixin @@ -15,48 +16,32 @@ def test_outs(self): player = Player(0, 0, table) ai = MainAI(table, player) - tiles = self._string_to_136_array(sou='111345677', pin='15', man='56') - tile = self._string_to_136_array(man='9')[0] - player.init_hand(tiles) - player.draw_tile(tile) - - outs, shanten = ai.calculate_outs() + tiles = self._string_to_136_array(sou='111345677', pin='15', man='569') + outs, shanten = ai.calculate_outs(tiles) self.assertEqual(shanten, 2) self.assertEqual(outs[0]['discard'], 9) self.assertEqual(outs[0]['waiting'], [3, 6, 7, 8, 11, 12, 13, 14, 15, 18, 19, 20, 21, 22, 23, 24, 25]) self.assertEqual(outs[0]['tiles_count'], 57) - tiles = self._string_to_136_array(sou='111345677', pin='45', man='56') - tile = self._string_to_136_array(man='9')[0] - player.init_hand(tiles) - player.draw_tile(tile) - - outs, shanten = ai.calculate_outs() + tiles = self._string_to_136_array(sou='111345677', pin='45', man='569') + outs, shanten = ai.calculate_outs(tiles) self.assertEqual(shanten, 1) self.assertEqual(outs[0]['discard'], 23) self.assertEqual(outs[0]['waiting'], [3, 6, 11, 14]) self.assertEqual(outs[0]['tiles_count'], 16) - 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) - - outs, shanten = ai.calculate_outs() + tiles = self._string_to_136_array(sou='11145677', pin='345', man='569') + outs, shanten = ai.calculate_outs(tiles) self.assertEqual(shanten, 0) self.assertEqual(outs[0]['discard'], 8) self.assertEqual(outs[0]['waiting'], [3, 6]) self.assertEqual(outs[0]['tiles_count'], 8) - tiles = self._string_to_136_array(sou='11145677', pin='345', man='56') - tile = self._string_to_136_array(man='4')[0] - player.init_hand(tiles) - player.draw_tile(tile) - - outs, shanten = ai.calculate_outs() + tiles = self._string_to_136_array(sou='11145677', pin='345', man='456') + outs, shanten = ai.calculate_outs(tiles) self.assertEqual(shanten, Shanten.AGARI_STATE) self.assertEqual(len(outs), 0) @@ -124,3 +109,64 @@ def test_set_is_tempai_flag_to_the_player(self): player.discard_tile() self.assertEqual(player.in_tempai, True) + + def test_open_hand_with_yakuhai_pair_in_hand(self): + table = Table() + player = Player(0, 0, table) + + tiles = self._string_to_136_array(sou='123678', pin='258', honors='4455') + tile = self._string_to_136_array(honors='4')[0] + player.init_hand(tiles) + + # we don't need to open hand with not our wind + meld, _ = player.try_to_call_meld(tile, 3) + self.assertEqual(meld, None) + + # with dragon let's open our hand + tile = self._string_to_136_array(honors='5')[0] + meld, _ = player.try_to_call_meld(tile, 3) + + self.assertNotEqual(meld, None) + player.add_called_meld(meld) + self.assertEqual(meld.type, Meld.PON) + self.assertEqual(meld.tiles, [124, 124, 124]) + self.assertEqual(len(player.closed_hand), 11) + self.assertEqual(len(player.tiles), 14) + player.discard_tile() + + # once hand was opened, we can open set of not our winds + tile = self._string_to_136_array(honors='4')[0] + meld, _ = player.try_to_call_meld(tile, 3) + self.assertNotEqual(meld, None) + player.add_called_meld(meld) + self.assertEqual(meld.type, Meld.PON) + self.assertEqual(meld.tiles, [120, 120, 120]) + self.assertEqual(len(player.closed_hand), 9) + self.assertEqual(len(player.tiles), 14) + + def test_continue_to_call_melds_with_already_opened_hand(self): + table = Table() + player = Player(0, 0, table) + + tiles = self._string_to_136_array(sou='123678', pin='25899', honors='55') + tile = self._string_to_136_array(pin='7')[0] + player.init_hand(tiles) + + meld, _ = player.try_to_call_meld(tile, 3) + self.assertEqual(meld, None) + + tiles = self._string_to_136_array(sou='123678', pin='2589') + player.init_hand(tiles) + meld_tiles = [self._string_to_136_tile(honors='5'), self._string_to_136_tile(honors='5'), + self._string_to_136_tile(honors='5')] + player.add_called_meld(self._make_meld(Meld.PON, meld_tiles)) + + # we have already opened yakuhai pon, + # so we can continue to open hand + # if it will improve our shanten + tile = self._string_to_136_array(pin='7')[0] + meld, tile_to_discard = player.try_to_call_meld(tile, 3) + self.assertNotEqual(meld, None) + self.assertEqual(meld.type, Meld.CHI) + self.assertEqual(meld.tiles, [60, 64, 68]) + self.assertEqual(tile_to_discard, 52) diff --git a/project/mahjong/client.py b/project/mahjong/client.py index d4b1af61..45f01160 100644 --- a/project/mahjong/client.py +++ b/project/mahjong/client.py @@ -34,15 +34,19 @@ def draw_tile(self, tile): def discard_tile(self): return self.player.discard_tile() - def call_meld(self, meld): + def add_called_meld(self, meld): # when opponent called meld it is means - # that he will not get the tile from the wall - # so, we need to compensate "-" from enemy discard method + # that he discards tile from hand, not from wall self.table.count_of_remaining_tiles += 1 - return self.table.get_player(meld.who).add_meld(meld) + return self.table.get_player(meld.who).add_called_meld(meld) - def enemy_discard(self, player_seat, tile): + def enemy_discard(self, tile, player_seat): + """ + :param player_seat: + :param tile: 136 format tile + :return: + """ self.table.get_player(player_seat).add_discarded_tile(tile) self.table.count_of_remaining_tiles -= 1 diff --git a/project/mahjong/hand.py b/project/mahjong/hand.py index a84c4d18..9bf440e1 100644 --- a/project/mahjong/hand.py +++ b/project/mahjong/hand.py @@ -1330,12 +1330,13 @@ def find_pairs(self, tiles_34): return pair_indices - def find_valid_combinations(self, tiles_34, first_index, second_index): + def find_valid_combinations(self, tiles_34, first_index, second_index, hand_not_completed=False): """ Find and return all valid set combinations in given suit :param tiles_34: :param first_index: :param second_index: + :param hand_not_completed: in that mode we can return just possible shi\pon sets :return: list of valid combinations """ indices = [] @@ -1398,6 +1399,10 @@ def is_valid_combination(possible_set): if count_of_sets > count_of_possible_sets: valid_combinations.remove(item) + # lit of chi\pon sets for not completed hand + if hand_not_completed: + return valid_combinations + # hard case - we can build a lot of sets from our tiles # for example we have 123456 tiles and we can build sets: # [1, 2, 3] [4, 5, 6] [2, 3, 4] [3, 4, 5] diff --git a/project/mahjong/player.py b/project/mahjong/player.py index d5fd2b8e..533bfebc 100644 --- a/project/mahjong/player.py +++ b/project/mahjong/player.py @@ -27,6 +27,7 @@ class Player(object): # tiles that were discarded after player's riichi safe_tiles = [] tiles = [] + closed_hand = [] melds = [] table = None in_tempai = False @@ -38,6 +39,7 @@ def __init__(self, seat, dealer_seat, table, use_previous_ai_version=False): self.melds = [] self.tiles = [] self.safe_tiles = [] + self.closed_hand = [] self.seat = seat self.table = table self.dealer_seat = dealer_seat @@ -72,14 +74,29 @@ def __str__(self): def __repr__(self): return self.__str__() - def add_meld(self, meld): + def erase_state(self): + self.discards = [] + self.melds = [] + self.tiles = [] + self.safe_tiles = [] + self.in_tempai = False + self.in_riichi = False + self.in_defence_mode = False + self.dealer_seat = 0 + + def add_called_meld(self, meld): self.melds.append(meld) + for tile in meld.tiles: + if tile in self.closed_hand: + self.closed_hand.remove(tile) + def add_discarded_tile(self, tile): self.discards.append(Tile(tile)) def init_hand(self, tiles): self.tiles = [Tile(i) for i in tiles] + self.closed_hand = self.tiles[:] def draw_tile(self, tile): self.tiles.append(Tile(tile)) @@ -93,16 +110,6 @@ def discard_tile(self): self.tiles.remove(tile_to_discard) return tile_to_discard - def erase_state(self): - self.discards = [] - self.melds = [] - self.tiles = [] - self.safe_tiles = [] - self.in_tempai = False - self.in_riichi = False - self.in_defence_mode = False - self.dealer_seat = 0 - def can_call_riichi(self): return all([ self.in_tempai, @@ -111,6 +118,16 @@ def can_call_riichi(self): self.table.count_of_remaining_tiles > 4 ]) + def try_to_call_meld(self, tile, enemy_seat): + """ + Determine should we call a meld or not. + If yes, it will add tile to the player's hand and will return Meld object + :param tile: 136 format tile + :param enemy_seat: 1, 2, 3 + :return: meld and tile to discard after called open set + """ + return self.ai.try_to_call_meld(tile, enemy_seat) + @property def player_wind(self): position = self.dealer_seat @@ -126,3 +143,7 @@ def player_wind(self): @property def is_dealer(self): return self.seat == self.dealer_seat + + @property + def is_open_hand(self): + return len(self.melds) > 0 diff --git a/project/mahjong/table.py b/project/mahjong/table.py index 37dd2b25..33fdc2c0 100644 --- a/project/mahjong/table.py +++ b/project/mahjong/table.py @@ -52,7 +52,7 @@ def init_main_player_hand(self, tiles): self.get_main_player().init_hand(tiles) def add_open_set(self, meld): - self.get_player(meld.who).add_meld(meld) + self.get_player(meld.who).add_called_meld(meld) def add_dora_indicator(self, tile): self.dora_indicators.append(tile) diff --git a/project/mahjong/tests/tests_client.py b/project/mahjong/tests/tests_client.py index f5be9057..e033e102 100644 --- a/project/mahjong/tests/tests_client.py +++ b/project/mahjong/tests/tests_client.py @@ -41,7 +41,7 @@ def test_call_meld(self): meld = Meld() meld.who = 3 - client.call_meld(meld) + client.add_called_meld(meld) self.assertEqual(len(client.table.get_player(3).melds), 1) self.assertEqual(client.table.count_of_remaining_tiles, 71) @@ -52,7 +52,7 @@ def test_enemy_discard(self): self.assertEqual(client.table.count_of_remaining_tiles, 70) - client.enemy_discard(1, 10) + client.enemy_discard(10, 1) self.assertEqual(len(client.table.get_player(1).discards), 1) self.assertEqual(client.table.count_of_remaining_tiles, 69) @@ -62,7 +62,7 @@ def test_enemy_discard_and_safe_tiles(self): client.table.init_round(0, 0, 0, 0, 0, [0, 0, 0, 0]) client.table.players[0].in_riichi = True - client.enemy_discard(1, 10) + client.enemy_discard(10, 1) self.assertEqual(len(client.table.players[0].safe_tiles), 1) diff --git a/project/mahjong/tests/tests_player.py b/project/mahjong/tests/tests_player.py index 6baa9494..6fc69f9b 100644 --- a/project/mahjong/tests/tests_player.py +++ b/project/mahjong/tests/tests_player.py @@ -2,11 +2,13 @@ import unittest from mahjong.constants import EAST, SOUTH, WEST, NORTH +from mahjong.meld import Meld from mahjong.player import Player from mahjong.table import Table +from utils.tests import TestMixin -class PlayerTestCase(unittest.TestCase): +class PlayerTestCase(unittest.TestCase, TestMixin): def test_can_call_riichi_and_tempai(self): table = Table() @@ -82,3 +84,16 @@ def test_player_wind(self): player = Player(0, 3, table) self.assertEqual(player.player_wind, SOUTH) + + def test_player_called_meld_and_closed_hand(self): + table = Table() + player = Player(0, 0, table) + + tiles = self._string_to_136_array(sou='123678', pin='3599', honors='555') + player.init_hand(tiles) + meld_tiles = [self._string_to_136_tile(honors='5'), self._string_to_136_tile(honors='5'), + self._string_to_136_tile(honors='5')] + + self.assertEqual(len(player.closed_hand), 13) + player.add_called_meld(self._make_meld(Meld.PON, meld_tiles)) + self.assertEqual(len(player.closed_hand), 10) diff --git a/project/mahjong/utils.py b/project/mahjong/utils.py index f7b2b80b..253ab8b7 100644 --- a/project/mahjong/utils.py +++ b/project/mahjong/utils.py @@ -116,6 +116,14 @@ def is_man(tile): return 17 < tile <= 26 +def is_honor(tile): + """ + :param tile: 34 tile format + :return: boolean + """ + return tile >= 27 + + def simplify(tile): """ :param tile: 34 tile format diff --git a/project/tenhou/client.py b/project/tenhou/client.py index fc8ee173..3023e757 100644 --- a/project/tenhou/client.py +++ b/project/tenhou/client.py @@ -211,7 +211,7 @@ def start_game(self): # set call if ' Date: Thu, 3 Nov 2016 12:14:45 +0800 Subject: [PATCH 02/80] Work in progress --- project/bots_battle.py | 24 ++-- project/game/game_manager.py | 194 +++++++++++++++++--------- project/game/logger.py | 15 +- project/game/tests.py | 133 +++++++++++++----- project/mahjong/ai/main.py | 123 ++++++++-------- project/mahjong/ai/tests/tests_ai.py | 70 +++++----- project/mahjong/client.py | 6 +- project/mahjong/hand.py | 10 +- project/mahjong/meld.py | 3 +- project/mahjong/player.py | 33 +++-- project/mahjong/tests/tests_player.py | 22 ++- project/mahjong/tile.py | 11 +- project/mahjong/utils.py | 4 +- 13 files changed, 416 insertions(+), 232 deletions(-) diff --git a/project/bots_battle.py b/project/bots_battle.py index effcf332..1944b1bd 100644 --- a/project/bots_battle.py +++ b/project/bots_battle.py @@ -10,13 +10,13 @@ from game.game_manager import GameManager from mahjong.client import Client -TOTAL_HANCHANS = 100 +TOTAL_HANCHANS = 20 def main(): # enable it for manual testing logger = logging.getLogger('game') - logger.disabled = True + logger.disabled = False # let's load three bots with old logic # and one copy with new logic @@ -28,12 +28,13 @@ def main(): for client in clients: total_results[client.id] = { 'name': client.player.name, - 'version': client.player.ai.version, + 'version': 'v{}'.format(client.player.ai.version), 'positions': [], 'played_rounds': 0, 'lose_rounds': 0, 'win_rounds': 0, 'riichi_rounds': 0, + 'called_rounds': 0, } for x in trange(TOTAL_HANCHANS): @@ -51,11 +52,12 @@ def main(): clients = sorted(clients, key=lambda i: i.player.scores, reverse=True) for client in clients: player = client.player - table_data.append([player.position, - player.name, - 'v{0}'.format(player.ai.version), - '{0:,d}'.format(int(player.scores)) - ]) + table_data.append([ + player.position, + player.name, + 'v{0}'.format(player.ai.version), + '{0:,d}'.format(int(player.scores)) + ]) total_result_client = total_results[client.id] total_result_client['positions'].append(player.position) @@ -68,7 +70,7 @@ def main(): print('\n') table_data = [ - ['Player', 'AI', 'Played rounds', 'Average place', 'Win rate', 'Feed rate', 'Riichi rate'], + ['Player', 'AI', 'Average place', 'Win rate', 'Feed rate', 'Riichi rate', 'Call rate'], ] # recalculate stat values @@ -77,11 +79,13 @@ def main(): lose_rounds = item['lose_rounds'] win_rounds = item['win_rounds'] riichi_rounds = item['riichi_rounds'] + called_rounds = item['called_rounds'] item['average_place'] = sum(item['positions']) / len(item['positions']) item['feed_rate'] = (lose_rounds / played_rounds) * 100 item['win_rate'] = (win_rounds / played_rounds) * 100 item['riichi_rate'] = (riichi_rounds / played_rounds) * 100 + item['call_rate'] = (called_rounds / played_rounds) * 100 calculated_clients = sorted(total_results.values(), key=lambda i: i['average_place']) @@ -89,11 +93,11 @@ def main(): table_data.append([ item['name'], item['version'], - '{0:,d}'.format(item['played_rounds']), format(item['average_place'], '.2f'), format(item['win_rate'], '.2f') + '%', format(item['feed_rate'], '.2f') + '%', format(item['riichi_rate'], '.2f') + '%', + format(item['call_rate'], '.2f') + '%', ]) print('Final results:') diff --git a/project/game/game_manager.py b/project/game/game_manager.py index d867ce26..ad804312 100644 --- a/project/game/game_manager.py +++ b/project/game/game_manager.py @@ -8,6 +8,7 @@ from mahjong.ai.agari import Agari from mahjong.client import Client from mahjong.hand import FinishedHand +from mahjong.meld import Meld from mahjong.tile import TilesConverter # we need to have it @@ -32,9 +33,10 @@ class GameManager(object): dead_wall = [] clients = [] dora_indicators = [] + players_with_open_hands = [] dealer = None - current_client = None + current_client_seat = None round_number = 0 honba_sticks = 0 riichi_sticks = 0 @@ -58,7 +60,7 @@ def init_game(self): """ shuffle(self.clients, shuffle_seed) for i in range(0, len(self.clients)): - self.clients[i].position = i + self.clients[i].seat = i dealer = randint(0, 3) self.set_dealer(dealer) @@ -73,6 +75,8 @@ def init_round(self): global seed_value seed_value = random() + self.players_with_open_hands = [] + self.tiles = [i for i in range(0, 136)] # need to change random function in future @@ -128,67 +132,83 @@ def play_round(self): continue_to_play = True while continue_to_play: - client = self._get_current_client() - in_tempai = client.player.in_tempai + current_client = self._get_current_client() + in_tempai = current_client.player.in_tempai tile = self._cut_tiles(1)[0] # we don't need to add tile to the hand when we are in riichi - if client.player.in_riichi: - tiles = client.player.tiles + [tile] + if current_client.player.in_riichi: + tiles = current_client.player.tiles + [tile] else: - client.draw_tile(tile) - tiles = client.player.tiles + current_client.draw_tile(tile) + tiles = current_client.player.tiles is_win = self.agari.is_agari(TilesConverter.to_34_array(tiles)) # win by tsumo after tile draw if is_win: - result = self.process_the_end_of_the_round(tiles=client.player.tiles, + tiles.remove(tile) + result = self.process_the_end_of_the_round(tiles=tiles, win_tile=tile, - winner=client, + winner=current_client, loser=None, is_tsumo=True) return result # if not in riichi, let's decide what tile to discard - if not client.player.in_riichi: - tile = client.discard_tile() - in_tempai = client.player.in_tempai + if not current_client.player.in_riichi: + tile = current_client.discard_tile() + in_tempai = current_client.player.in_tempai + + result = self.check_clients_possible_ron(current_client, tile) + # the end of the round + if result: + return result - # after tile discard let's check all other players can they win or not - # at this tile + # if there is no challenger to ron, let's check can we call riichi with tile discard or not + if in_tempai and current_client.player.can_call_riichi(): + self.call_riichi(current_client) + + # let's check other players hand to possibility open sets + possible_melds = [] for other_client in self.clients: # there is no need to check the current client - if other_client == client: + if other_client == current_client: continue - # let's store other players discards - other_client.enemy_discard(tile, other_client.position - client.position) + meld, discarded_tile = other_client.player.try_to_call_meld(tile, + other_client.seat - current_client.seat) - # TODO support multiple ron - if self.can_call_ron(other_client, tile): - # the end of the round - result = self.process_the_end_of_the_round(tiles=other_client.player.tiles, - win_tile=tile, - winner=other_client, - loser=client, - is_tsumo=False) - return result + if meld: + possible_melds.append({ + 'meld': meld, + 'discarded_tile': discarded_tile, + 'who': other_client.seat + }) - # if there is no challenger to ron, let's check can we call riichi with tile discard or not - if in_tempai and client.player.can_call_riichi(): - self.call_riichi(client) + if possible_melds: + # pon is more important than chi + possible_melds = sorted(possible_melds, key=lambda x: x['meld'].type == Meld.PON) + tile_to_discard = possible_melds[0]['discarded_tile'] + # if tile_to_discard: + meld = possible_melds[0]['meld'] - # let's check other players hand to possibility open sets - # for other_client in self.clients: - # there is no need to check the current client - # if other_client == client: - # continue + # we changed current client with called open set + self.current_client_seat = possible_melds[0]['who'] + current_client = self._get_current_client() + self.players_with_open_hands.append(self.current_client_seat) + + current_client.add_called_meld(meld) + current_client.player.tiles.append(tile) - # meld, discard_tile = other_client.player.try_to_call_meld(tile, other_client.position - client.position) + current_client.discard_tile(tile_to_discard) - self.current_client = self._move_position(self.current_client) + self.check_clients_possible_ron(current_client, tile_to_discard) + + logger.info('Called meld: {} by {}'.format(meld, current_client.player.name)) + else: + self.current_client_seat = self._move_position(self.current_client_seat) # retake if not len(self.tiles): @@ -197,6 +217,34 @@ def play_round(self): result = self.process_the_end_of_the_round([], 0, None, None, False) return result + def check_clients_possible_ron(self, current_client, tile): + """ + After tile discard let's check all other players can they win or not + at this tile + :param current_client: + :param tile: + :return: None or ron result + """ + for other_client in self.clients: + # there is no need to check the current client + if other_client == current_client: + continue + + # let's store other players discards + other_client.enemy_discard(tile, other_client.seat - current_client.seat) + + # TODO support multiple ron + if self.can_call_ron(other_client, tile): + # the end of the round + result = self.process_the_end_of_the_round(tiles=other_client.player.tiles, + win_tile=tile, + winner=other_client, + loser=current_client, + is_tsumo=False) + return result + + return None + def play_game(self, total_results): """ :param total_results: a dictionary with keys as client ids @@ -226,6 +274,10 @@ def play_game(self, total_results): if client.player.in_riichi: total_results[client.id]['riichi_rounds'] += 1 + called_melds = [x for x in self.players_with_open_hands if x == client.seat] + if called_melds: + total_results[client.id]['called_rounds'] += 1 + played_rounds += 1 self.recalculate_players_position() @@ -249,8 +301,9 @@ def recalculate_players_position(self): client.player.position = i + 1 def can_call_ron(self, client, win_tile): - if not client.player.in_tempai or not client.player.in_riichi: + if not client.player.in_tempai: return False + tiles = client.player.tiles is_ron = self.agari.is_agari(TilesConverter.to_34_array(tiles + [win_tile])) return is_ron @@ -260,11 +313,11 @@ def call_riichi(self, client): client.player.scores -= 1000 self.riichi_sticks += 1 - who_called_riichi = client.position + who_called_riichi = client.seat for client in self.clients: - client.enemy_riichi(who_called_riichi - client.position) + client.enemy_riichi(who_called_riichi - client.seat) - logger.info('Riichi: {0} - 1,000'.format(client.player.name)) + logger.info('Riichi: {0} -1,000'.format(self.clients[who_called_riichi].player.name)) def set_dealer(self, dealer): self.dealer = dealer @@ -279,12 +332,16 @@ def set_dealer(self, dealer): client.player.dealer_seat = dealer - x # first move should be dealer's move - self.current_client = dealer + self.current_client_seat = dealer def process_the_end_of_the_round(self, tiles, win_tile, winner, loser, is_tsumo): """ Increment a round number and do a scores calculations """ + count_of_tiles = len(tiles) + # retake or win + if count_of_tiles and count_of_tiles != 13: + raise ValueError('Wrong tiles count: {}'.format(len(tiles))) if winner: logger.info('{0}: {1} + {2}'.format( @@ -299,24 +356,23 @@ def process_the_end_of_the_round(self, tiles, win_tile, winner, loser, is_tsumo) self.round_number += 1 if winner: - hand_value = self.finished_hand.estimate_hand_value(tiles + [win_tile], - win_tile, - is_tsumo, - winner.player.in_riichi, - winner.player.is_dealer, - False) - if hand_value['cost']: - hand_value = hand_value['cost']['main'] - else: - logger.error('Can\'t estimate a hand: {0}. Error: {1}'.format( + hand_value = self.finished_hand.estimate_hand_value(tiles=tiles + [win_tile], + win_tile=win_tile, + is_tsumo=is_tsumo, + is_riichi=winner.player.in_riichi, + is_dealer=winner.player.is_dealer, + ) + + if hand_value['error']: + logger.error("Can't estimate a hand: {}. Error: {}".format( TilesConverter.to_one_line_string(tiles + [win_tile]), hand_value['error'] )) - hand_value = 1000 + raise ValueError('Not correct hand') - scores_to_pay = hand_value + self.honba_sticks * 300 riichi_bonus = self.riichi_sticks * 1000 self.riichi_sticks = 0 + honba_bonus = self.honba_sticks * 300 # if dealer won we need to increment honba sticks if winner.player.is_dealer: @@ -328,27 +384,32 @@ def process_the_end_of_the_round(self, tiles, win_tile, winner, loser, is_tsumo) # win by ron if loser: + scores_to_pay = hand_value['cost']['main'] + honba_bonus win_amount = scores_to_pay + riichi_bonus winner.player.scores += win_amount loser.player.scores -= scores_to_pay - logger.info('Win: {0} + {1:,d}'.format(winner.player.name, win_amount)) - logger.info('Lose: {0} - {1:,d}'.format(loser.player.name, scores_to_pay)) + logger.info('Win: {0} +{1:,d} +{2:,d}'.format(winner.player.name, scores_to_pay, riichi_bonus)) + logger.info('Lose: {0} -{1:,d}'.format(loser.player.name, scores_to_pay)) # win by tsumo else: - scores_to_pay /= 3 - # will be changed when real hand calculation will be implemented - # round to nearest 100. 333 -> 300 - scores_to_pay = 100 * round(float(scores_to_pay) / 100) - - win_amount = scores_to_pay * 3 + riichi_bonus + calculated_cost = hand_value['cost']['main'] + hand_value['cost']['additional'] * 2 + win_amount = calculated_cost + riichi_bonus + honba_bonus winner.player.scores += win_amount + logger.info('Win: {0} +{1:,d}'.format(winner.player.name, win_amount)) + for client in self.clients: if client != winner: + if client.player.is_dealer: + scores_to_pay = hand_value['cost']['main'] + else: + scores_to_pay = hand_value['cost']['additional'] + scores_to_pay += (honba_bonus / 3) + client.player.scores -= scores_to_pay + logger.info('Lose: {0} -{1:,d}'.format(client.player.name, int(scores_to_pay))) - logger.info('Win: {0} + {1:,d}'.format(winner.player.name, win_amount)) # retake else: tempai_users = 0 @@ -395,7 +456,8 @@ def process_the_end_of_the_round(self, tiles, win_tile, winner, loser, is_tsumo) 'winner': winner, 'loser': loser, 'is_tsumo': is_tsumo, - 'is_game_end': is_game_end + 'is_game_end': is_game_end, + 'players_with_open_hands': self.players_with_open_hands } def players_sorted_by_scores(self): @@ -415,7 +477,7 @@ def _set_client_names(self): client.player.name = name def _get_current_client(self) -> Client: - return self.clients[self.current_client] + return self.clients[self.current_client_seat] def _cut_tiles(self, count_of_tiles) -> []: """ @@ -430,7 +492,7 @@ def _cut_tiles(self, count_of_tiles) -> []: def _move_position(self, current_position): """ - loop 0 -> 1 -> 2 -> 3 -> 0 + Loop 0 -> 1 -> 2 -> 3 -> 0 """ current_position += 1 if current_position > 3: diff --git a/project/game/logger.py b/project/game/logger.py index 6ca5a53d..f788dd05 100644 --- a/project/game/logger.py +++ b/project/game/logger.py @@ -1,15 +1,28 @@ # -*- coding: utf-8 -*- import logging +import datetime +import os + def set_up_logging(): + logs_directory = os.path.join(os.path.dirname(os.path.realpath(__file__)), '..', 'logs') + if not os.path.exists(logs_directory): + os.mkdir(logs_directory) + logger = logging.getLogger('game') logger.setLevel(logging.DEBUG) ch = logging.StreamHandler() ch.setLevel(logging.DEBUG) - formatter = logging.Formatter('%(levelname)s %(message)s') + file_name = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') + '.log' + fh = logging.FileHandler(os.path.join(logs_directory, file_name)) + fh.setLevel(logging.DEBUG) + + formatter = logging.Formatter('%(levelname)s: %(message)s') ch.setFormatter(formatter) + fh.setFormatter(formatter) logger.addHandler(ch) + logger.addHandler(fh) diff --git a/project/game/tests.py b/project/game/tests.py index f31a1eb2..51b319e6 100644 --- a/project/game/tests.py +++ b/project/game/tests.py @@ -5,13 +5,14 @@ import game.game_manager from game.game_manager import GameManager from mahjong.client import Client +from utils.tests import TestMixin -class GameManagerTestCase(unittest.TestCase): +class GameManagerTestCase(unittest.TestCase, TestMixin): def setUp(self): logger = logging.getLogger('game') - logger.disabled = True + logger.disabled = False def test_init_game(self): clients = [Client() for _ in range(0, 4)] @@ -28,7 +29,7 @@ def test_init_round(self): self.assertEqual(len(manager.dead_wall), 14) self.assertEqual(len(manager.dora_indicators), 1) - self.assertIsNotNone(manager.current_client) + self.assertIsNotNone(manager.current_client_seat) self.assertEqual(manager.round_number, 0) self.assertEqual(manager.honba_sticks, 0) self.assertEqual(manager.riichi_sticks, 0) @@ -171,6 +172,17 @@ def test_call_riichi(self): self.assertEqual(clients[2].player.in_riichi, False) self.assertEqual(clients[3].player.in_riichi, True) + # def test_debug(self): + # game.game_manager.shuffle_seed = lambda: 0.39144288381776615 + # + # clients = [Client() for _ in range(0, 4)] + # manager = GameManager(clients) + # manager.init_game() + # manager.set_dealer(1) + # manager.init_round() + # + # manager.play_round() + def test_play_round_and_win_by_tsumo(self): game.game_manager.shuffle_seed = lambda: 0.7662959679647414 @@ -222,13 +234,26 @@ def test_play_round_with_retake(self): self.assertEqual(result['winner'], None) self.assertEqual(result['loser'], None) + def test_play_round_and_open_yakuhai_hand(self): + game.game_manager.shuffle_seed = lambda: 0.8204258359989736 + + clients = [Client() for _ in range(0, 4)] + manager = GameManager(clients) + manager.init_game() + manager.set_dealer(3) + manager.init_round() + + result = manager.play_round() + + self.assertEqual(len(result['players_with_open_hands']), 1) + def test_scores_calculations_after_retake(self): clients = [Client() for _ in range(0, 4)] manager = GameManager(clients) manager.init_game() manager.init_round() - manager.process_the_end_of_the_round(None, None, None, None, False) + manager.process_the_end_of_the_round([], None, None, None, False) self.assertEqual(clients[0].player.scores, 25000) self.assertEqual(clients[1].player.scores, 25000) @@ -236,7 +261,7 @@ def test_scores_calculations_after_retake(self): self.assertEqual(clients[3].player.scores, 25000) clients[0].player.in_tempai = True - manager.process_the_end_of_the_round(None, None, None, None, False) + manager.process_the_end_of_the_round([], None, None, None, False) self.assertEqual(clients[0].player.scores, 28000) self.assertEqual(clients[1].player.scores, 24000) @@ -248,7 +273,7 @@ def test_scores_calculations_after_retake(self): clients[0].player.in_tempai = True clients[1].player.in_tempai = True - manager.process_the_end_of_the_round(None, None, None, None, False) + manager.process_the_end_of_the_round([], None, None, None, False) self.assertEqual(clients[0].player.scores, 26500) self.assertEqual(clients[1].player.scores, 26500) @@ -261,7 +286,7 @@ def test_scores_calculations_after_retake(self): clients[0].player.in_tempai = True clients[1].player.in_tempai = True clients[2].player.in_tempai = True - manager.process_the_end_of_the_round(None, None, None, None, False) + manager.process_the_end_of_the_round([], None, None, None, False) self.assertEqual(clients[0].player.scores, 26000) self.assertEqual(clients[1].player.scores, 26000) @@ -275,7 +300,7 @@ def test_scores_calculations_after_retake(self): clients[1].player.in_tempai = True clients[2].player.in_tempai = True clients[3].player.in_tempai = True - manager.process_the_end_of_the_round(None, None, None, None, False) + manager.process_the_end_of_the_round([], None, None, None, False) self.assertEqual(clients[0].player.scores, 25000) self.assertEqual(clients[1].player.scores, 25000) @@ -289,7 +314,7 @@ def test_retake_and_honba_increment(self): manager.init_round() # no one in tempai, so honba stick should be added - manager.process_the_end_of_the_round(None, None, None, None, False) + manager.process_the_end_of_the_round([], None, None, None, False) self.assertEqual(manager.honba_sticks, 1) manager.honba_sticks = 0 @@ -298,13 +323,13 @@ def test_retake_and_honba_increment(self): clients[1].player.in_tempai = True # dealer NOT in tempai, no honba - manager.process_the_end_of_the_round(None, None, None, None, False) + manager.process_the_end_of_the_round([], None, None, None, False) self.assertEqual(manager.honba_sticks, 0) clients[0].player.in_tempai = True # dealer in tempai, so honba stick should be added - manager.process_the_end_of_the_round(None, None, None, None, False) + manager.process_the_end_of_the_round([], None, None, None, False) self.assertEqual(manager.honba_sticks, 1) def test_win_by_ron_and_scores_calculation(self): @@ -312,14 +337,17 @@ def test_win_by_ron_and_scores_calculation(self): manager = GameManager(clients) manager.init_game() manager.init_round() + manager.set_dealer(0) winner = clients[0] loser = clients[1] - # only 1000 hand - manager.process_the_end_of_the_round(list(range(0, 14)), 0, winner, loser, False) - self.assertEqual(winner.player.scores, 26000) - self.assertEqual(loser.player.scores, 24000) + # only 1500 hand + tiles = self._string_to_136_array(sou='123567', pin='12345', man='11') + win_tile = self._string_to_136_tile(pin='6') + manager.process_the_end_of_the_round(tiles, win_tile, winner, loser, False) + self.assertEqual(winner.player.scores, 26500) + self.assertEqual(loser.player.scores, 23500) winner.player.scores = 25000 winner.player.dealer_seat = 1 @@ -327,7 +355,9 @@ def test_win_by_ron_and_scores_calculation(self): manager.riichi_sticks = 2 manager.honba_sticks = 2 - manager.process_the_end_of_the_round(list(range(0, 14)), 0, winner, loser, False) + tiles = self._string_to_136_array(sou='123567', pin='12345', man='11') + win_tile = self._string_to_136_tile(pin='6') + manager.process_the_end_of_the_round(tiles, win_tile, winner, loser, False) self.assertEqual(winner.player.scores, 28600) self.assertEqual(loser.player.scores, 23400) self.assertEqual(manager.riichi_sticks, 0) @@ -339,9 +369,11 @@ def test_win_by_ron_and_scores_calculation(self): manager.honba_sticks = 2 # if dealer won we need to increment honba sticks - manager.process_the_end_of_the_round(list(range(0, 14)), 0, winner, loser, False) - self.assertEqual(winner.player.scores, 26600) - self.assertEqual(loser.player.scores, 23400) + tiles = self._string_to_136_array(sou='123567', pin='12345', man='11') + win_tile = self._string_to_136_tile(pin='6') + manager.process_the_end_of_the_round(tiles, win_tile, winner, loser, False) + self.assertEqual(winner.player.scores, 27100) + self.assertEqual(loser.player.scores, 22900) self.assertEqual(manager.honba_sticks, 3) def test_win_by_tsumo_and_scores_calculation(self): @@ -350,14 +382,41 @@ def test_win_by_tsumo_and_scores_calculation(self): manager.init_game() manager.init_round() manager.riichi_sticks = 1 + manager.honba_sticks = 1 winner = clients[0] - manager.process_the_end_of_the_round(list(range(0, 14)), 0, winner, None, True) + manager.set_dealer(0) + winner.player.in_riichi = True + + tiles = self._string_to_136_array(sou='123567', pin='12345', man='11') + win_tile = self._string_to_136_tile(pin='6') + manager.process_the_end_of_the_round(tiles, win_tile, winner, None, True) + + # 3900 + riichi stick (1000) + honba stick (300) = 5200 + # 1400 from each other player + self.assertEqual(winner.player.scores, 30200) + self.assertEqual(clients[1].player.scores, 23600) + self.assertEqual(clients[2].player.scores, 23600) + self.assertEqual(clients[3].player.scores, 23600) + + for client in clients: + client.player.scores = 25000 - self.assertEqual(winner.player.scores, 26900) - self.assertEqual(clients[1].player.scores, 24700) - self.assertEqual(clients[2].player.scores, 24700) - self.assertEqual(clients[3].player.scores, 24700) + winner = clients[0] + manager.set_dealer(1) + winner.player.in_riichi = True + manager.riichi_sticks = 0 + manager.honba_sticks = 0 + + tiles = self._string_to_136_array(sou='123567', pin='12345', man='11') + win_tile = self._string_to_136_tile(pin='6') + manager.process_the_end_of_the_round(tiles, win_tile, winner, None, True) + + # 1300 from dealer and 700 from other players + self.assertEqual(winner.player.scores, 27700) + self.assertEqual(clients[1].player.scores, 24300) + self.assertEqual(clients[2].player.scores, 23700) + self.assertEqual(clients[3].player.scores, 24300) def test_change_dealer_after_end_of_the_round(self): clients = [Client() for _ in range(0, 4)] @@ -366,28 +425,28 @@ def test_change_dealer_after_end_of_the_round(self): manager.init_round() # retake. dealer is NOT in tempai, let's move a dealer position - manager.process_the_end_of_the_round(list(range(0, 14)), None, None, None, False) + manager.process_the_end_of_the_round(list(range(0, 13)), None, None, None, False) self.assertEqual(manager.dealer, 1) # retake. dealer is in tempai, don't move a dealer position clients[1].player.in_tempai = True - manager.process_the_end_of_the_round(list(range(0, 14)), 0, None, None, False) + manager.process_the_end_of_the_round(list(range(0, 13)), 0, None, None, False) self.assertEqual(manager.dealer, 1) # dealer win by ron, don't move a dealer position - manager.process_the_end_of_the_round(list(range(0, 14)), 0, None, None, False) + manager.process_the_end_of_the_round(list(range(0, 13)), 0, None, None, False) self.assertEqual(manager.dealer, 1) # dealer win by tsumo, don't move a dealer position - manager.process_the_end_of_the_round(list(range(0, 14)), 0, None, None, False) + manager.process_the_end_of_the_round(list(range(0, 13)), 0, None, None, False) self.assertEqual(manager.dealer, 1) # NOT dealer win by ron, let's move a dealer position - manager.process_the_end_of_the_round(list(range(0, 14)), 0, clients[3], clients[2], False) + manager.process_the_end_of_the_round(list(range(0, 13)), 0, clients[3], clients[2], False) self.assertEqual(manager.dealer, 2) # NOT dealer win by tsumo, let's move a dealer position - manager.process_the_end_of_the_round(list(range(0, 14)), 0, clients[1], None, True) + manager.process_the_end_of_the_round(list(range(0, 13)), 0, clients[1], None, True) self.assertEqual(manager.dealer, 3) def test_is_game_end_by_negative_scores(self): @@ -398,10 +457,12 @@ def test_is_game_end_by_negative_scores(self): winner = clients[0] loser = clients[1] - loser.player.scores = 500 + loser.player.scores = 0 - result = manager.process_the_end_of_the_round(list(range(0, 14)), 0, winner, loser, False) - self.assertEqual(loser.player.scores, -500) + tiles = self._string_to_136_array(sou='123567', pin='12345', man='11') + win_tile = self._string_to_136_tile(pin='6') + result = manager.process_the_end_of_the_round(tiles, win_tile, winner, loser, False) + self.assertEqual(loser.player.scores, -1500) self.assertEqual(result['is_game_end'], True) def test_is_game_end_by_eight_winds(self): @@ -416,12 +477,10 @@ def test_is_game_end_by_eight_winds(self): for x in range(0, 7): # to avoid honba - client = current_dealer == 0 and 1 or 0 - - result = manager.process_the_end_of_the_round(list(range(0, 14)), 0, clients[client], None, True) + result = manager.process_the_end_of_the_round([], 0, None, None, True) self.assertEqual(result['is_game_end'], False) self.assertNotEqual(manager.dealer, current_dealer) current_dealer = manager.dealer - result = manager.process_the_end_of_the_round(list(range(0, 14)), 0, clients[0], None, True) + result = manager.process_the_end_of_the_round(list(range(0, 13)), 0, clients[0], None, True) self.assertEqual(result['is_game_end'], True) diff --git a/project/mahjong/ai/main.py b/project/mahjong/ai/main.py index e1ef7b5e..81d892ce 100644 --- a/project/mahjong/ai/main.py +++ b/project/mahjong/ai/main.py @@ -7,11 +7,11 @@ from mahjong.hand import HandDivider from mahjong.meld import Meld from mahjong.tile import TilesConverter -from mahjong.utils import is_sou, is_pin, is_honor, is_chi, is_pon +from mahjong.utils import is_pin, is_honor, is_chi, is_pon, is_man class MainAI(BaseAI): - version = '0.0.6' + version = '0.1.0' agari = None shanten = None @@ -31,8 +31,11 @@ def __init__(self, table, player): self.previous_shanten = 7 self.yakuhai_strategy = False + def erase_state(self): + self.yakuhai_strategy = False + def discard_tile(self): - results, shanten = self.calculate_outs(self.player.tiles) + results, shanten = self.calculate_outs(self.player.tiles, self.player.closed_hand) self.previous_shanten = shanten if shanten == 0: @@ -58,12 +61,14 @@ def discard_tile(self): return tile_in_hand - def calculate_outs(self, tiles): + def calculate_outs(self, tiles, closed_hand): """ :param tiles: array of tiles in 136 format + :param closed_hand: array of tiles in 136 format :return: """ tiles_34 = TilesConverter.to_34_array(tiles) + closed_tiles_34 = TilesConverter.to_34_array(closed_hand) shanten = self.shanten.calculate_shanten(tiles_34) # win @@ -75,6 +80,9 @@ def calculate_outs(self, tiles): if not tiles_34[i]: continue + if not closed_tiles_34[i]: + continue + tiles_34[i] -= 1 raw_data[i] = [] @@ -90,7 +98,11 @@ def calculate_outs(self, tiles): tiles_34[i] += 1 if raw_data[i]: - raw_data[i] = {'tile': i, 'tiles_count': self.count_tiles(raw_data[i], tiles_34), 'waiting': raw_data[i]} + raw_data[i] = { + 'tile': i, + 'tiles_count': self.count_tiles(raw_data[i], tiles_34), + 'waiting': raw_data[i] + } results = [] tiles_34 = TilesConverter.to_34_array(self.player.tiles) @@ -129,51 +141,29 @@ def try_to_call_meld(self, tile, enemy_seat): :param enemy_seat: 1, 2, 3 :return: meld and tile to discard after called open set """ - player_tiles = self.player.closed_hand[:] + closed_hand = self.player.closed_hand[:] - valued_tiles = [CHUN, HAKU, HATSU, self.player.table.round_wind, self.player.player_wind] + # we opened all our hand + if len(closed_hand) == 1: + return None, None - tiles_34 = TilesConverter.to_34_array(player_tiles) + self.determine_open_hand_strategy() + + tiles_34 = TilesConverter.to_34_array(closed_hand) discarded_tile = tile // 4 - is_kamicha_discard = enemy_seat == 3 + # previous player + is_kamicha_discard = self.player.seat - 1 == enemy_seat or self.player.seat == 0 and enemy_seat == 3 new_tiles = self.player.tiles[:] + [tile] - outs_results, shanten = self.calculate_outs(new_tiles) - - # let's go for yakuhai - if discarded_tile in valued_tiles and tiles_34[discarded_tile] == 2: - first_tile = TilesConverter.find_34_tile_in_136_array(discarded_tile, player_tiles) - # we need to remove tile from array, to not find it again for second tile - player_tiles.remove(first_tile) - second_tile = TilesConverter.find_34_tile_in_136_array(discarded_tile, player_tiles) - tiles = [ - first_tile, - second_tile, - tile - ] - - meld = Meld() - meld.who = self.player.seat - meld.from_who = enemy_seat - meld.type = Meld.PON - meld.tiles = tiles - - tile_34 = outs_results[0]['discard'] - tile_to_discard = TilesConverter.find_34_tile_in_136_array(tile_34, self.player.tiles) - - self.player.tiles.append(tile) - - self.yakuhai_strategy = True + outs_results, shanten = self.calculate_outs(new_tiles, closed_hand) - return meld, tile_to_discard - - if self.player.is_open_hand: - # once hand was opened for yakuhai, we can open not our winds - if self.yakuhai_strategy and is_honor(discarded_tile) and tiles_34[discarded_tile] == 2: - first_tile = TilesConverter.find_34_tile_in_136_array(discarded_tile, player_tiles) + if self.yakuhai_strategy: + # pon of honor tiles + if is_honor(discarded_tile) and tiles_34[discarded_tile] == 2: + first_tile = TilesConverter.find_34_tile_in_136_array(discarded_tile, closed_hand) # we need to remove tile from array, to not find it again for second tile - player_tiles.remove(first_tile) - second_tile = TilesConverter.find_34_tile_in_136_array(discarded_tile, player_tiles) + closed_hand.remove(first_tile) + second_tile = TilesConverter.find_34_tile_in_136_array(discarded_tile, closed_hand) tiles = [ first_tile, second_tile, @@ -189,31 +179,34 @@ def try_to_call_meld(self, tile, enemy_seat): tile_34 = outs_results[0]['discard'] tile_to_discard = TilesConverter.find_34_tile_in_136_array(tile_34, self.player.tiles) - self.player.tiles.append(tile) - return meld, tile_to_discard # tile will decrease the count of shanten in hand # so let's call opened set with it if shanten < self.previous_shanten: - new_tiles_34 = TilesConverter.to_34_array(new_tiles) + closed_hand_34 = TilesConverter.to_34_array(closed_hand + [tile]) - if is_sou(discarded_tile): - combinations = self.hand_divider.find_valid_combinations(new_tiles_34, 0, 8, True) + if is_man(discarded_tile): + combinations = self.hand_divider.find_valid_combinations(closed_hand_34, 0, 8, True) elif is_pin(discarded_tile): - combinations = self.hand_divider.find_valid_combinations(new_tiles_34, 9, 17, True) + combinations = self.hand_divider.find_valid_combinations(closed_hand_34, 9, 17, True) else: - combinations = self.hand_divider.find_valid_combinations(new_tiles_34, 18, 26, True) + combinations = self.hand_divider.find_valid_combinations(closed_hand_34, 18, 26, True) + + if combinations: + combinations = combinations[0] possible_melds = [] for combination in combinations: # we can call pon from everyone - if is_pon(combination): - possible_melds.append(combination) + if is_pon(combination) and discarded_tile in combination: + if combination not in possible_melds: + possible_melds.append(combination) # we can call chi only from left player - if is_chi(combination) and is_kamicha_discard: - possible_melds.append(combination) + if is_chi(combination) and is_kamicha_discard and discarded_tile in combination: + if combination not in possible_melds: + possible_melds.append(combination) if len(possible_melds): # TODO add logic to find best meld @@ -221,10 +214,10 @@ def try_to_call_meld(self, tile, enemy_seat): meld_type = is_chi(combination) and Meld.CHI or Meld.PON combination.remove(discarded_tile) - first_tile = TilesConverter.find_34_tile_in_136_array(combination[0], player_tiles) + first_tile = TilesConverter.find_34_tile_in_136_array(combination[0], closed_hand) # we need to remove tile from array, to not find it again for second tile - player_tiles.remove(first_tile) - second_tile = TilesConverter.find_34_tile_in_136_array(combination[1], player_tiles) + closed_hand.remove(first_tile) + second_tile = TilesConverter.find_34_tile_in_136_array(combination[1], closed_hand) tiles = [ first_tile, second_tile, @@ -240,8 +233,20 @@ def try_to_call_meld(self, tile, enemy_seat): tile_34 = outs_results[0]['discard'] tile_to_discard = TilesConverter.find_34_tile_in_136_array(tile_34, self.player.tiles) - self.player.tiles.append(tile) - return meld, tile_to_discard return None, None + + def determine_open_hand_strategy(self): + if self._switch_to_yakuhai_strategy(): + self.yakuhai_strategy = True + + def _switch_to_yakuhai_strategy(self): + """ + Determine can we switch to yakuhai strategy or not + We can do it if we have valued pair in hand + :return: boolean + """ + valued_tiles = [CHUN, HAKU, HATSU, self.player.table.round_wind, self.player.player_wind] + tiles_34 = TilesConverter.to_34_array(self.player.tiles) + return any([tiles_34[x] == 2 for x in valued_tiles]) diff --git a/project/mahjong/ai/tests/tests_ai.py b/project/mahjong/ai/tests/tests_ai.py index 4d8537c4..73be019b 100644 --- a/project/mahjong/ai/tests/tests_ai.py +++ b/project/mahjong/ai/tests/tests_ai.py @@ -17,7 +17,7 @@ def test_outs(self): ai = MainAI(table, player) tiles = self._string_to_136_array(sou='111345677', pin='15', man='569') - outs, shanten = ai.calculate_outs(tiles) + outs, shanten = ai.calculate_outs(tiles, tiles) self.assertEqual(shanten, 2) self.assertEqual(outs[0]['discard'], 9) @@ -25,7 +25,7 @@ def test_outs(self): self.assertEqual(outs[0]['tiles_count'], 57) tiles = self._string_to_136_array(sou='111345677', pin='45', man='569') - outs, shanten = ai.calculate_outs(tiles) + outs, shanten = ai.calculate_outs(tiles, tiles) self.assertEqual(shanten, 1) self.assertEqual(outs[0]['discard'], 23) @@ -33,7 +33,7 @@ def test_outs(self): self.assertEqual(outs[0]['tiles_count'], 16) tiles = self._string_to_136_array(sou='11145677', pin='345', man='569') - outs, shanten = ai.calculate_outs(tiles) + outs, shanten = ai.calculate_outs(tiles, tiles) self.assertEqual(shanten, 0) self.assertEqual(outs[0]['discard'], 8) @@ -41,7 +41,7 @@ def test_outs(self): self.assertEqual(outs[0]['tiles_count'], 8) tiles = self._string_to_136_array(sou='11145677', pin='345', man='456') - outs, shanten = ai.calculate_outs(tiles) + outs, shanten = ai.calculate_outs(tiles, tiles) self.assertEqual(shanten, Shanten.AGARI_STATE) self.assertEqual(len(outs), 0) @@ -114,59 +114,57 @@ def test_open_hand_with_yakuhai_pair_in_hand(self): table = Table() player = Player(0, 0, table) - tiles = self._string_to_136_array(sou='123678', pin='258', honors='4455') - tile = self._string_to_136_array(honors='4')[0] + tiles = self._string_to_136_array(sou='123678', pin='25899', honors='44') + # 4 honor + tile = 122 player.init_hand(tiles) # we don't need to open hand with not our wind meld, _ = player.try_to_call_meld(tile, 3) self.assertEqual(meld, None) - # with dragon let's open our hand - tile = self._string_to_136_array(honors='5')[0] + # with dragon pair in hand let's open our hand + tiles = self._string_to_136_array(sou='12368', pin='2358', honors='4455') + tile = 122 + player.init_hand(tiles) meld, _ = player.try_to_call_meld(tile, 3) - self.assertNotEqual(meld, None) player.add_called_meld(meld) + player.tiles.append(tile) + self.assertEqual(meld.type, Meld.PON) - self.assertEqual(meld.tiles, [124, 124, 124]) + self.assertEqual(meld.tiles, [120, 121, 122]) self.assertEqual(len(player.closed_hand), 11) self.assertEqual(len(player.tiles), 14) player.discard_tile() - # once hand was opened, we can open set of not our winds - tile = self._string_to_136_array(honors='4')[0] + tile = 126 meld, _ = player.try_to_call_meld(tile, 3) self.assertNotEqual(meld, None) player.add_called_meld(meld) + player.tiles.append(tile) + self.assertEqual(meld.type, Meld.PON) - self.assertEqual(meld.tiles, [120, 120, 120]) - self.assertEqual(len(player.closed_hand), 9) + self.assertEqual(meld.tiles, [124, 125, 126]) + self.assertEqual(len(player.closed_hand), 8) self.assertEqual(len(player.tiles), 14) + player.discard_tile() - def test_continue_to_call_melds_with_already_opened_hand(self): - table = Table() - player = Player(0, 0, table) - - tiles = self._string_to_136_array(sou='123678', pin='25899', honors='55') - tile = self._string_to_136_array(pin='7')[0] - player.init_hand(tiles) - - meld, _ = player.try_to_call_meld(tile, 3) + tile = self._string_to_136_tile(sou='7') + # we can call chi only from left player + meld, _ = player.try_to_call_meld(tile, 2) self.assertEqual(meld, None) - tiles = self._string_to_136_array(sou='123678', pin='2589') - player.init_hand(tiles) - meld_tiles = [self._string_to_136_tile(honors='5'), self._string_to_136_tile(honors='5'), - self._string_to_136_tile(honors='5')] - player.add_called_meld(self._make_meld(Meld.PON, meld_tiles)) - - # we have already opened yakuhai pon, - # so we can continue to open hand - # if it will improve our shanten - tile = self._string_to_136_array(pin='7')[0] - meld, tile_to_discard = player.try_to_call_meld(tile, 3) + meld, _ = player.try_to_call_meld(tile, 3) self.assertNotEqual(meld, None) + player.add_called_meld(meld) + player.tiles.append(tile) + self.assertEqual(meld.type, Meld.CHI) - self.assertEqual(meld.tiles, [60, 64, 68]) - self.assertEqual(tile_to_discard, 52) + self.assertEqual(meld.tiles, [92, 96, 100]) + self.assertEqual(len(player.closed_hand), 5) + self.assertEqual(len(player.tiles), 14) + + self.assertEqual(player.in_tempai, False) + player.discard_tile() + self.assertEqual(player.in_tempai, True) diff --git a/project/mahjong/client.py b/project/mahjong/client.py index 45f01160..50625bb5 100644 --- a/project/mahjong/client.py +++ b/project/mahjong/client.py @@ -7,7 +7,7 @@ class Client(object): statistics = None id = '' - position = 0 + seat = 0 def __init__(self, use_previous_ai_version=False): self.table = Table(use_previous_ai_version) @@ -31,8 +31,8 @@ def draw_tile(self, tile): self.table.count_of_remaining_tiles -= 1 self.player.draw_tile(tile) - def discard_tile(self): - return self.player.discard_tile() + def discard_tile(self, tile=None): + return self.player.discard_tile(tile) def add_called_meld(self, meld): # when opponent called meld it is means diff --git a/project/mahjong/hand.py b/project/mahjong/hand.py index 9bf440e1..c1bfc7bf 100644 --- a/project/mahjong/hand.py +++ b/project/mahjong/hand.py @@ -1251,14 +1251,14 @@ def divide_hand(self, tiles_34, open_sets, called_kan_indices): local_tiles_34[pair_index] -= 2 - # 0 - 8 sou tiles - sou = self.find_valid_combinations(local_tiles_34, 0, 8) + # 0 - 8 man tiles + man = self.find_valid_combinations(local_tiles_34, 0, 8) # 9 - 17 pin tiles pin = self.find_valid_combinations(local_tiles_34, 9, 17) - # 18 - 26 man tiles - man = self.find_valid_combinations(local_tiles_34, 18, 26) + # 18 - 26 sou tiles + sou = self.find_valid_combinations(local_tiles_34, 18, 26) honor = [] for x in HONOR_INDICES: @@ -1401,7 +1401,7 @@ def is_valid_combination(possible_set): # lit of chi\pon sets for not completed hand if hand_not_completed: - return valid_combinations + return [valid_combinations] # hard case - we can build a lot of sets from our tiles # for example we have 123456 tiles and we can build sets: diff --git a/project/mahjong/meld.py b/project/mahjong/meld.py index f29c3172..ef9162b2 100644 --- a/project/mahjong/meld.py +++ b/project/mahjong/meld.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +from mahjong.tile import TilesConverter class Meld(object): @@ -14,7 +15,7 @@ class Meld(object): from_who = None def __str__(self): - return 'Who: {0}, Type: {1}, Tiles: {2}'.format(self.who, self.type, self.tiles) + return 'Type: {}, Tiles: {} {}'.format(self.type, TilesConverter.to_one_line_string(self.tiles), self.tiles) # for calls in array def __repr__(self): diff --git a/project/mahjong/player.py b/project/mahjong/player.py index 533bfebc..df8db3ae 100644 --- a/project/mahjong/player.py +++ b/project/mahjong/player.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- import logging +from functools import reduce from mahjong.constants import EAST, SOUTH, WEST, NORTH from utils.settings_handler import settings @@ -27,7 +28,6 @@ class Player(object): # tiles that were discarded after player's riichi safe_tiles = [] tiles = [] - closed_hand = [] melds = [] table = None in_tempai = False @@ -39,7 +39,6 @@ def __init__(self, seat, dealer_seat, table, use_previous_ai_version=False): self.melds = [] self.tiles = [] self.safe_tiles = [] - self.closed_hand = [] self.seat = seat self.table = table self.dealer_seat = dealer_seat @@ -79,32 +78,37 @@ def erase_state(self): self.melds = [] self.tiles = [] self.safe_tiles = [] + self.in_tempai = False self.in_riichi = False self.in_defence_mode = False + self.dealer_seat = 0 + self.ai.erase_state() + def add_called_meld(self, meld): self.melds.append(meld) - for tile in meld.tiles: - if tile in self.closed_hand: - self.closed_hand.remove(tile) - def add_discarded_tile(self, tile): self.discards.append(Tile(tile)) def init_hand(self, tiles): self.tiles = [Tile(i) for i in tiles] - self.closed_hand = self.tiles[:] def draw_tile(self, tile): self.tiles.append(Tile(tile)) # we need sort it to have a better string presentation self.tiles = sorted(self.tiles) - def discard_tile(self): - tile_to_discard = self.ai.discard_tile() + def discard_tile(self, tile=None): + """ + We can say what tile to discard + input tile = None we will discard tile based on AI logic + :param tile: 136 tiles format + :return: + """ + tile_to_discard = tile or self.ai.discard_tile() if tile_to_discard != Shanten.AGARI_STATE: self.add_discarded_tile(tile_to_discard) self.tiles.remove(tile_to_discard) @@ -113,7 +117,10 @@ def discard_tile(self): def can_call_riichi(self): return all([ self.in_tempai, + not self.in_riichi, + not self.is_open_hand, + self.scores >= 1000, self.table.count_of_remaining_tiles > 4 ]) @@ -147,3 +154,11 @@ def is_dealer(self): @property def is_open_hand(self): return len(self.melds) > 0 + + @property + def closed_hand(self): + tiles = self.tiles[:] + meld_tiles = [x.tiles for x in self.melds] + if meld_tiles: + meld_tiles = reduce(lambda z, y: z + y, [x.tiles for x in self.melds]) + return [item for item in tiles if item not in meld_tiles] diff --git a/project/mahjong/tests/tests_player.py b/project/mahjong/tests/tests_player.py index 6fc69f9b..608f764d 100644 --- a/project/mahjong/tests/tests_player.py +++ b/project/mahjong/tests/tests_player.py @@ -70,6 +70,22 @@ def test_can_call_riichi_and_remaining_tiles(self): self.assertEqual(player.can_call_riichi(), True) + def test_can_call_riichi_and_open_hand(self): + table = Table() + player = Player(0, 0, table) + + player.in_tempai = True + player.in_riichi = False + player.scores = 2000 + player.melds = [1] + player.table.count_of_remaining_tiles = 40 + + self.assertEqual(player.can_call_riichi(), False) + + player.melds = [] + + self.assertEqual(player.can_call_riichi(), True) + def test_player_wind(self): table = Table() @@ -91,9 +107,11 @@ def test_player_called_meld_and_closed_hand(self): tiles = self._string_to_136_array(sou='123678', pin='3599', honors='555') player.init_hand(tiles) - meld_tiles = [self._string_to_136_tile(honors='5'), self._string_to_136_tile(honors='5'), - self._string_to_136_tile(honors='5')] + + meld_tiles = [124, 125, 126] self.assertEqual(len(player.closed_hand), 13) + player.add_called_meld(self._make_meld(Meld.PON, meld_tiles)) + self.assertEqual(len(player.closed_hand), 10) diff --git a/project/mahjong/tile.py b/project/mahjong/tile.py index 9cbb4a2a..1ac5a4dd 100644 --- a/project/mahjong/tile.py +++ b/project/mahjong/tile.py @@ -61,13 +61,22 @@ def string_to_136_array(sou=None, pin=None, man=None, honors=None): """ def _split_string(string, offset): data = [] + temp = [] if not string: return [] for i in string: tile = offset + (int(i) - 1) * 4 - data.append(tile) + if tile in data: + count_of_tiles = len([x for x in temp if x == tile]) + new_tile = tile + count_of_tiles + data.append(new_tile) + + temp.append(tile) + else: + data.append(tile) + temp.append(tile) return data diff --git a/project/mahjong/utils.py b/project/mahjong/utils.py index 253ab8b7..27df478e 100644 --- a/project/mahjong/utils.py +++ b/project/mahjong/utils.py @@ -92,7 +92,7 @@ def is_pair(item): return len(item) == 2 -def is_sou(tile): +def is_man(tile): """ :param tile: 34 tile format :return: boolean @@ -108,7 +108,7 @@ def is_pin(tile): return 8 < tile <= 17 -def is_man(tile): +def is_sou(tile): """ :param tile: 34 tile format :return: boolean From 34a79d14a5ec482f4a039b876299bb41144a4f84 Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Sat, 5 Nov 2016 11:09:41 +0800 Subject: [PATCH 03/80] Add yakuhai strategy --- .gitignore | 1 + project/bots_battle.py | 2 +- project/game/game_manager.py | 79 +++++++-- project/game/tests.py | 76 ++++++--- project/mahjong/ai/main.py | 158 ++++-------------- project/mahjong/ai/shanten.py | 7 +- project/mahjong/ai/strategies/__init__.py | 1 + project/mahjong/ai/strategies/main.py | 132 +++++++++++++++ project/mahjong/ai/strategies/yakuhai.py | 22 +++ project/mahjong/ai/tests/tests_ai.py | 53 +----- project/mahjong/ai/tests/tests_strategies.py | 96 +++++++++++ project/mahjong/hand.py | 58 ++++--- project/mahjong/player.py | 27 +-- .../mahjong/tests/tests_yaku_calculation.py | 43 +++-- project/mahjong/tile.py | 13 -- project/mahjong/yaku.py | 4 +- project/tenhou/decoder.py | 11 +- project/utils/logger.py | 8 +- 18 files changed, 503 insertions(+), 288 deletions(-) create mode 100644 project/mahjong/ai/strategies/__init__.py create mode 100644 project/mahjong/ai/strategies/main.py create mode 100644 project/mahjong/ai/strategies/yakuhai.py create mode 100644 project/mahjong/ai/tests/tests_strategies.py diff --git a/.gitignore b/.gitignore index 5b1a7eef..b45f3981 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ tests_validate_hand.py loader.py *.db temp +*.log replays/data/* diff --git a/project/bots_battle.py b/project/bots_battle.py index 1944b1bd..4db6dba7 100644 --- a/project/bots_battle.py +++ b/project/bots_battle.py @@ -10,7 +10,7 @@ from game.game_manager import GameManager from mahjong.client import Client -TOTAL_HANCHANS = 20 +TOTAL_HANCHANS = 30 def main(): diff --git a/project/game/game_manager.py b/project/game/game_manager.py index ad804312..937a57e9 100644 --- a/project/game/game_manager.py +++ b/project/game/game_manager.py @@ -76,6 +76,7 @@ def init_round(self): seed_value = random() self.players_with_open_hands = [] + self.dora_indicators = [] self.tiles = [i for i in range(0, 136)] @@ -98,7 +99,7 @@ def init_round(self): player_scores = list(player_scores) client.table.init_round( - self.round_number, + self._unique_dealers, self.honba_sticks, self.riichi_sticks, self.dora_indicators[0], @@ -149,12 +150,34 @@ def play_round(self): # win by tsumo after tile draw if is_win: tiles.remove(tile) - result = self.process_the_end_of_the_round(tiles=tiles, - win_tile=tile, - winner=current_client, - loser=None, - is_tsumo=True) - return result + can_win = True + + # with open hand it can be situation when we in the tempai + # but our hand doesn't contain any yaku + # in that case we can't call ron + if not current_client.player.in_riichi: + result = self.finished_hand.estimate_hand_value(tiles=tiles + [tile], + win_tile=tile, + is_tsumo=True, + is_riichi=False, + open_sets=current_client.player.meld_tiles, + dora_indicators=self.dora_indicators, + player_wind=current_client.player.player_wind, + round_wind=current_client.player.table.round_wind) + can_win = result['error'] is None + + if can_win: + result = self.process_the_end_of_the_round(tiles=tiles, + win_tile=tile, + winner=current_client, + loser=None, + is_tsumo=True) + return result + else: + # we can't win + # so let's add tile back to hand + # and discard it later + tiles.append(tile) # if not in riichi, let's decide what tile to discard if not current_client.player.in_riichi: @@ -174,7 +197,8 @@ def play_round(self): possible_melds = [] for other_client in self.clients: # there is no need to check the current client - if other_client == current_client: + # or check client in riichi + if other_client == current_client or other_client.player.in_riichi: continue meld, discarded_tile = other_client.player.try_to_call_meld(tile, @@ -202,11 +226,14 @@ def play_round(self): current_client.add_called_meld(meld) current_client.player.tiles.append(tile) - current_client.discard_tile(tile_to_discard) + logger.info('Called meld: {} by {}'.format(meld, current_client.player.name)) - self.check_clients_possible_ron(current_client, tile_to_discard) + # we need to double validate that we are doing fine + if tile_to_discard not in current_client.player.closed_hand: + raise ValueError("We can't discard a tile from the opened part of the hand") - logger.info('Called meld: {} by {}'.format(meld, current_client.player.name)) + current_client.discard_tile(tile_to_discard) + self.check_clients_possible_ron(current_client, tile_to_discard) else: self.current_client_seat = self._move_position(self.current_client_seat) @@ -305,6 +332,21 @@ def can_call_ron(self, client, win_tile): return False tiles = client.player.tiles + + # with open hand it can be situation when we in the tempai + # but our hand doesn't contain any yaku + # in that case we can't call ron + if not client.player.in_riichi: + result = self.finished_hand.estimate_hand_value(tiles=tiles + [win_tile], + win_tile=win_tile, + is_tsumo=False, + is_riichi=False, + open_sets=client.player.meld_tiles, + dora_indicators=self.dora_indicators, + player_wind=client.player.player_wind, + round_wind=client.player.table.round_wind) + return result['error'] is None + is_ron = self.agari.is_agari(TilesConverter.to_34_array(tiles + [win_tile])) return is_ron @@ -356,12 +398,19 @@ def process_the_end_of_the_round(self, tiles, win_tile, winner, loser, is_tsumo) self.round_number += 1 if winner: + # add one more dora for riichi win + if winner.player.in_riichi: + self.dora_indicators.append(self.dead_wall[9]) + hand_value = self.finished_hand.estimate_hand_value(tiles=tiles + [win_tile], win_tile=win_tile, is_tsumo=is_tsumo, is_riichi=winner.player.in_riichi, is_dealer=winner.player.is_dealer, - ) + open_sets=winner.player.meld_tiles, + dora_indicators=self.dora_indicators, + player_wind=winner.player.player_wind, + round_wind=winner.player.table.round_wind) if hand_value['error']: logger.error("Can't estimate a hand: {}. Error: {}".format( @@ -370,6 +419,9 @@ def process_the_end_of_the_round(self, tiles, win_tile, winner, loser, is_tsumo) )) raise ValueError('Not correct hand') + logger.info('Dora indicators: {}'.format(TilesConverter.to_one_line_string(self.dora_indicators))) + logger.info('Hand yaku: {}'.format(', '.join(str(x) for x in hand_value['hand_yaku']))) + riichi_bonus = self.riichi_sticks * 1000 self.riichi_sticks = 0 honba_bonus = self.honba_sticks * 300 @@ -397,7 +449,8 @@ def process_the_end_of_the_round(self, tiles, win_tile, winner, loser, is_tsumo) win_amount = calculated_cost + riichi_bonus + honba_bonus winner.player.scores += win_amount - logger.info('Win: {0} +{1:,d}'.format(winner.player.name, win_amount)) + logger.info('Win: {0} +{1:,d} +{2:,d}'.format(winner.player.name, calculated_cost, + riichi_bonus + honba_bonus)) for client in self.clients: if client != winner: diff --git a/project/game/tests.py b/project/game/tests.py index 51b319e6..fa0cf6ae 100644 --- a/project/game/tests.py +++ b/project/game/tests.py @@ -172,17 +172,6 @@ def test_call_riichi(self): self.assertEqual(clients[2].player.in_riichi, False) self.assertEqual(clients[3].player.in_riichi, True) - # def test_debug(self): - # game.game_manager.shuffle_seed = lambda: 0.39144288381776615 - # - # clients = [Client() for _ in range(0, 4)] - # manager = GameManager(clients) - # manager.init_game() - # manager.set_dealer(1) - # manager.init_round() - # - # manager.play_round() - def test_play_round_and_win_by_tsumo(self): game.game_manager.shuffle_seed = lambda: 0.7662959679647414 @@ -386,25 +375,27 @@ def test_win_by_tsumo_and_scores_calculation(self): winner = clients[0] manager.set_dealer(0) - winner.player.in_riichi = True + manager.dora_indicators = [100] + # to avoid ura-dora, because of this test can fail + winner.player.in_riichi = False tiles = self._string_to_136_array(sou='123567', pin='12345', man='11') win_tile = self._string_to_136_tile(pin='6') manager.process_the_end_of_the_round(tiles, win_tile, winner, None, True) - # 3900 + riichi stick (1000) + honba stick (300) = 5200 - # 1400 from each other player - self.assertEqual(winner.player.scores, 30200) - self.assertEqual(clients[1].player.scores, 23600) - self.assertEqual(clients[2].player.scores, 23600) - self.assertEqual(clients[3].player.scores, 23600) + # 2400 + riichi stick (1000) = 3400 + # 700 from each other player + 100 honba payment + self.assertEqual(winner.player.scores, 28400) + self.assertEqual(clients[1].player.scores, 24200) + self.assertEqual(clients[2].player.scores, 24200) + self.assertEqual(clients[3].player.scores, 24200) for client in clients: client.player.scores = 25000 winner = clients[0] manager.set_dealer(1) - winner.player.in_riichi = True + winner.player.in_riichi = False manager.riichi_sticks = 0 manager.honba_sticks = 0 @@ -412,11 +403,11 @@ def test_win_by_tsumo_and_scores_calculation(self): win_tile = self._string_to_136_tile(pin='6') manager.process_the_end_of_the_round(tiles, win_tile, winner, None, True) - # 1300 from dealer and 700 from other players - self.assertEqual(winner.player.scores, 27700) - self.assertEqual(clients[1].player.scores, 24300) - self.assertEqual(clients[2].player.scores, 23700) - self.assertEqual(clients[3].player.scores, 24300) + # 700 from dealer and 400 from other players + self.assertEqual(winner.player.scores, 26500) + self.assertEqual(clients[1].player.scores, 24600) + self.assertEqual(clients[2].player.scores, 24300) + self.assertEqual(clients[3].player.scores, 24600) def test_change_dealer_after_end_of_the_round(self): clients = [Client() for _ in range(0, 4)] @@ -457,6 +448,7 @@ def test_is_game_end_by_negative_scores(self): winner = clients[0] loser = clients[1] + manager.dora_indicators = [100] loser.player.scores = 0 tiles = self._string_to_136_array(sou='123567', pin='12345', man='11') @@ -484,3 +476,39 @@ def test_is_game_end_by_eight_winds(self): result = manager.process_the_end_of_the_round(list(range(0, 13)), 0, clients[0], None, True) self.assertEqual(result['is_game_end'], True) + + def test_ron_with_not_correct_hand(self): + """ + With open for yakuhai strategy we can have situation like this + 234567m67s66z + 8s + [444z] + We have open hand and we don't have yaku in the hand + In that case we can't call ron. + Round should be ended without exceptions + """ + game.game_manager.shuffle_seed = lambda: 0.5082102963203375 + + clients = [Client() for _ in range(0, 4)] + manager = GameManager(clients) + manager.init_game() + manager.set_dealer(1) + manager.init_round() + + manager.play_round() + + def test_tsumo_with_not_correct_hand(self): + """ + With open for yakuhai strategy we can have situation like this + 234567m67s66z + 8s + [444z] + We have open hand and we don't have yaku in the hand + In that case we can't call tsumo. + Round should be ended without exceptions + """ + game.game_manager.shuffle_seed = lambda: 0.26483054978923926 + + clients = [Client() for _ in range(0, 4)] + manager = GameManager(clients) + manager.init_game() + manager.set_dealer(1) + manager.init_round() + + manager.play_round() diff --git a/project/mahjong/ai/main.py b/project/mahjong/ai/main.py index 81d892ce..14a8f330 100644 --- a/project/mahjong/ai/main.py +++ b/project/mahjong/ai/main.py @@ -3,6 +3,8 @@ from mahjong.ai.base import BaseAI from mahjong.ai.defence import Defence from mahjong.ai.shanten import Shanten +from mahjong.ai.strategies.main import BaseStrategy +from mahjong.ai.strategies.yakuhai import YakuhaiStrategy from mahjong.constants import HAKU, CHUN, HATSU from mahjong.hand import HandDivider from mahjong.meld import Meld @@ -19,7 +21,7 @@ class MainAI(BaseAI): hand_divider = None previous_shanten = 7 - yakuhai_strategy = False + current_strategy = None def __init__(self, table, player): super(MainAI, self).__init__(table, player) @@ -29,10 +31,10 @@ def __init__(self, table, player): self.defence = Defence(table) self.hand_divider = HandDivider() self.previous_shanten = 7 - self.yakuhai_strategy = False + self.current_strategy = None def erase_state(self): - self.yakuhai_strategy = False + self.current_strategy = None def discard_tile(self): results, shanten = self.calculate_outs(self.player.tiles, self.player.closed_hand) @@ -56,10 +58,15 @@ def discard_tile(self): # tile34 = results[0]['discard'] # tile_in_hand = TilesConverter.find_34_tile_in_136_array(tile34, self.player.tiles) - tile34 = results[0]['discard'] - tile_in_hand = TilesConverter.find_34_tile_in_136_array(tile34, self.player.tiles) + # we are in agari state, but we can't win because we don't have yaku + # in that case let's do tsumogiri + if not results: + return self.player.last_draw + else: + tile34 = results[0]['discard'] + tile_in_hand = TilesConverter.find_34_tile_in_136_array(tile34, self.player.tiles) - return tile_in_hand + return tile_in_hand def calculate_outs(self, tiles, closed_hand): """ @@ -69,7 +76,7 @@ def calculate_outs(self, tiles, closed_hand): """ tiles_34 = TilesConverter.to_34_array(tiles) closed_tiles_34 = TilesConverter.to_34_array(closed_hand) - shanten = self.shanten.calculate_shanten(tiles_34) + shanten = self.shanten.calculate_shanten(tiles_34, self.player.is_open_hand) # win if shanten == Shanten.AGARI_STATE: @@ -83,6 +90,10 @@ def calculate_outs(self, tiles, closed_hand): if not closed_tiles_34[i]: continue + # let's keep valued pair of tiles for later game + if closed_tiles_34[i] >= 2 and i in self.valued_honors: + continue + tiles_34[i] -= 1 raw_data[i] = [] @@ -91,7 +102,7 @@ def calculate_outs(self, tiles, closed_hand): continue tiles_34[j] += 1 - if self.shanten.calculate_shanten(tiles_34) == shanten - 1: + if self.shanten.calculate_shanten(tiles_34, self.player.is_open_hand) == shanten - 1: raw_data[i].append(j) tiles_34[j] -= 1 @@ -134,119 +145,22 @@ def count_tiles(self, raw_data, tiles): return n def try_to_call_meld(self, tile, enemy_seat): - """ - Determine should we call a meld or not. - If yes, it will add tile to the player's hand and will return Meld object - :param tile: 136 format tile - :param enemy_seat: 1, 2, 3 - :return: meld and tile to discard after called open set - """ - closed_hand = self.player.closed_hand[:] - - # we opened all our hand - if len(closed_hand) == 1: + if not self.current_strategy: return None, None - self.determine_open_hand_strategy() - - tiles_34 = TilesConverter.to_34_array(closed_hand) - discarded_tile = tile // 4 - # previous player - is_kamicha_discard = self.player.seat - 1 == enemy_seat or self.player.seat == 0 and enemy_seat == 3 - - new_tiles = self.player.tiles[:] + [tile] - outs_results, shanten = self.calculate_outs(new_tiles, closed_hand) - - if self.yakuhai_strategy: - # pon of honor tiles - if is_honor(discarded_tile) and tiles_34[discarded_tile] == 2: - first_tile = TilesConverter.find_34_tile_in_136_array(discarded_tile, closed_hand) - # we need to remove tile from array, to not find it again for second tile - closed_hand.remove(first_tile) - second_tile = TilesConverter.find_34_tile_in_136_array(discarded_tile, closed_hand) - tiles = [ - first_tile, - second_tile, - tile - ] - - meld = Meld() - meld.who = self.player.seat - meld.from_who = enemy_seat - meld.type = Meld.PON - meld.tiles = tiles - - tile_34 = outs_results[0]['discard'] - tile_to_discard = TilesConverter.find_34_tile_in_136_array(tile_34, self.player.tiles) - - return meld, tile_to_discard - - # tile will decrease the count of shanten in hand - # so let's call opened set with it - if shanten < self.previous_shanten: - closed_hand_34 = TilesConverter.to_34_array(closed_hand + [tile]) - - if is_man(discarded_tile): - combinations = self.hand_divider.find_valid_combinations(closed_hand_34, 0, 8, True) - elif is_pin(discarded_tile): - combinations = self.hand_divider.find_valid_combinations(closed_hand_34, 9, 17, True) - else: - combinations = self.hand_divider.find_valid_combinations(closed_hand_34, 18, 26, True) - - if combinations: - combinations = combinations[0] - - possible_melds = [] - for combination in combinations: - # we can call pon from everyone - if is_pon(combination) and discarded_tile in combination: - if combination not in possible_melds: - possible_melds.append(combination) - - # we can call chi only from left player - if is_chi(combination) and is_kamicha_discard and discarded_tile in combination: - if combination not in possible_melds: - possible_melds.append(combination) - - if len(possible_melds): - # TODO add logic to find best meld - combination = possible_melds[0] - meld_type = is_chi(combination) and Meld.CHI or Meld.PON - - combination.remove(discarded_tile) - first_tile = TilesConverter.find_34_tile_in_136_array(combination[0], closed_hand) - # we need to remove tile from array, to not find it again for second tile - closed_hand.remove(first_tile) - second_tile = TilesConverter.find_34_tile_in_136_array(combination[1], closed_hand) - tiles = [ - first_tile, - second_tile, - tile - ] - - meld = Meld() - meld.who = self.player.seat - meld.from_who = enemy_seat - meld.type = meld_type - meld.tiles = sorted(tiles) - - tile_34 = outs_results[0]['discard'] - tile_to_discard = TilesConverter.find_34_tile_in_136_array(tile_34, self.player.tiles) - - return meld, tile_to_discard - - return None, None - - def determine_open_hand_strategy(self): - if self._switch_to_yakuhai_strategy(): - self.yakuhai_strategy = True - - def _switch_to_yakuhai_strategy(self): - """ - Determine can we switch to yakuhai strategy or not - We can do it if we have valued pair in hand - :return: boolean - """ - valued_tiles = [CHUN, HAKU, HATSU, self.player.table.round_wind, self.player.player_wind] - tiles_34 = TilesConverter.to_34_array(self.player.tiles) - return any([tiles_34[x] == 2 for x in valued_tiles]) + return self.current_strategy.try_to_call_meld(tile, enemy_seat) + + def determine_strategy(self): + if self.current_strategy: + return False + + strategies = [YakuhaiStrategy(BaseStrategy.YAKUHAI, self.player)] + + for strategy in strategies: + if strategy.should_activate_strategy(): + self.current_strategy = strategy + return True + + @property + def valued_honors(self): + return [CHUN, HAKU, HATSU, self.player.table.round_wind, self.player.player_wind] diff --git a/project/mahjong/ai/shanten.py b/project/mahjong/ai/shanten.py index e6b0dfc3..c0304d8d 100644 --- a/project/mahjong/ai/shanten.py +++ b/project/mahjong/ai/shanten.py @@ -14,10 +14,11 @@ class Shanten(object): number_isolated_tiles = 0 min_shanten = 0 - def calculate_shanten(self, tiles): + def calculate_shanten(self, tiles, is_open_hand=False): """ Return the count of tiles before tempai :param tiles: 34 tiles format array + :param is_open_hand: :return: int """ self._init(tiles) @@ -27,7 +28,9 @@ def calculate_shanten(self, tiles): if count_of_tiles > 14: return -2 - self.min_shanten = self._scan_chitoitsu_and_kokushi() + if not is_open_hand: + self.min_shanten = self._scan_chitoitsu_and_kokushi() + self._remove_character_tiles(count_of_tiles) init_mentsu = math.floor((14 - count_of_tiles) / 3) diff --git a/project/mahjong/ai/strategies/__init__.py b/project/mahjong/ai/strategies/__init__.py new file mode 100644 index 00000000..40a96afc --- /dev/null +++ b/project/mahjong/ai/strategies/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/project/mahjong/ai/strategies/main.py b/project/mahjong/ai/strategies/main.py new file mode 100644 index 00000000..d30ab629 --- /dev/null +++ b/project/mahjong/ai/strategies/main.py @@ -0,0 +1,132 @@ +# -*- coding: utf-8 -*- +from mahjong.meld import Meld +from mahjong.tile import TilesConverter +from mahjong.utils import is_man, is_pin, is_sou, is_chi, is_pon + + +class BaseStrategy(object): + YAKUHAI = 0 + + player = None + type = None + + def __init__(self, type, player): + self.type = type + self.player = player + + def should_activate_strategy(self): + """ + Based on player hand and table situation + we can determine should we use this strategy or not + :return: boolean + """ + raise NotImplemented() + + def is_tile_suitable(self, tile): + """ + Can tile be used for open hand strategy or not + :param tile: in 136 tiles format + :return: boolean + """ + raise NotImplemented() + + def try_to_call_meld(self, tile, enemy_seat): + """ + 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 enemy_seat: 1, 2, 3 + :return: meld and tile to discard after called open set + """ + if self.player.in_riichi: + return None, None + + closed_hand = self.player.closed_hand[:] + + # we opened all our hand + if len(closed_hand) == 1: + return None, None + + # we can't use this tile for our chosen strategy + if not self.is_tile_suitable(tile): + return None, None + + discarded_tile = tile // 4 + # previous player + is_kamicha_discard = self.player.seat - 1 == enemy_seat or self.player.seat == 0 and enemy_seat == 3 + + new_tiles = self.player.tiles[:] + [tile] + outs_results, shanten = self.player.ai.calculate_outs(new_tiles, closed_hand) + + # we can't improve hand, so we don't need to open it + if not outs_results: + return None, None + + # tile will decrease the count of shanten in hand + # so let's call opened set with it + if shanten < self.player.ai.previous_shanten: + closed_hand_34 = TilesConverter.to_34_array(closed_hand + [tile]) + + combinations = [] + if is_man(discarded_tile): + combinations = self.player.ai.hand_divider.find_valid_combinations(closed_hand_34, 0, 8, True) + elif is_pin(discarded_tile): + combinations = self.player.ai.hand_divider.find_valid_combinations(closed_hand_34, 9, 17, True) + elif is_sou(discarded_tile): + combinations = self.player.ai.hand_divider.find_valid_combinations(closed_hand_34, 18, 26, True) + else: + # honor tiles + if closed_hand_34[discarded_tile] == 3: + combinations = [[[discarded_tile] * 3]] + + if combinations: + combinations = combinations[0] + + possible_melds = [] + for combination in combinations: + # we can call pon from everyone + if is_pon(combination) and discarded_tile in combination: + if combination not in possible_melds: + possible_melds.append(combination) + + # we can call chi only from left player + if is_chi(combination) and is_kamicha_discard and discarded_tile in combination: + if combination not in possible_melds: + possible_melds.append(combination) + + if len(possible_melds): + # TODO add logic to find best meld + combination = possible_melds[0] + meld_type = is_chi(combination) and Meld.CHI or Meld.PON + combination.remove(discarded_tile) + + first_tile = TilesConverter.find_34_tile_in_136_array(combination[0], closed_hand) + closed_hand.remove(first_tile) + + second_tile = TilesConverter.find_34_tile_in_136_array(combination[1], closed_hand) + closed_hand.remove(second_tile) + + tiles = [ + first_tile, + second_tile, + tile + ] + + meld = Meld() + meld.who = self.player.seat + meld.from_who = enemy_seat + meld.type = meld_type + meld.tiles = sorted(tiles) + + tile_to_discard = None + # we need to find possible tile to discard + # it can be that first result already on our set + for out_result in outs_results: + tile_34 = out_result['discard'] + tile_to_discard = TilesConverter.find_34_tile_in_136_array(tile_34, closed_hand) + if tile_to_discard: + break + + return meld, tile_to_discard + + return None, None diff --git a/project/mahjong/ai/strategies/yakuhai.py b/project/mahjong/ai/strategies/yakuhai.py new file mode 100644 index 00000000..99b027e5 --- /dev/null +++ b/project/mahjong/ai/strategies/yakuhai.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +from mahjong.ai.strategies.main import BaseStrategy +from mahjong.tile import TilesConverter + + +class YakuhaiStrategy(BaseStrategy): + + def should_activate_strategy(self): + """ + We can go for yakuhai strategy if we have at least one yakuhai pair in the hand + :return: boolean + """ + tiles_34 = TilesConverter.to_34_array(self.player.tiles) + return any([tiles_34[x] >= 2 for x in self.player.ai.valued_honors]) + + def is_tile_suitable(self, tile): + """ + For yakuhai we don't have limits + :param tile: 136 tiles format + :return: True + """ + return True diff --git a/project/mahjong/ai/tests/tests_ai.py b/project/mahjong/ai/tests/tests_ai.py index 73be019b..6eabea6b 100644 --- a/project/mahjong/ai/tests/tests_ai.py +++ b/project/mahjong/ai/tests/tests_ai.py @@ -110,61 +110,14 @@ def test_set_is_tempai_flag_to_the_player(self): player.discard_tile() self.assertEqual(player.in_tempai, True) - def test_open_hand_with_yakuhai_pair_in_hand(self): + def test_not_open_hand_in_riichi(self): table = Table() player = Player(0, 0, table) - tiles = self._string_to_136_array(sou='123678', pin='25899', honors='44') - # 4 honor - tile = 122 - player.init_hand(tiles) - - # we don't need to open hand with not our wind - meld, _ = player.try_to_call_meld(tile, 3) - self.assertEqual(meld, None) + player.in_riichi = True - # with dragon pair in hand let's open our hand tiles = self._string_to_136_array(sou='12368', pin='2358', honors='4455') - tile = 122 + tile = self._string_to_136_tile(honors='5') player.init_hand(tiles) meld, _ = player.try_to_call_meld(tile, 3) - self.assertNotEqual(meld, None) - player.add_called_meld(meld) - player.tiles.append(tile) - - self.assertEqual(meld.type, Meld.PON) - self.assertEqual(meld.tiles, [120, 121, 122]) - self.assertEqual(len(player.closed_hand), 11) - self.assertEqual(len(player.tiles), 14) - player.discard_tile() - - tile = 126 - meld, _ = player.try_to_call_meld(tile, 3) - self.assertNotEqual(meld, None) - player.add_called_meld(meld) - player.tiles.append(tile) - - self.assertEqual(meld.type, Meld.PON) - self.assertEqual(meld.tiles, [124, 125, 126]) - 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, 2) self.assertEqual(meld, None) - - meld, _ = player.try_to_call_meld(tile, 3) - self.assertNotEqual(meld, None) - player.add_called_meld(meld) - player.tiles.append(tile) - - self.assertEqual(meld.type, Meld.CHI) - self.assertEqual(meld.tiles, [92, 96, 100]) - self.assertEqual(len(player.closed_hand), 5) - self.assertEqual(len(player.tiles), 14) - - self.assertEqual(player.in_tempai, False) - player.discard_tile() - self.assertEqual(player.in_tempai, True) diff --git a/project/mahjong/ai/tests/tests_strategies.py b/project/mahjong/ai/tests/tests_strategies.py new file mode 100644 index 00000000..14bb4c7c --- /dev/null +++ b/project/mahjong/ai/tests/tests_strategies.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +import unittest + +from mahjong.ai.main import MainAI +from mahjong.ai.shanten import Shanten +from mahjong.ai.strategies.main import BaseStrategy +from mahjong.ai.strategies.yakuhai import YakuhaiStrategy +from mahjong.meld import Meld +from mahjong.player import Player +from mahjong.table import Table +from utils.tests import TestMixin + + +class YakuhaiStrategyTestCase(unittest.TestCase, TestMixin): + + def test_should_activate_strategy(self): + table = Table() + player = Player(0, 0, table) + 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) + + def test_suitable_tiles(self): + table = Table() + player = Player(0, 0, table) + 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 = Player(0, 0, table) + + tiles = self._string_to_136_array(sou='123678', pin='25899', honors='44') + # 4 honor + tile = 122 + player.init_hand(tiles) + + # we don't need to open hand with not our wind + meld, _ = player.try_to_call_meld(tile, 3) + 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 = 122 + player.init_hand(tiles) + meld, _ = player.try_to_call_meld(tile, 3) + self.assertNotEqual(meld, None) + player.add_called_meld(meld) + player.tiles.append(tile) + + self.assertEqual(meld.type, Meld.PON) + self.assertEqual(meld.tiles, [120, 121, 122]) + self.assertEqual(len(player.closed_hand), 11) + self.assertEqual(len(player.tiles), 14) + player.discard_tile() + + tile = 126 + meld, _ = player.try_to_call_meld(tile, 3) + self.assertNotEqual(meld, None) + player.add_called_meld(meld) + player.tiles.append(tile) + + self.assertEqual(meld.type, Meld.PON) + self.assertEqual(meld.tiles, [124, 125, 126]) + 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, 2) + self.assertEqual(meld, None) + + meld, _ = player.try_to_call_meld(tile, 3) + self.assertNotEqual(meld, None) + player.add_called_meld(meld) + player.tiles.append(tile) + + self.assertEqual(meld.type, Meld.CHI) + self.assertEqual(meld.tiles, [92, 96, 100]) + self.assertEqual(len(player.closed_hand), 5) + self.assertEqual(len(player.tiles), 14) diff --git a/project/mahjong/hand.py b/project/mahjong/hand.py index c1bfc7bf..dab83a0c 100644 --- a/project/mahjong/hand.py +++ b/project/mahjong/hand.py @@ -2,6 +2,7 @@ import math import itertools +import copy from functools import reduce from mahjong.ai.agari import Agari @@ -64,6 +65,8 @@ def estimate_hand_value(self, if not open_sets: open_sets = [] else: + # it is important to work with copy of list + open_sets = copy.deepcopy(open_sets) # cast 136 format to 34 format for item in open_sets: item[0] //= 4 @@ -78,6 +81,8 @@ def estimate_hand_value(self, if not called_kan_indices: called_kan_indices = [] else: + # it is important to work with copy of list + called_kan_indices = copy.deepcopy(called_kan_indices) kan_indices_136 = called_kan_indices called_kan_indices = [x // 4 for x in called_kan_indices] @@ -339,28 +344,6 @@ def return_response(): if is_chitoitsu: fu = 25 - tiles_for_dora = tiles + kan_indices_136 - count_of_dora = 0 - count_of_aka_dora = 0 - for tile in tiles_for_dora: - count_of_dora += plus_dora(tile, dora_indicators) - - for tile in tiles_for_dora: - if is_aka_dora(tile): - count_of_aka_dora += 1 - - if count_of_dora: - yaku_item = yaku.dora - yaku_item.han['open'] = count_of_dora - yaku_item.han['closed'] = count_of_dora - hand_yaku.append(yaku_item) - - if count_of_aka_dora: - yaku_item = yaku.aka_dora - yaku_item.han['open'] = count_of_aka_dora - yaku_item.han['closed'] = count_of_aka_dora - hand_yaku.append(yaku_item) - # yakuman is not connected with other yaku yakuman_list = [x for x in hand_yaku if x.is_yakuman] if yakuman_list: @@ -381,7 +364,36 @@ def return_response(): if han == 0 or (han == 1 and fu < 30): error = 'Not valid han ({0}) and fu ({1})'.format(han, fu) cost = None - else: + # else: + + # we can add dora han only if we have other yaku in hand + # and if we don't have yakuman + if not yakuman_list: + tiles_for_dora = tiles + kan_indices_136 + count_of_dora = 0 + count_of_aka_dora = 0 + for tile in tiles_for_dora: + count_of_dora += plus_dora(tile, dora_indicators) + + for tile in tiles_for_dora: + if is_aka_dora(tile): + count_of_aka_dora += 1 + + if count_of_dora: + yaku_item = yaku.dora + yaku_item.han['open'] = count_of_dora + yaku_item.han['closed'] = count_of_dora + hand_yaku.append(yaku_item) + han += count_of_dora + + if count_of_aka_dora: + yaku_item = yaku.aka_dora + yaku_item.han['open'] = count_of_aka_dora + yaku_item.han['closed'] = count_of_aka_dora + hand_yaku.append(yaku_item) + han += count_of_aka_dora + + if not error: cost = self.calculate_scores(han, fu, is_tsumo, is_dealer) calculated_hand = { diff --git a/project/mahjong/player.py b/project/mahjong/player.py index df8db3ae..fa3b9e9a 100644 --- a/project/mahjong/player.py +++ b/project/mahjong/player.py @@ -5,7 +5,6 @@ from mahjong.constants import EAST, SOUTH, WEST, NORTH from utils.settings_handler import settings from mahjong.ai.shanten import Shanten -from mahjong.tile import Tile logger = logging.getLogger('tenhou') @@ -30,6 +29,7 @@ class Player(object): tiles = [] melds = [] table = None + last_draw = None in_tempai = False in_riichi = False in_defence_mode = False @@ -79,6 +79,7 @@ def erase_state(self): self.tiles = [] self.safe_tiles = [] + self.last_draw = None self.in_tempai = False self.in_riichi = False self.in_defence_mode = False @@ -91,16 +92,21 @@ def add_called_meld(self, meld): self.melds.append(meld) def add_discarded_tile(self, tile): - self.discards.append(Tile(tile)) + self.discards.append(tile) def init_hand(self, tiles): - self.tiles = [Tile(i) for i in tiles] + self.tiles = tiles + + self.ai.determine_strategy() def draw_tile(self, tile): - self.tiles.append(Tile(tile)) + self.last_draw = tile + self.tiles.append(tile) # we need sort it to have a better string presentation self.tiles = sorted(self.tiles) + self.ai.determine_strategy() + def discard_tile(self, tile=None): """ We can say what tile to discard @@ -109,9 +115,11 @@ def discard_tile(self, tile=None): :return: """ tile_to_discard = tile or self.ai.discard_tile() + if tile_to_discard != Shanten.AGARI_STATE: self.add_discarded_tile(tile_to_discard) self.tiles.remove(tile_to_discard) + return tile_to_discard def can_call_riichi(self): @@ -126,13 +134,6 @@ def can_call_riichi(self): ]) def try_to_call_meld(self, tile, enemy_seat): - """ - Determine should we call a meld or not. - If yes, it will add tile to the player's hand and will return Meld object - :param tile: 136 format tile - :param enemy_seat: 1, 2, 3 - :return: meld and tile to discard after called open set - """ return self.ai.try_to_call_meld(tile, enemy_seat) @property @@ -162,3 +163,7 @@ def closed_hand(self): if meld_tiles: meld_tiles = reduce(lambda z, y: z + y, [x.tiles for x in self.melds]) return [item for item in tiles if item not in meld_tiles] + + @property + def meld_tiles(self): + return [x.tiles for x in self.melds][:] diff --git a/project/mahjong/tests/tests_yaku_calculation.py b/project/mahjong/tests/tests_yaku_calculation.py index b4f63924..0220a2cd 100644 --- a/project/mahjong/tests/tests_yaku_calculation.py +++ b/project/mahjong/tests/tests_yaku_calculation.py @@ -1181,10 +1181,19 @@ def test_is_north(self): def test_dora_in_hand(self): hand = FinishedHand() + # hand without yaku, but with dora should be consider as invalid + tiles = self._string_to_136_array(sou='345678', man='456789', honors='55') + win_tile = self._string_to_136_tile(sou='5') + dora_indicators = [self._string_to_136_tile(sou='5')] + open_sets = [self._string_to_136_array(sou='678')] + + result = hand.estimate_hand_value(tiles, win_tile, dora_indicators=dora_indicators, open_sets=open_sets) + self.assertNotEqual(result['error'], None) + tiles = self._string_to_136_array(sou='123456', man='123456', pin='33') win_tile = self._string_to_136_tile(man='6') - dora_indicators = [self._string_to_136_tile(pin='2')] + result = hand.estimate_hand_value(tiles, win_tile, dora_indicators=dora_indicators) self.assertEqual(result['error'], None) self.assertEqual(result['han'], 3) @@ -1194,21 +1203,21 @@ def test_dora_in_hand(self): tiles = self._string_to_136_array(man='22456678', pin='123678') win_tile = self._string_to_136_tile(man='2') dora_indicators = [self._string_to_136_tile(man='1'), self._string_to_136_tile(pin='2')] - result = hand.estimate_hand_value(tiles, win_tile, dora_indicators=dora_indicators) + result = hand.estimate_hand_value(tiles, win_tile, dora_indicators=dora_indicators, is_tsumo=True) self.assertEqual(result['error'], None) - self.assertEqual(result['han'], 3) - self.assertEqual(result['fu'], 40) - self.assertEqual(len(result['hand_yaku']), 1) + self.assertEqual(result['han'], 4) + self.assertEqual(result['fu'], 30) + self.assertEqual(len(result['hand_yaku']), 2) # double dora tiles = self._string_to_136_array(man='678', pin='34577', sou='123345') win_tile = self._string_to_136_tile(pin='7') dora_indicators = [self._string_to_136_tile(sou='4'), self._string_to_136_tile(sou='4')] - result = hand.estimate_hand_value(tiles, win_tile, dora_indicators=dora_indicators) + result = hand.estimate_hand_value(tiles, win_tile, dora_indicators=dora_indicators, is_tsumo=True) self.assertEqual(result['error'], None) - self.assertEqual(result['han'], 2) - self.assertEqual(result['fu'], 40) - self.assertEqual(len(result['hand_yaku']), 1) + self.assertEqual(result['han'], 3) + self.assertEqual(result['fu'], 30) + self.assertEqual(len(result['hand_yaku']), 2) # double dora and honor tiles tiles = self._string_to_136_array(man='678', pin='345', sou='123345', honors='66') @@ -1227,11 +1236,11 @@ def test_dora_in_hand(self): win_tile = self._string_to_136_tile(pin='4') tiles.append(FIVE_RED_SOU) dora_indicators = [self._string_to_136_tile(pin='2'), self._string_to_136_tile(pin='2')] - result = hand.estimate_hand_value(tiles, win_tile, dora_indicators=dora_indicators) + result = hand.estimate_hand_value(tiles, win_tile, dora_indicators=dora_indicators, is_tsumo=True) self.assertEqual(result['error'], None) - self.assertEqual(result['han'], 1) - self.assertEqual(result['fu'], 40) - self.assertEqual(len(result['hand_yaku']), 1) + self.assertEqual(result['han'], 2) + self.assertEqual(result['fu'], 30) + self.assertEqual(len(result['hand_yaku']), 2) settings.FIVE_REDS = False @@ -1241,8 +1250,8 @@ def test_dora_in_hand(self): dora_indicators = [self._string_to_136_tile(man='6')] called_kan_indices = [self._string_to_136_tile(man='7')] result = hand.estimate_hand_value(tiles, win_tile, dora_indicators=dora_indicators, - called_kan_indices=called_kan_indices) + called_kan_indices=called_kan_indices, is_tsumo=True) self.assertEqual(result['error'], None) - self.assertEqual(result['han'], 4) - self.assertEqual(result['fu'], 50) - self.assertEqual(len(result['hand_yaku']), 1) + self.assertEqual(result['han'], 5) + self.assertEqual(result['fu'], 40) + self.assertEqual(len(result['hand_yaku']), 2) diff --git a/project/mahjong/tile.py b/project/mahjong/tile.py index 1ac5a4dd..7362c07d 100644 --- a/project/mahjong/tile.py +++ b/project/mahjong/tile.py @@ -1,19 +1,6 @@ # -*- coding: utf-8 -*- -class Tile(int): - TILES = ''' - 1s 2s 3s 4s 5s 6s 7s 8s 9s - 1p 2p 3p 4p 5p 6p 7p 8p 9p - 1m 2m 3m 4m 5m 6m 7m 8m 9m - ew sw ww nw - wd gd rd - '''.split() - - def as_data(self): - return self.TILES[self // 4] - - class TilesConverter(object): @staticmethod diff --git a/project/mahjong/yaku.py b/project/mahjong/yaku.py index 90a90909..4cb8f228 100644 --- a/project/mahjong/yaku.py +++ b/project/mahjong/yaku.py @@ -9,8 +9,8 @@ def __init__(self, name, open_value, closed_value, is_yakuman=False): self.is_yakuman = is_yakuman def __str__(self): - if self.name == 'Dora': - return 'Dora {}'.format(self.han['open']) + if self.name == 'Dora' or self.name == 'Aka Dora': + return '{} {}'.format(self.name, self.han['open']) else: return self.name diff --git a/project/tenhou/decoder.py b/project/tenhou/decoder.py index b53ab8c3..09d4400b 100644 --- a/project/tenhou/decoder.py +++ b/project/tenhou/decoder.py @@ -4,7 +4,6 @@ from bs4 import BeautifulSoup from mahjong.meld import Meld -from mahjong.tile import Tile class TenhouDecoder(object): @@ -157,7 +156,7 @@ def parse_chi(self, data, meld): base_and_called = data >> 10 base = base_and_called // 3 base = (base // 7) * 9 + base % 7 - meld.tiles = [Tile(t0 + 4 * (base + 0)), Tile(t1 + 4 * (base + 1)), Tile(t2 + 4 * (base + 2))] + meld.tiles = [t0 + 4 * (base + 0), t1 + 4 * (base + 1), t2 + 4 * (base + 2)] def parse_pon(self, data, meld): t4 = (data >> 5) & 0x3 @@ -166,20 +165,20 @@ def parse_pon(self, data, meld): base = base_and_called // 3 if data & 0x8: meld.type = Meld.PON - meld.tiles = [Tile(t0 + 4 * base), Tile(t1 + 4 * base), Tile(t2 + 4 * base)] + meld.tiles = [t0 + 4 * base, t1 + 4 * base, t2 + 4 * base] else: meld.type = Meld.CHAKAN - meld.tiles = [Tile(t0 + 4 * base), Tile(t1 + 4 * base), Tile(t2 + 4 * base), Tile(t4 + 4 * base)] + meld.tiles = [t0 + 4 * base, t1 + 4 * base, t2 + 4 * base, t4 + 4 * base] def parse_kan(self, data, meld): base_and_called = data >> 8 base = base_and_called // 4 meld.type = Meld.KAN - meld.tiles = [Tile(4 * base), Tile(1 + 4 * base), Tile(2 + 4 * base), Tile(3 + 4 * base)] + meld.tiles = [4 * base, 1 + 4 * base, 2 + 4 * base, 3 + 4 * base] def parse_nuki(self, data, meld): meld.type = Meld.NUKI - meld.tiles = [Tile(data >> 8)] + meld.tiles = [data >> 8] def parse_dora_indicator(self, message): soup = BeautifulSoup(message, 'html.parser') diff --git a/project/utils/logger.py b/project/utils/logger.py index f3c795f0..bd370ac8 100644 --- a/project/utils/logger.py +++ b/project/utils/logger.py @@ -10,16 +10,16 @@ def set_up_logging(): """ Main logger for usual bot needs """ + logs_directory = os.path.join(os.path.dirname(os.path.realpath(__file__)), '..', 'logs') + if not os.path.exists(logs_directory): + os.mkdir(logs_directory) + logger = logging.getLogger('tenhou') logger.setLevel(logging.DEBUG) - logs_directory = os.path.join(os.path.dirname(os.path.realpath(__file__)), '..', 'logs') ch = logging.StreamHandler() ch.setLevel(logging.DEBUG) - if not os.path.exists(logs_directory): - os.mkdir(logs_directory) - file_name = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') + '.log' fh = logging.FileHandler(os.path.join(logs_directory, file_name)) fh.setLevel(logging.DEBUG) From 56cf944c1aba12882deb5babee887c66ce98d43e Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Mon, 7 Nov 2016 10:19:12 +0800 Subject: [PATCH 04/80] Try to detect best meld to open hand --- project/bots_battle.py | 2 +- project/game/game_manager.py | 5 ++ project/game/tests.py | 23 +++++++ project/mahjong/ai/strategies/main.py | 96 +++++++++++++++++++++++++-- project/mahjong/ai/tests/tests_ai.py | 22 ++++++ project/mahjong/hand.py | 4 +- 6 files changed, 143 insertions(+), 9 deletions(-) diff --git a/project/bots_battle.py b/project/bots_battle.py index 4db6dba7..92d7459c 100644 --- a/project/bots_battle.py +++ b/project/bots_battle.py @@ -10,7 +10,7 @@ from game.game_manager import GameManager from mahjong.client import Client -TOTAL_HANCHANS = 30 +TOTAL_HANCHANS = 10 def main(): diff --git a/project/game/game_manager.py b/project/game/game_manager.py index 937a57e9..2209f1ff 100644 --- a/project/game/game_manager.py +++ b/project/game/game_manager.py @@ -223,6 +223,11 @@ def play_round(self): current_client = self._get_current_client() self.players_with_open_hands.append(self.current_client_seat) + logger.info('Hand: {} + {}'.format( + TilesConverter.to_one_line_string(current_client.player.tiles), + TilesConverter.to_one_line_string([tile]) + )) + current_client.add_called_meld(meld) current_client.player.tiles.append(tile) diff --git a/project/game/tests.py b/project/game/tests.py index fa0cf6ae..b8c750d8 100644 --- a/project/game/tests.py +++ b/project/game/tests.py @@ -172,6 +172,29 @@ def test_call_riichi(self): self.assertEqual(clients[2].player.in_riichi, False) self.assertEqual(clients[3].player.in_riichi, True) + def test_debug(self): + game.game_manager.shuffle_seed = lambda: 0.5559848260363641 + + # # '33456m456p345s555z' + # tiles_34 = self._string_to_34_array(man='33456', pin='456', sou='345', honors='555') + # open_sets = [self._string_to_open_34_set(man='345'), self._string_to_open_34_set(pin='456')] + # result = hand.divide_hand(tiles_34, open_sets, []) + # self.assertEqual(len(result), 1) + + clients = [Client() for _ in range(0, 4)] + manager = GameManager(clients) + + # clients = [Client(use_previous_ai_version=True) for _ in range(0, 3)] + # clients += [Client(use_previous_ai_version=False)] + # manager = GameManager(clients) + + manager.init_game() + manager.set_dealer(3) + manager._unique_dealers = 1 + manager.init_round() + + manager.play_round() + def test_play_round_and_win_by_tsumo(self): game.game_manager.shuffle_seed = lambda: 0.7662959679647414 diff --git a/project/mahjong/ai/strategies/main.py b/project/mahjong/ai/strategies/main.py index d30ab629..dbc9ef36 100644 --- a/project/mahjong/ai/strategies/main.py +++ b/project/mahjong/ai/strategies/main.py @@ -68,16 +68,37 @@ def try_to_call_meld(self, tile, enemy_seat): closed_hand_34 = TilesConverter.to_34_array(closed_hand + [tile]) combinations = [] + first_index = 0 + second_index = 0 + first_limit = 0 + second_limit = 0 if is_man(discarded_tile): - combinations = self.player.ai.hand_divider.find_valid_combinations(closed_hand_34, 0, 8, True) + first_index = 0 + second_index = 8 elif is_pin(discarded_tile): - combinations = self.player.ai.hand_divider.find_valid_combinations(closed_hand_34, 9, 17, True) + first_index = 9 + second_index = 17 elif is_sou(discarded_tile): - combinations = self.player.ai.hand_divider.find_valid_combinations(closed_hand_34, 18, 26, True) - else: + first_index = 18 + second_index = 26 + + if second_index == 0: # honor tiles if closed_hand_34[discarded_tile] == 3: combinations = [[[discarded_tile] * 3]] + else: + # to avoid not necessary calculations + # we can check only tiles around +-2 discarded tile + first_limit = discarded_tile - 2 + if first_limit < first_index: + first_limit = 0 + second_limit = discarded_tile + 2 + if second_limit > second_index: + second_limit = second_index + + combinations = self.player.ai.hand_divider.find_valid_combinations(closed_hand_34, + first_limit, + second_limit, True) if combinations: combinations = combinations[0] @@ -95,8 +116,7 @@ def try_to_call_meld(self, tile, enemy_seat): possible_melds.append(combination) if len(possible_melds): - # TODO add logic to find best meld - combination = possible_melds[0] + combination = self._find_best_meld_to_open(possible_melds, closed_hand_34, first_limit, second_limit) meld_type = is_chi(combination) and Meld.CHI or Meld.PON combination.remove(discarded_tile) @@ -130,3 +150,67 @@ def try_to_call_meld(self, tile, enemy_seat): return meld, tile_to_discard return None, None + + def _find_best_meld_to_open(self, possible_melds, closed_hand_34, first_limit, second_limit): + """ + For now best meld will be the meld with higher count of remaining sets in the hand + :param possible_melds: + :param closed_hand_34: + :param first_limit: + :param second_limit: + :return: + """ + + if len(possible_melds) == 1: + return possible_melds[0] + + best_meld = None + best_option = -2 + + for combination in possible_melds: + remaining_hand = [] + local_closed_hand_34 = closed_hand_34[:] + + # remove combination from hand and let's see what we will hand in the end + local_closed_hand_34[combination[0]] -= 1 + local_closed_hand_34[combination[1]] -= 1 + local_closed_hand_34[combination[2]] -= 1 + + pair_indices = self.player.ai.hand_divider.find_pairs(local_closed_hand_34, + first_limit, + second_limit) + + if pair_indices: + for pair_index in pair_indices: + pair_34 = local_closed_hand_34[:] + pair_34[pair_index] -= 2 + + hand = [[[pair_index] * 2]] + + pair_combinations = self.player.ai.hand_divider.find_valid_combinations(pair_34, + first_limit, + second_limit, True) + if pair_combinations: + hand.append(pair_combinations) + + remaining_hand.append(hand) + + local_combinations = self.player.ai.hand_divider.find_valid_combinations(local_closed_hand_34, + first_limit, + second_limit, True) + + if local_combinations: + for pair_index in pair_indices: + local_combinations.append([[pair_index] * 2]) + remaining_hand.append(local_combinations) + + most_long_hand = -1 + for item in remaining_hand: + if len(item) > most_long_hand: + most_long_hand = len(item) + + if most_long_hand > best_option: + best_option = most_long_hand + best_meld = combination + + return best_meld diff --git a/project/mahjong/ai/tests/tests_ai.py b/project/mahjong/ai/tests/tests_ai.py index 6eabea6b..135e7bb9 100644 --- a/project/mahjong/ai/tests/tests_ai.py +++ b/project/mahjong/ai/tests/tests_ai.py @@ -121,3 +121,25 @@ def test_not_open_hand_in_riichi(self): player.init_hand(tiles) meld, _ = player.try_to_call_meld(tile, 3) self.assertEqual(meld, None) + + def test_chose_right_set_to_open_hand(self): + table = Table() + player = Player(0, 0, table) + + tiles = self._string_to_136_array(man='335688', pin='22', sou='345', honors='55') + tile = self._string_to_136_tile(man='4') + player.init_hand(tiles) + meld, _ = player.try_to_call_meld(tile, 3) + self.assertNotEqual(meld, None) + self.assertEqual(meld.type, Meld.CHI) + # we should open hand with 456m, not with 345m + self.assertEqual(meld.tiles, [12, 16, 20]) + + tiles = self._string_to_136_array(man='335666', pin='22', sou='345', honors='55') + tile = self._string_to_136_tile(man='4') + player.init_hand(tiles) + meld, _ = player.try_to_call_meld(tile, 3) + self.assertNotEqual(meld, None) + self.assertEqual(meld.type, Meld.CHI) + # we should open hand with 345m, not with 456m + self.assertEqual(meld.tiles, [8, 12, 16]) diff --git a/project/mahjong/hand.py b/project/mahjong/hand.py index dab83a0c..ba0cbc6b 100644 --- a/project/mahjong/hand.py +++ b/project/mahjong/hand.py @@ -1326,13 +1326,13 @@ def divide_hand(self, tiles_34, open_sets, called_kan_indices): return hands - def find_pairs(self, tiles_34): + def find_pairs(self, tiles_34, first_index=0, second_index=33): """ Find all possible pairs in the hand and return their indices :return: array of pair indices """ pair_indices = [] - for x in range(0, 34): + for x in range(first_index, second_index + 1): # ignore pon of honor tiles, because it can't be a part of pair if x in HONOR_INDICES and tiles_34[x] != 2: continue From 57b865093918449248c68c61ea5e2932745307de Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Mon, 7 Nov 2016 22:54:06 +0800 Subject: [PATCH 05/80] Add honitsu strategy --- project/game/game_manager.py | 6 +- project/game/tests.py | 27 +------- project/mahjong/ai/main.py | 30 ++++++++- project/mahjong/ai/strategies/honitsu.py | 51 +++++++++++++++ project/mahjong/ai/strategies/main.py | 1 + project/mahjong/ai/tests/tests_ai.py | 19 +++--- project/mahjong/ai/tests/tests_strategies.py | 66 +++++++++++++++++++- 7 files changed, 161 insertions(+), 39 deletions(-) create mode 100644 project/mahjong/ai/strategies/honitsu.py diff --git a/project/game/game_manager.py b/project/game/game_manager.py index 2209f1ff..72524f95 100644 --- a/project/game/game_manager.py +++ b/project/game/game_manager.py @@ -238,7 +238,11 @@ def play_round(self): raise ValueError("We can't discard a tile from the opened part of the hand") current_client.discard_tile(tile_to_discard) - self.check_clients_possible_ron(current_client, tile_to_discard) + + # the end of the round + result = self.check_clients_possible_ron(current_client, tile_to_discard) + if result: + return result else: self.current_client_seat = self._move_position(self.current_client_seat) diff --git a/project/game/tests.py b/project/game/tests.py index b8c750d8..33591047 100644 --- a/project/game/tests.py +++ b/project/game/tests.py @@ -172,31 +172,8 @@ def test_call_riichi(self): self.assertEqual(clients[2].player.in_riichi, False) self.assertEqual(clients[3].player.in_riichi, True) - def test_debug(self): - game.game_manager.shuffle_seed = lambda: 0.5559848260363641 - - # # '33456m456p345s555z' - # tiles_34 = self._string_to_34_array(man='33456', pin='456', sou='345', honors='555') - # open_sets = [self._string_to_open_34_set(man='345'), self._string_to_open_34_set(pin='456')] - # result = hand.divide_hand(tiles_34, open_sets, []) - # self.assertEqual(len(result), 1) - - clients = [Client() for _ in range(0, 4)] - manager = GameManager(clients) - - # clients = [Client(use_previous_ai_version=True) for _ in range(0, 3)] - # clients += [Client(use_previous_ai_version=False)] - # manager = GameManager(clients) - - manager.init_game() - manager.set_dealer(3) - manager._unique_dealers = 1 - manager.init_round() - - manager.play_round() - def test_play_round_and_win_by_tsumo(self): - game.game_manager.shuffle_seed = lambda: 0.7662959679647414 + game.game_manager.shuffle_seed = lambda: 0.3060278776465999 clients = [Client() for _ in range(0, 4)] manager = GameManager(clients) @@ -257,7 +234,7 @@ def test_play_round_and_open_yakuhai_hand(self): result = manager.play_round() - self.assertEqual(len(result['players_with_open_hands']), 1) + self.assertEqual(len(result['players_with_open_hands']), 4) def test_scores_calculations_after_retake(self): clients = [Client() for _ in range(0, 4)] diff --git a/project/mahjong/ai/main.py b/project/mahjong/ai/main.py index 14a8f330..b16afa33 100644 --- a/project/mahjong/ai/main.py +++ b/project/mahjong/ai/main.py @@ -3,6 +3,7 @@ from mahjong.ai.base import BaseAI from mahjong.ai.defence import Defence from mahjong.ai.shanten import Shanten +from mahjong.ai.strategies.honitsu import HonitsuStrategy from mahjong.ai.strategies.main import BaseStrategy from mahjong.ai.strategies.yakuhai import YakuhaiStrategy from mahjong.constants import HAKU, CHUN, HATSU @@ -115,6 +116,22 @@ def calculate_outs(self, tiles, closed_hand): 'waiting': raw_data[i] } + # in honitsu mode we should discard tiles from other suit, even if it is better to save them + if self.current_strategy and self.current_strategy.type == BaseStrategy.HONITSU: + for i in range(0, 34): + if not tiles_34[i]: + continue + + if not closed_tiles_34[i]: + continue + + if not self.current_strategy.is_tile_suitable(i * 4): + raw_data[i] = { + 'tile': i, + 'tiles_count': 1, + 'waiting': [] + } + results = [] tiles_34 = TilesConverter.to_34_array(self.player.tiles) for tile in range(0, len(tiles_34)): @@ -136,6 +153,10 @@ def calculate_outs(self, tiles, closed_hand): # we need to discard honor tile first results = sorted(results, key=lambda x: (x['tiles_count'], x['discard']), reverse=True) + # in honitsu mode we should discard tiles from other suit, even if it is better to save them + if self.current_strategy and self.current_strategy.type == BaseStrategy.HONITSU: + results = sorted(results, key=lambda x: self.current_strategy.is_tile_suitable(x['discard'] * 4), reverse=False) + return results, shanten def count_tiles(self, raw_data, tiles): @@ -154,12 +175,17 @@ def determine_strategy(self): if self.current_strategy: return False - strategies = [YakuhaiStrategy(BaseStrategy.YAKUHAI, self.player)] + # order is important + strategies = [ + YakuhaiStrategy(BaseStrategy.YAKUHAI, self.player), + HonitsuStrategy(BaseStrategy.HONITSU, self.player), + ] for strategy in strategies: if strategy.should_activate_strategy(): self.current_strategy = strategy - return True + + return self.current_strategy and True or False @property def valued_honors(self): diff --git a/project/mahjong/ai/strategies/honitsu.py b/project/mahjong/ai/strategies/honitsu.py new file mode 100644 index 00000000..aad23e4f --- /dev/null +++ b/project/mahjong/ai/strategies/honitsu.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +from mahjong.ai.strategies.main import BaseStrategy +from mahjong.tile import TilesConverter +from mahjong.utils import is_sou, is_pin, is_man, is_honor + + +class HonitsuStrategy(BaseStrategy): + chosen_suit = None + + def should_activate_strategy(self): + """ + We can go for honitsu/chinitsu strategy if we have prevalence of one suit and honor tiles + :return: boolean + """ + + suits = [ + {'count': 0, 'name': 'sou', 'function': is_sou}, + {'count': 0, 'name': 'man', 'function': is_man}, + {'count': 0, 'name': 'pin', 'function': is_pin}, + {'count': 0, 'name': 'honor', 'function': is_honor} + ] + + tiles = TilesConverter.to_34_array(self.player.tiles) + for x in range(0, 34): + tile = tiles[x] + if not tile: + continue + + for item in suits: + if item['function'](x): + item['count'] += tile + + 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] + + if suit['count'] + honor['count'] >= 9: + self.chosen_suit = suit['function'] + return True + else: + return False + + def is_tile_suitable(self, tile): + """ + We can use only tiles of chosen suit and honor tiles + :param tile: 136 tiles format + :return: True + """ + return self.chosen_suit(tile // 4) or is_honor(tile // 4) diff --git a/project/mahjong/ai/strategies/main.py b/project/mahjong/ai/strategies/main.py index dbc9ef36..7178a592 100644 --- a/project/mahjong/ai/strategies/main.py +++ b/project/mahjong/ai/strategies/main.py @@ -6,6 +6,7 @@ class BaseStrategy(object): YAKUHAI = 0 + HONITSU = 1 player = None type = None diff --git a/project/mahjong/ai/tests/tests_ai.py b/project/mahjong/ai/tests/tests_ai.py index 135e7bb9..067a3470 100644 --- a/project/mahjong/ai/tests/tests_ai.py +++ b/project/mahjong/ai/tests/tests_ai.py @@ -50,26 +50,27 @@ def test_discard_tile(self): table = Table() player = Player(0, 0, table) - tiles = self._string_to_136_array(sou='111345677', pin='15', man='56') - tile = self._string_to_136_array(man='9')[0] + tiles = self._string_to_136_array(sou='11134567', pin='159', man='45') + tile = self._string_to_136_tile(man='9') player.init_hand(tiles) player.draw_tile(tile) discarded_tile = player.discard_tile() - self.assertEqual(discarded_tile, 36) - - player.draw_tile(self._string_to_136_array(pin='4')[0]) + self.assertEqual(discarded_tile, 68) + player.draw_tile(self._string_to_136_tile(pin='4')) discarded_tile = player.discard_tile() - self.assertEqual(discarded_tile, 92) - - player.draw_tile(self._string_to_136_array(pin='3')[0]) + self.assertEqual(discarded_tile, 36) + player.draw_tile(self._string_to_136_tile(pin='3')) discarded_tile = player.discard_tile() self.assertEqual(discarded_tile, 32) - player.draw_tile(self._string_to_136_array(man='4')[0]) + player.draw_tile(self._string_to_136_tile(man='4')) + discarded_tile = player.discard_tile() + self.assertEqual(discarded_tile, 16) + player.draw_tile(self._string_to_136_tile(sou='8')) discarded_tile = player.discard_tile() self.assertEqual(discarded_tile, Shanten.AGARI_STATE) diff --git a/project/mahjong/ai/tests/tests_strategies.py b/project/mahjong/ai/tests/tests_strategies.py index 14bb4c7c..39ff3e06 100644 --- a/project/mahjong/ai/tests/tests_strategies.py +++ b/project/mahjong/ai/tests/tests_strategies.py @@ -1,8 +1,7 @@ # -*- coding: utf-8 -*- import unittest -from mahjong.ai.main import MainAI -from mahjong.ai.shanten import Shanten +from mahjong.ai.strategies.honitsu import HonitsuStrategy from mahjong.ai.strategies.main import BaseStrategy from mahjong.ai.strategies.yakuhai import YakuhaiStrategy from mahjong.meld import Meld @@ -94,3 +93,66 @@ def test_open_hand_with_yakuhai_pair_in_hand(self): self.assertEqual(meld.tiles, [92, 96, 100]) self.assertEqual(len(player.closed_hand), 5) self.assertEqual(len(player.tiles), 14) + + +class HonitsuStrategyTestCase(unittest.TestCase, TestMixin): + + def test_should_activate_strategy(self): + table = Table() + player = Player(0, 0, table) + 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='2389', honors='1123') + player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(), True) + + def test_suitable_tiles(self): + table = Table() + player = Player(0, 0, table) + strategy = HonitsuStrategy(BaseStrategy.HONITSU, player) + + tiles = self._string_to_136_array(sou='12355', man='2389', honors='1123') + 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 = Player(0, 0, table) + + 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 collecting honitsu + tile = self._string_to_136_tile(man='1') + meld, _ = player.try_to_call_meld(tile, 3) + self.assertEqual(meld, None) + + # any honor tile is suitable + tile = self._string_to_136_tile(honors='2') + meld, _ = player.try_to_call_meld(tile, 3) + 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 + # 8 == 3m + self.assertEqual(tile_to_discard, 8) From 888e4971dba2d5c9e90d93c083041e4809febf9e Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Fri, 11 Nov 2016 11:03:58 +0800 Subject: [PATCH 06/80] Allow to save replays from bots battles --- .gitignore | 2 +- project/game/game_manager.py | 46 +++++++++++++++---- project/game/replay.py | 87 ++++++++++++++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 9 deletions(-) create mode 100644 project/game/replay.py diff --git a/.gitignore b/.gitignore index b45f3981..becf592d 100644 --- a/.gitignore +++ b/.gitignore @@ -14,7 +14,7 @@ loader.py temp *.log -replays/data/* +project/game/data/* # temporary files experiments \ No newline at end of file diff --git a/project/game/game_manager.py b/project/game/game_manager.py index 72524f95..8345dd29 100644 --- a/project/game/game_manager.py +++ b/project/game/game_manager.py @@ -5,6 +5,7 @@ from random import randint, shuffle, random from game.logger import set_up_logging +from game.replay import Replay from mahjong.ai.agari import Agari from mahjong.client import Client from mahjong.hand import FinishedHand @@ -44,6 +45,7 @@ class GameManager(object): _unique_dealers = 0 def __init__(self, clients): + self.tiles = [] self.dead_wall = [] self.dora_indicators = [] @@ -52,11 +54,12 @@ def __init__(self, clients): self.agari = Agari() self.finished_hand = FinishedHand() + self.replay = Replay() def init_game(self): """ - Initial of the game. - Clients random placement and dealer selection + Beginning of the game. + Clients random placement and dealer selection. """ shuffle(self.clients, shuffle_seed) for i in range(0, len(self.clients)): @@ -71,6 +74,10 @@ def init_game(self): self._unique_dealers = 1 def init_round(self): + """ + Generate players hands, dead wall and dora indicators + """ + # each round should have personal seed global seed_value seed_value = random() @@ -129,6 +136,8 @@ def init_round(self): )) logger.info('Players: {0}'.format(self.players_sorted_by_scores())) + self.replay.init_round(self.clients, seed_value, self.dora_indicators[:], self.dealer) + def play_round(self): continue_to_play = True @@ -137,6 +146,7 @@ def play_round(self): in_tempai = current_client.player.in_tempai tile = self._cut_tiles(1)[0] + self.replay.draw(current_client.seat, tile) # we don't need to add tile to the hand when we are in riichi if current_client.player.in_riichi: @@ -184,6 +194,10 @@ def play_round(self): tile = current_client.discard_tile() in_tempai = current_client.player.in_tempai + if in_tempai and current_client.player.can_call_riichi(): + self.replay.riichi(current_client.seat, 1) + + self.replay.discard(current_client.seat, tile) result = self.check_clients_possible_ron(current_client, tile) # the end of the round if result: @@ -192,6 +206,7 @@ def play_round(self): # if there is no challenger to ron, let's check can we call riichi with tile discard or not if in_tempai and current_client.player.can_call_riichi(): self.call_riichi(current_client) + self.replay.riichi(current_client.seat, 2) # let's check other players hand to possibility open sets possible_melds = [] @@ -232,12 +247,14 @@ def play_round(self): current_client.player.tiles.append(tile) logger.info('Called meld: {} by {}'.format(meld, current_client.player.name)) + self.replay.open_meld(current_client.seat, meld.type, meld.tiles) # we need to double validate that we are doing fine if tile_to_discard not in current_client.player.closed_hand: raise ValueError("We can't discard a tile from the opened part of the hand") current_client.discard_tile(tile_to_discard) + self.replay.discard(current_client.seat, tile) # the end of the round result = self.check_clients_possible_ron(current_client, tile_to_discard) @@ -291,6 +308,7 @@ def play_game(self, total_results): is_game_end = False self.init_game() + self.replay.init_game() played_rounds = 0 @@ -317,6 +335,7 @@ def play_game(self, total_results): played_rounds += 1 self.recalculate_players_position() + self.replay.end_game(self.clients) logger.info('Final Scores: {0}'.format(self.players_sorted_by_scores())) logger.info('The end of the game') @@ -431,6 +450,14 @@ def process_the_end_of_the_round(self, tiles, win_tile, winner, loser, is_tsumo) logger.info('Dora indicators: {}'.format(TilesConverter.to_one_line_string(self.dora_indicators))) logger.info('Hand yaku: {}'.format(', '.join(str(x) for x in hand_value['hand_yaku']))) + self.replay.win(winner.seat, + loser and loser.seat or winner.seat, + hand_value['han'], + hand_value['fu'], + hand_value['cost'], + ', '.join(str(x) for x in hand_value['hand_yaku']) + ) + riichi_bonus = self.riichi_sticks * 1000 self.riichi_sticks = 0 honba_bonus = self.honba_sticks * 300 @@ -474,23 +501,24 @@ def process_the_end_of_the_round(self, tiles, win_tile, winner, loser, is_tsumo) # retake else: - tempai_users = 0 + tempai_users = [] for client in self.clients: if client.player.in_tempai: - tempai_users += 1 + tempai_users.append(client.seat) - if tempai_users == 0 or tempai_users == 4: + tempai_users_count = len(tempai_users) + if tempai_users_count == 0 or tempai_users_count == 4: self.honba_sticks += 1 # no one in tempai, so deal should move - if tempai_users == 0: + if tempai_users_count == 0: new_dealer = self._move_position(self.dealer) self.set_dealer(new_dealer) else: # 1 tempai user will get 3000 # 2 tempai users will get 1500 each # 3 tempai users will get 1000 each - scores_to_pay = 3000 / tempai_users + scores_to_pay = 3000 / tempai_users_count for client in self.clients: if client.player.in_tempai: client.player.scores += scores_to_pay @@ -500,7 +528,9 @@ def process_the_end_of_the_round(self, tiles, win_tile, winner, loser, is_tsumo) if client.player.is_dealer: self.honba_sticks += 1 else: - client.player.scores -= 3000 / (4 - tempai_users) + client.player.scores -= 3000 / (4 - tempai_users_count) + + self.replay.retake(tempai_users) # if someone has negative scores, # we need to end the game diff --git a/project/game/replay.py b/project/game/replay.py new file mode 100644 index 00000000..8379b033 --- /dev/null +++ b/project/game/replay.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +import json + +import os +import time + +replays_directory = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'data') +if not os.path.exists(replays_directory): + os.mkdir(replays_directory) + + +class Replay(object): + replay_name = '' + tags = [] + + def init_game(self): + self.tags = [] + self.replay_name = '{}.json'.format(int(time.time())) + + def end_game(self, clients): + self.tags.append({ + 'tag': 'end', + 'scores': [x.player.scores for x in clients] + }) + + with open(os.path.join(replays_directory, self.replay_name), 'w') as f: + f.write(json.dumps(self.tags)) + + def init_round(self, clients, seed, dora_indicators, dealer): + self.tags.append({ + 'tag': 'init', + 'seed': seed, + 'dora': dora_indicators, + 'dealer': dealer, + 'players': [{ + 'seat': x.seat, + 'name': x.player.name, + 'scores': x.player.scores, + 'tiles': x.player.tiles + } for x in clients] + }) + + def draw(self, who, tile): + self.tags.append({ + 'tag': 'draw', + 'who': who, + 'tile': tile + }) + + def discard(self, who, tile): + self.tags.append({ + 'tag': 'discard', + 'who': who, + 'tile': tile + }) + + def riichi(self, who, step): + self.tags.append({ + 'tag': 'riichi', + 'who': who, + 'step': step + }) + + def open_meld(self, who, meld_type, tiles): + self.tags.append({ + 'tag': 'meld', + 'who': who, + 'type': meld_type, + 'tiles': tiles + }) + + def retake(self, tempai_players): + self.tags.append({ + 'tag': 'retake', + 'tempai_players': tempai_players, + }) + + def win(self, who, from_who, han, fu, scores, yaku): + self.tags.append({ + 'tag': 'win', + 'who': who, + 'from_who': from_who, + 'han': han, + 'fu': fu, + 'scores': scores, + 'yaku': yaku, + }) From ab25f2d36ebb5c24859125b50bd1dbe2d821880c Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Sun, 11 Dec 2016 13:40:54 +0700 Subject: [PATCH 07/80] We need to move player position in any case --- project/game/game_manager.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/project/game/game_manager.py b/project/game/game_manager.py index 8345dd29..54660f99 100644 --- a/project/game/game_manager.py +++ b/project/game/game_manager.py @@ -260,8 +260,8 @@ def play_round(self): result = self.check_clients_possible_ron(current_client, tile_to_discard) if result: return result - else: - self.current_client_seat = self._move_position(self.current_client_seat) + + self.current_client_seat = self._move_position(self.current_client_seat) # retake if not len(self.tiles): From 46cf2fd17f83dcd2bbb536b6e2005ea692eb3b30 Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Sun, 11 Dec 2016 14:03:03 +0700 Subject: [PATCH 08/80] Improve honitsu strategy --- project/game/tests.py | 4 ++-- project/mahjong/ai/strategies/honitsu.py | 10 ++++++++-- project/mahjong/ai/tests/tests_strategies.py | 10 ++++++++-- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/project/game/tests.py b/project/game/tests.py index 33591047..8f32e286 100644 --- a/project/game/tests.py +++ b/project/game/tests.py @@ -224,7 +224,7 @@ def test_play_round_with_retake(self): self.assertEqual(result['loser'], None) def test_play_round_and_open_yakuhai_hand(self): - game.game_manager.shuffle_seed = lambda: 0.8204258359989736 + game.game_manager.shuffle_seed = lambda: 0.457500580104948 clients = [Client() for _ in range(0, 4)] manager = GameManager(clients) @@ -234,7 +234,7 @@ def test_play_round_and_open_yakuhai_hand(self): result = manager.play_round() - self.assertEqual(len(result['players_with_open_hands']), 4) + self.assertEqual(len(result['players_with_open_hands']), 1) def test_scores_calculations_after_retake(self): clients = [Client() for _ in range(0, 4)] diff --git a/project/mahjong/ai/strategies/honitsu.py b/project/mahjong/ai/strategies/honitsu.py index aad23e4f..f518d884 100644 --- a/project/mahjong/ai/strategies/honitsu.py +++ b/project/mahjong/ai/strategies/honitsu.py @@ -5,6 +5,8 @@ class HonitsuStrategy(BaseStrategy): + REQUIRED_TILES = 10 + chosen_suit = None def should_activate_strategy(self): @@ -35,10 +37,14 @@ def should_activate_strategy(self): 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[x] >= 2: + count_of_pairs += 1 - if suit['count'] + honor['count'] >= 9: + if suit['count'] + honor['count'] >= HonitsuStrategy.REQUIRED_TILES: self.chosen_suit = suit['function'] - return True + return count_of_pairs > 0 else: return False diff --git a/project/mahjong/ai/tests/tests_strategies.py b/project/mahjong/ai/tests/tests_strategies.py index 39ff3e06..1282f0bf 100644 --- a/project/mahjong/ai/tests/tests_strategies.py +++ b/project/mahjong/ai/tests/tests_strategies.py @@ -106,16 +106,22 @@ def test_should_activate_strategy(self): player.init_hand(tiles) self.assertEqual(strategy.should_activate_strategy(), False) - tiles = self._string_to_136_array(sou='12355', man='2389', honors='1123') + 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) + def test_suitable_tiles(self): table = Table() player = Player(0, 0, table) strategy = HonitsuStrategy(BaseStrategy.HONITSU, player) - tiles = self._string_to_136_array(sou='12355', man='2389', honors='1123') + tiles = self._string_to_136_array(sou='12355', man='238', honors='11234') player.init_hand(tiles) self.assertEqual(strategy.should_activate_strategy(), True) From ac87a7968ff13c2c324c3171b2227d1114cfd1f5 Mon Sep 17 00:00:00 2001 From: Nihisil Date: Wed, 28 Dec 2016 13:02:52 +0700 Subject: [PATCH 09/80] Make file names to be valid on Windows platform --- project/game/logger.py | 2 +- project/utils/logger.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/project/game/logger.py b/project/game/logger.py index f788dd05..50a8a0c6 100644 --- a/project/game/logger.py +++ b/project/game/logger.py @@ -16,7 +16,7 @@ def set_up_logging(): ch = logging.StreamHandler() ch.setLevel(logging.DEBUG) - file_name = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') + '.log' + file_name = datetime.datetime.now().strftime('%Y-%m-%d %H_%M_%S') + '.log' fh = logging.FileHandler(os.path.join(logs_directory, file_name)) fh.setLevel(logging.DEBUG) diff --git a/project/utils/logger.py b/project/utils/logger.py index bd370ac8..d426d144 100644 --- a/project/utils/logger.py +++ b/project/utils/logger.py @@ -20,7 +20,7 @@ def set_up_logging(): ch = logging.StreamHandler() ch.setLevel(logging.DEBUG) - file_name = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') + '.log' + file_name = datetime.datetime.now().strftime('%Y-%m-%d %H_%M_%S') + '.log' fh = logging.FileHandler(os.path.join(logs_directory, file_name)) fh.setLevel(logging.DEBUG) From b19d0a196e115c70ee54dc152efcd4996e97fa39 Mon Sep 17 00:00:00 2001 From: Nihisil Date: Thu, 29 Dec 2016 00:32:32 +0700 Subject: [PATCH 10/80] Base support of tanyao strategy --- project/game/tests.py | 18 ++- project/mahjong/ai/main.py | 2 + project/mahjong/ai/strategies/honitsu.py | 6 +- project/mahjong/ai/strategies/main.py | 11 +- project/mahjong/ai/strategies/tanyao.py | 66 +++++++++++ project/mahjong/ai/tests/tests_strategies.py | 114 +++++++++++++++++++ 6 files changed, 211 insertions(+), 6 deletions(-) create mode 100644 project/mahjong/ai/strategies/tanyao.py diff --git a/project/game/tests.py b/project/game/tests.py index 8f32e286..f2bf5335 100644 --- a/project/game/tests.py +++ b/project/game/tests.py @@ -14,6 +14,18 @@ def setUp(self): logger = logging.getLogger('game') logger.disabled = False + # def test_debug(self): + # game.game_manager.shuffle_seed = lambda: 0.6268116629798297 + # + # clients = [Client() for _ in range(0, 4)] + # manager = GameManager(clients) + # manager.init_game() + # manager.set_dealer(2) + # manager._unique_dealers = 2 + # manager.init_round() + # + # result = manager.play_round() + def test_init_game(self): clients = [Client() for _ in range(0, 4)] manager = GameManager(clients) @@ -173,7 +185,7 @@ def test_call_riichi(self): self.assertEqual(clients[3].player.in_riichi, True) def test_play_round_and_win_by_tsumo(self): - game.game_manager.shuffle_seed = lambda: 0.3060278776465999 + game.game_manager.shuffle_seed = lambda: 0.6718028503751606 clients = [Client() for _ in range(0, 4)] manager = GameManager(clients) @@ -207,7 +219,7 @@ def test_play_round_and_win_by_ron(self): self.assertNotEqual(result['loser'], None) def test_play_round_with_retake(self): - game.game_manager.shuffle_seed = lambda: 0.01 + game.game_manager.shuffle_seed = lambda: 0.5859797343777 clients = [Client() for _ in range(0, 4)] manager = GameManager(clients) @@ -234,7 +246,7 @@ def test_play_round_and_open_yakuhai_hand(self): result = manager.play_round() - self.assertEqual(len(result['players_with_open_hands']), 1) + self.assertEqual(len(result['players_with_open_hands']), 3) def test_scores_calculations_after_retake(self): clients = [Client() for _ in range(0, 4)] diff --git a/project/mahjong/ai/main.py b/project/mahjong/ai/main.py index b16afa33..4c33a477 100644 --- a/project/mahjong/ai/main.py +++ b/project/mahjong/ai/main.py @@ -5,6 +5,7 @@ from mahjong.ai.shanten import Shanten from mahjong.ai.strategies.honitsu import HonitsuStrategy from mahjong.ai.strategies.main import BaseStrategy +from mahjong.ai.strategies.tanyao import TanyaoStrategy from mahjong.ai.strategies.yakuhai import YakuhaiStrategy from mahjong.constants import HAKU, CHUN, HATSU from mahjong.hand import HandDivider @@ -179,6 +180,7 @@ def determine_strategy(self): strategies = [ YakuhaiStrategy(BaseStrategy.YAKUHAI, self.player), HonitsuStrategy(BaseStrategy.HONITSU, self.player), + TanyaoStrategy(BaseStrategy.TANYAO, self.player), ] for strategy in strategies: diff --git a/project/mahjong/ai/strategies/honitsu.py b/project/mahjong/ai/strategies/honitsu.py index f518d884..c8d8ab62 100644 --- a/project/mahjong/ai/strategies/honitsu.py +++ b/project/mahjong/ai/strategies/honitsu.py @@ -42,6 +42,9 @@ def should_activate_strategy(self): if tiles[x] >= 2: count_of_pairs += 1 + # 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 @@ -54,4 +57,5 @@ def is_tile_suitable(self, tile): :param tile: 136 tiles format :return: True """ - return self.chosen_suit(tile // 4) or is_honor(tile // 4) + tile //= 4 + return self.chosen_suit(tile) or is_honor(tile) diff --git a/project/mahjong/ai/strategies/main.py b/project/mahjong/ai/strategies/main.py index 7178a592..0fd27fc3 100644 --- a/project/mahjong/ai/strategies/main.py +++ b/project/mahjong/ai/strategies/main.py @@ -7,12 +7,15 @@ class BaseStrategy(object): YAKUHAI = 0 HONITSU = 1 + TANYAO = 2 player = None type = None + # number of shanten where we can start to open hand + min_shanten = 7 - def __init__(self, type, player): - self.type = type + def __init__(self, strategy_type, player): + self.type = strategy_type self.player = player def should_activate_strategy(self): @@ -59,6 +62,10 @@ def try_to_call_meld(self, tile, enemy_seat): new_tiles = self.player.tiles[:] + [tile] outs_results, shanten = self.player.ai.calculate_outs(new_tiles, closed_hand) + # each strategy can use their own value to min shanten number + if shanten > self.min_shanten: + return None, None + # we can't improve hand, so we don't need to open it if not outs_results: return None, None diff --git a/project/mahjong/ai/strategies/tanyao.py b/project/mahjong/ai/strategies/tanyao.py new file mode 100644 index 00000000..a8930c3b --- /dev/null +++ b/project/mahjong/ai/strategies/tanyao.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +from mahjong.ai.strategies.main import BaseStrategy +from mahjong.constants import TERMINAL_INDICES, HONOR_INDICES +from mahjong.tile import TilesConverter +from mahjong.utils import is_sou, is_pin, is_man, is_honor + + +class TanyaoStrategy(BaseStrategy): + min_shanten = 3 + not_suitable_tiles = TERMINAL_INDICES + HONOR_INDICES + + def should_activate_strategy(self): + """ + Tanyao hand is a hand without terminal and honor tiles, to achieve this + we will use different approaches + :return: boolean + """ + + tiles = TilesConverter.to_34_array(self.player.tiles) + count_of_terminal_pon_sets = 0 + count_of_terminal_pairs = 0 + for x in range(0, 34): + tile = tiles[x] + if not tile: + continue + + if x in self.not_suitable_tiles and tile >= 3: + count_of_terminal_pon_sets += 1 + + if x in self.not_suitable_tiles and tile >= 2: + count_of_terminal_pairs += 1 + + # if we already have pon of honor\terminal tiles + # we don't need to open hand for tanyao + if count_of_terminal_pon_sets > 0: + return False + + # one pair is ok in tanyao pair + # but 2+ pairs can't be suitable + if count_of_terminal_pairs > 1: + return False + + # 123 and 789 indices + indices = [ + [0, 1, 2], [6, 7, 8], + [9, 10, 11], [15, 16, 17], + [18, 19, 20], [24, 25, 26] + ] + + for index_set in indices: + first = tiles[index_set[0]] + second = tiles[index_set[1]] + third = tiles[index_set[2]] + if first >= 1 and second >= 1 and third >= 1: + return False + + return True + + def is_tile_suitable(self, tile): + """ + We can use only simples tiles (2-8) in any suit + :param tile: 136 tiles format + :return: True + """ + tile //= 4 + return tile not in self.not_suitable_tiles diff --git a/project/mahjong/ai/tests/tests_strategies.py b/project/mahjong/ai/tests/tests_strategies.py index 1282f0bf..a3a384da 100644 --- a/project/mahjong/ai/tests/tests_strategies.py +++ b/project/mahjong/ai/tests/tests_strategies.py @@ -3,6 +3,7 @@ from mahjong.ai.strategies.honitsu import HonitsuStrategy from mahjong.ai.strategies.main import BaseStrategy +from mahjong.ai.strategies.tanyao import TanyaoStrategy from mahjong.ai.strategies.yakuhai import YakuhaiStrategy from mahjong.meld import Meld from mahjong.player import Player @@ -162,3 +163,116 @@ def test_open_hand_and_discard_tiles_logic(self): # we are in honitsu mode, so we should discard man suits # 8 == 3m self.assertEqual(tile_to_discard, 8) + + +class TanyaoStrategyTestCase(unittest.TestCase, TestMixin): + + def test_should_activate_strategy_and_terminal_pon_sets(self): + table = Table() + player = Player(0, 0, table) + 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 = Table() + player = Player(0, 0, table) + 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_already_completed_sided_set(self): + table = Table() + player = Player(0, 0, table) + 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 = Table() + player = Player(0, 0, table) + 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 = Table() + player = Player(0, 0, table) + + # 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, 3) + 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, 3) + self.assertNotEqual(meld, None) From 0fc63367b4784c465583cb8356cf05b226da4035 Mon Sep 17 00:00:00 2001 From: Nihisil Date: Thu, 29 Dec 2016 17:00:36 +0700 Subject: [PATCH 11/80] A couple of fixes with open hand strategies --- project/game/logger.py | 5 ++++ project/game/tests.py | 10 +++++--- project/mahjong/ai/main.py | 23 ++++++++++++++--- project/mahjong/ai/strategies/main.py | 18 ++++++++++++++ project/mahjong/ai/tests/tests_ai.py | 26 ++++++++++++++++++++ project/mahjong/ai/tests/tests_strategies.py | 10 ++++++++ project/mahjong/hand.py | 10 ++++++++ 7 files changed, 95 insertions(+), 7 deletions(-) diff --git a/project/game/logger.py b/project/game/logger.py index 50a8a0c6..0e2ab3f4 100644 --- a/project/game/logger.py +++ b/project/game/logger.py @@ -26,3 +26,8 @@ def set_up_logging(): logger.addHandler(ch) logger.addHandler(fh) + + logger = logging.getLogger('ai') + logger.setLevel(logging.DEBUG) + logger.addHandler(ch) + logger.addHandler(fh) diff --git a/project/game/tests.py b/project/game/tests.py index f2bf5335..2b13bfd2 100644 --- a/project/game/tests.py +++ b/project/game/tests.py @@ -15,13 +15,15 @@ def setUp(self): logger.disabled = False # def test_debug(self): - # game.game_manager.shuffle_seed = lambda: 0.6268116629798297 + # game.game_manager.shuffle_seed = lambda: 0.5910323486646264 # # clients = [Client() for _ in range(0, 4)] + # # clients = [Client(use_previous_ai_version=True) for _ in range(0, 3)] + # # clients += [Client(use_previous_ai_version=False)] # manager = GameManager(clients) # manager.init_game() - # manager.set_dealer(2) - # manager._unique_dealers = 2 + # manager.set_dealer(1) + # manager._unique_dealers = 1 # manager.init_round() # # result = manager.play_round() @@ -246,7 +248,7 @@ def test_play_round_and_open_yakuhai_hand(self): result = manager.play_round() - self.assertEqual(len(result['players_with_open_hands']), 3) + self.assertEqual(len(result['players_with_open_hands']), 1) def test_scores_calculations_after_retake(self): clients = [Client() for _ in range(0, 4)] diff --git a/project/mahjong/ai/main.py b/project/mahjong/ai/main.py index 4c33a477..b90922bd 100644 --- a/project/mahjong/ai/main.py +++ b/project/mahjong/ai/main.py @@ -1,4 +1,6 @@ # -*- coding: utf-8 -*- +import logging + from mahjong.ai.agari import Agari from mahjong.ai.base import BaseAI from mahjong.ai.defence import Defence @@ -9,9 +11,9 @@ from mahjong.ai.strategies.yakuhai import YakuhaiStrategy from mahjong.constants import HAKU, CHUN, HATSU from mahjong.hand import HandDivider -from mahjong.meld import Meld from mahjong.tile import TilesConverter -from mahjong.utils import is_pin, is_honor, is_chi, is_pon, is_man + +logger = logging.getLogger('ai') class MainAI(BaseAI): @@ -173,9 +175,13 @@ def try_to_call_meld(self, tile, enemy_seat): return self.current_strategy.try_to_call_meld(tile, enemy_seat) def determine_strategy(self): - if self.current_strategy: + # for already opened hand we don't need to give up on selected strategy + if self.player.is_open_hand: return False + old_strategy = self.current_strategy + self.current_strategy = None + # order is important strategies = [ YakuhaiStrategy(BaseStrategy.YAKUHAI, self.player), @@ -187,6 +193,17 @@ def determine_strategy(self): if strategy.should_activate_strategy(): self.current_strategy = strategy + 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))) + + if not self.current_strategy and old_strategy: + logger.debug('{} gave up on {}'.format(self.player.name, old_strategy)) + return self.current_strategy and True or False @property diff --git a/project/mahjong/ai/strategies/main.py b/project/mahjong/ai/strategies/main.py index 0fd27fc3..50cd223f 100644 --- a/project/mahjong/ai/strategies/main.py +++ b/project/mahjong/ai/strategies/main.py @@ -9,6 +9,12 @@ class BaseStrategy(object): HONITSU = 1 TANYAO = 2 + TYPES = { + YAKUHAI: 'Yakuhai', + HONITSU: 'Honitsu', + TANYAO: 'Tanyao', + } + player = None type = None # number of shanten where we can start to open hand @@ -18,6 +24,9 @@ def __init__(self, strategy_type, player): self.type = strategy_type self.player = player + def __str__(self): + return self.TYPES[self.type] + def should_activate_strategy(self): """ Based on player hand and table situation @@ -123,6 +132,15 @@ def try_to_call_meld(self, tile, enemy_seat): if combination not in possible_melds: possible_melds.append(combination) + # we can call melds only with allowed tiles + validated_melds = [] + for meld in possible_melds: + if (self.is_tile_suitable(meld[0] * 4) and + self.is_tile_suitable(meld[1] * 4) and + self.is_tile_suitable(meld[2] * 4)): + validated_melds.append(meld) + possible_melds = validated_melds + if len(possible_melds): combination = self._find_best_meld_to_open(possible_melds, closed_hand_34, first_limit, second_limit) meld_type = is_chi(combination) and Meld.CHI or Meld.PON diff --git a/project/mahjong/ai/tests/tests_ai.py b/project/mahjong/ai/tests/tests_ai.py index 067a3470..131dd503 100644 --- a/project/mahjong/ai/tests/tests_ai.py +++ b/project/mahjong/ai/tests/tests_ai.py @@ -3,6 +3,7 @@ from mahjong.ai.main import MainAI from mahjong.ai.shanten import Shanten +from mahjong.ai.strategies.main import BaseStrategy from mahjong.meld import Meld from mahjong.player import Player from mahjong.table import Table @@ -144,3 +145,28 @@ def test_chose_right_set_to_open_hand(self): self.assertEqual(meld.type, Meld.CHI) # we should open hand with 345m, not with 456m self.assertEqual(meld.tiles, [8, 12, 16]) + + def test_chose_strategy_and_reset_strategy(self): + table = Table() + player = Player(0, 0, table) + + 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) + + # we draw a tile that will change our selected strategy + tile = self._string_to_136_tile(sou='8') + player.draw_tile(tile) + self.assertEqual(player.ai.current_strategy, None) + + 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) + + # for already opened hand we don't need to give up on selected strategy + meld = Meld() + meld.tiles = [1, 2, 3] + player.add_called_meld(meld) + tile = self._string_to_136_tile(sou='8') + player.draw_tile(tile) + self.assertEqual(player.ai.current_strategy.type, BaseStrategy.TANYAO) diff --git a/project/mahjong/ai/tests/tests_strategies.py b/project/mahjong/ai/tests/tests_strategies.py index a3a384da..b2eb9d74 100644 --- a/project/mahjong/ai/tests/tests_strategies.py +++ b/project/mahjong/ai/tests/tests_strategies.py @@ -276,3 +276,13 @@ def test_dont_open_hand_with_high_shanten(self): player.init_hand(tiles) meld, _ = player.try_to_call_meld(tile, 3) self.assertNotEqual(meld, None) + + def test_dont_open_hand_with_not_suitable_melds(self): + table = Table() + player = Player(0, 0, table) + + 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, 3) + self.assertEqual(meld, None) diff --git a/project/mahjong/hand.py b/project/mahjong/hand.py index ba0cbc6b..2fc7b6b2 100644 --- a/project/mahjong/hand.py +++ b/project/mahjong/hand.py @@ -423,6 +423,16 @@ def return_response(): # let's use cost for most expensive hand calculated_hands = sorted(calculated_hands, key=lambda x: (x['han'], x['fu']), reverse=True) + + # debug lines + # if not calculated_hands: + # print(TilesConverter.to_one_line_string(tiles)) + # for open_set in open_sets: + # open_set[0] *= 4 + # open_set[1] *= 4 + # open_set[2] *= 4 + # print(TilesConverter.to_one_line_string(open_set)) + calculated_hand = calculated_hands[0] cost = calculated_hand['cost'] error = calculated_hand['error'] From 27ee3a58f6610866ff0fc9d8d8714de8d0f4867d Mon Sep 17 00:00:00 2001 From: Nihisil Date: Thu, 29 Dec 2016 23:58:11 +0700 Subject: [PATCH 12/80] Improve the way to call a set a little bit --- project/mahjong/ai/strategies/main.py | 130 ++++++++++++++++---------- project/mahjong/ai/tests/tests_ai.py | 12 +-- project/mahjong/tests/tests_tile.py | 6 ++ project/mahjong/tile.py | 22 +++++ 4 files changed, 113 insertions(+), 57 deletions(-) diff --git a/project/mahjong/ai/strategies/main.py b/project/mahjong/ai/strategies/main.py index 50cd223f..b05f4ca4 100644 --- a/project/mahjong/ai/strategies/main.py +++ b/project/mahjong/ai/strategies/main.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +from mahjong.constants import HONOR_INDICES from mahjong.meld import Meld from mahjong.tile import TilesConverter from mahjong.utils import is_man, is_pin, is_sou, is_chi, is_pon @@ -142,7 +143,8 @@ def try_to_call_meld(self, tile, enemy_seat): possible_melds = validated_melds if len(possible_melds): - combination = self._find_best_meld_to_open(possible_melds, closed_hand_34, first_limit, second_limit) + combination = self._find_best_meld_to_open(possible_melds, closed_hand_34, first_limit, second_limit, + new_tiles) meld_type = is_chi(combination) and Meld.CHI or Meld.PON combination.remove(discarded_tile) @@ -177,7 +179,7 @@ def try_to_call_meld(self, tile, enemy_seat): return None, None - def _find_best_meld_to_open(self, possible_melds, closed_hand_34, first_limit, second_limit): + def _find_best_meld_to_open(self, possible_melds, closed_hand_34, first_limit, second_limit, completed_hand): """ For now best meld will be the meld with higher count of remaining sets in the hand :param possible_melds: @@ -190,53 +192,79 @@ def _find_best_meld_to_open(self, possible_melds, closed_hand_34, first_limit, s if len(possible_melds) == 1: return possible_melds[0] - best_meld = None - best_option = -2 - - for combination in possible_melds: - remaining_hand = [] - local_closed_hand_34 = closed_hand_34[:] - - # remove combination from hand and let's see what we will hand in the end - local_closed_hand_34[combination[0]] -= 1 - local_closed_hand_34[combination[1]] -= 1 - local_closed_hand_34[combination[2]] -= 1 - - pair_indices = self.player.ai.hand_divider.find_pairs(local_closed_hand_34, - first_limit, - second_limit) - - if pair_indices: - for pair_index in pair_indices: - pair_34 = local_closed_hand_34[:] - pair_34[pair_index] -= 2 - - hand = [[[pair_index] * 2]] - - pair_combinations = self.player.ai.hand_divider.find_valid_combinations(pair_34, - first_limit, - second_limit, True) - if pair_combinations: - hand.append(pair_combinations) - - remaining_hand.append(hand) - - local_combinations = self.player.ai.hand_divider.find_valid_combinations(local_closed_hand_34, - first_limit, - second_limit, True) - - if local_combinations: - for pair_index in pair_indices: - local_combinations.append([[pair_index] * 2]) - remaining_hand.append(local_combinations) - - most_long_hand = -1 - for item in remaining_hand: - if len(item) > most_long_hand: - most_long_hand = len(item) - - if most_long_hand > best_option: - best_option = most_long_hand - best_meld = combination + completed_hand_34 = TilesConverter.to_34_array(completed_hand) + tile_to_replace = None + for tile in HONOR_INDICES: + if tile not in completed_hand: + tile_to_replace = tile + break + + # For now 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 + if not tile_to_replace: + return possible_melds[0] - return best_meld + results = [] + for meld in possible_melds: + temp_hand_34 = completed_hand_34[:] + temp_hand_34[meld[0]] -= 1 + temp_hand_34[meld[1]] -= 1 + temp_hand_34[meld[2]] -= 1 + temp_hand_34[tile_to_replace] = 3 + shanten = self.player.ai.shanten.calculate_shanten(temp_hand_34, self.player.is_open_hand) + results.append({'shanten': shanten, 'meld': meld}) + + results = sorted(results, key=lambda i: i['shanten']) + return results[0]['meld'] + + # best_meld = None + # best_option = -2 + # + # for combination in possible_melds: + # remaining_hand = [] + # local_closed_hand_34 = closed_hand_34[:] + # + # # remove combination from hand and let's see what we will hand in the end + # local_closed_hand_34[combination[0]] -= 1 + # local_closed_hand_34[combination[1]] -= 1 + # local_closed_hand_34[combination[2]] -= 1 + # + # pair_indices = self.player.ai.hand_divider.find_pairs(local_closed_hand_34, + # first_limit, + # second_limit) + # + # if pair_indices: + # for pair_index in pair_indices: + # pair_34 = local_closed_hand_34[:] + # pair_34[pair_index] -= 2 + # + # hand = [[[pair_index] * 2]] + # + # pair_combinations = self.player.ai.hand_divider.find_valid_combinations(pair_34, + # first_limit, + # second_limit, True) + # if pair_combinations: + # hand.append(pair_combinations) + # + # remaining_hand.append(hand) + # + # local_combinations = self.player.ai.hand_divider.find_valid_combinations(local_closed_hand_34, + # first_limit, + # second_limit, True) + # + # if local_combinations: + # for pair_index in pair_indices: + # local_combinations.append([[pair_index] * 2]) + # remaining_hand.append(local_combinations) + # + # most_long_hand = -1 + # for item in remaining_hand: + # if len(item) > most_long_hand: + # most_long_hand = len(item) + # + # if most_long_hand > best_option: + # best_option = most_long_hand + # best_meld = combination + # + # return best_meld diff --git a/project/mahjong/ai/tests/tests_ai.py b/project/mahjong/ai/tests/tests_ai.py index 131dd503..7ee3d7e1 100644 --- a/project/mahjong/ai/tests/tests_ai.py +++ b/project/mahjong/ai/tests/tests_ai.py @@ -128,14 +128,14 @@ def test_chose_right_set_to_open_hand(self): table = Table() player = Player(0, 0, table) - tiles = self._string_to_136_array(man='335688', pin='22', sou='345', honors='55') - tile = self._string_to_136_tile(man='4') + tiles = self._string_to_136_array(man='23455', pin='3445678', honors='1') + tile = self._string_to_136_tile(man='5') player.init_hand(tiles) meld, _ = player.try_to_call_meld(tile, 3) self.assertNotEqual(meld, None) - self.assertEqual(meld.type, Meld.CHI) - # we should open hand with 456m, not with 345m - self.assertEqual(meld.tiles, [12, 16, 20]) + self.assertEqual(meld.type, Meld.PON) + # 555m + self.assertEqual(meld.tiles, [16, 16, 17]) tiles = self._string_to_136_array(man='335666', pin='22', sou='345', honors='55') tile = self._string_to_136_tile(man='4') @@ -143,7 +143,7 @@ def test_chose_right_set_to_open_hand(self): meld, _ = player.try_to_call_meld(tile, 3) self.assertNotEqual(meld, None) self.assertEqual(meld.type, Meld.CHI) - # we should open hand with 345m, not with 456m + # 345m self.assertEqual(meld.tiles, [8, 12, 16]) def test_chose_strategy_and_reset_strategy(self): diff --git a/project/mahjong/tests/tests_tile.py b/project/mahjong/tests/tests_tile.py index d0b5b0d9..c96028d8 100644 --- a/project/mahjong/tests/tests_tile.py +++ b/project/mahjong/tests/tests_tile.py @@ -24,6 +24,12 @@ def test_convert_to_34_array(self): self.assertEqual(result[33], 1) self.assertEqual(sum(result), 14) + def test_convert_to_136_array(self): + tiles = [0, 32, 33, 36, 37, 68, 69, 72, 73, 104, 105, 108, 109, 132] + result = TilesConverter.to_34_array(tiles) + result = TilesConverter.to_136_array(result) + self.assertEqual(result, tiles) + def test_convert_string_to_136_array(self): tiles = TilesConverter.string_to_136_array(sou='19', pin='19', man='19', honors='1234567') diff --git a/project/mahjong/tile.py b/project/mahjong/tile.py index 7362c07d..68c17058 100644 --- a/project/mahjong/tile.py +++ b/project/mahjong/tile.py @@ -40,6 +40,28 @@ def to_34_array(tiles): results[tile] += 1 return results + @staticmethod + def to_136_array(tiles): + """ + Convert 34 array to the 136 tiles array + """ + temp = [] + results = [] + for x in range(0, 34): + if tiles[x]: + temp_value = [x * 4] * tiles[x] + for tile in temp_value: + if tile in results: + count_of_tiles = len([x for x in temp if x == tile]) + new_tile = tile + count_of_tiles + results.append(new_tile) + + temp.append(tile) + else: + results.append(tile) + temp.append(tile) + return results + @staticmethod def string_to_136_array(sou=None, pin=None, man=None, honors=None): """ From 1d61ac9f634a99a0a00f26022ece1cbb377e1f1c Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Tue, 17 Jan 2017 17:28:20 +0800 Subject: [PATCH 13/80] Fix an issue with counting chitoitsu shanten for open hands --- project/mahjong/ai/main.py | 11 +++++++---- project/mahjong/ai/strategies/main.py | 7 +++++-- project/mahjong/ai/tests/tests_ai.py | 26 ++++++++++++++++++-------- project/utils/tests.py | 3 +++ 4 files changed, 33 insertions(+), 14 deletions(-) diff --git a/project/mahjong/ai/main.py b/project/mahjong/ai/main.py index b90922bd..de957616 100644 --- a/project/mahjong/ai/main.py +++ b/project/mahjong/ai/main.py @@ -41,7 +41,9 @@ def erase_state(self): self.current_strategy = None def discard_tile(self): - results, shanten = self.calculate_outs(self.player.tiles, self.player.closed_hand) + results, shanten = self.calculate_outs(self.player.tiles, + self.player.closed_hand, + self.player.is_open_hand) self.previous_shanten = shanten if shanten == 0: @@ -72,15 +74,16 @@ def discard_tile(self): return tile_in_hand - def calculate_outs(self, tiles, closed_hand): + def calculate_outs(self, tiles, closed_hand, is_open_hand=False): """ :param tiles: array of tiles in 136 format :param closed_hand: array of tiles in 136 format + :param is_open_hand: boolean flag :return: """ tiles_34 = TilesConverter.to_34_array(tiles) closed_tiles_34 = TilesConverter.to_34_array(closed_hand) - shanten = self.shanten.calculate_shanten(tiles_34, self.player.is_open_hand) + shanten = self.shanten.calculate_shanten(tiles_34, is_open_hand) # win if shanten == Shanten.AGARI_STATE: @@ -106,7 +109,7 @@ def calculate_outs(self, tiles, closed_hand): continue tiles_34[j] += 1 - if self.shanten.calculate_shanten(tiles_34, self.player.is_open_hand) == shanten - 1: + if self.shanten.calculate_shanten(tiles_34, is_open_hand) == shanten - 1: raw_data[i].append(j) tiles_34[j] -= 1 diff --git a/project/mahjong/ai/strategies/main.py b/project/mahjong/ai/strategies/main.py index b05f4ca4..011440d4 100644 --- a/project/mahjong/ai/strategies/main.py +++ b/project/mahjong/ai/strategies/main.py @@ -70,7 +70,9 @@ def try_to_call_meld(self, tile, enemy_seat): is_kamicha_discard = self.player.seat - 1 == enemy_seat or self.player.seat == 0 and enemy_seat == 3 new_tiles = self.player.tiles[:] + [tile] - outs_results, shanten = self.player.ai.calculate_outs(new_tiles, closed_hand) + # we need to calculate count of shanten with open hand condition + # to exclude chitoitsu from the calculation + outs_results, shanten = self.player.ai.calculate_outs(new_tiles, closed_hand, is_open_hand=True) # each strategy can use their own value to min shanten number if shanten > self.min_shanten: @@ -212,7 +214,8 @@ def _find_best_meld_to_open(self, possible_melds, closed_hand_34, first_limit, s temp_hand_34[meld[1]] -= 1 temp_hand_34[meld[2]] -= 1 temp_hand_34[tile_to_replace] = 3 - shanten = self.player.ai.shanten.calculate_shanten(temp_hand_34, self.player.is_open_hand) + # open hand always should be true to exclude chitoitsu hands from calculations + shanten = self.player.ai.shanten.calculate_shanten(temp_hand_34, is_open_hand=True) results.append({'shanten': shanten, 'meld': meld}) results = sorted(results, key=lambda i: i['shanten']) diff --git a/project/mahjong/ai/tests/tests_ai.py b/project/mahjong/ai/tests/tests_ai.py index 7ee3d7e1..44a32437 100644 --- a/project/mahjong/ai/tests/tests_ai.py +++ b/project/mahjong/ai/tests/tests_ai.py @@ -18,7 +18,7 @@ def test_outs(self): ai = MainAI(table, player) tiles = self._string_to_136_array(sou='111345677', pin='15', man='569') - outs, shanten = ai.calculate_outs(tiles, tiles) + outs, shanten = ai.calculate_outs(tiles, tiles, False) self.assertEqual(shanten, 2) self.assertEqual(outs[0]['discard'], 9) @@ -26,7 +26,7 @@ def test_outs(self): self.assertEqual(outs[0]['tiles_count'], 57) tiles = self._string_to_136_array(sou='111345677', pin='45', man='569') - outs, shanten = ai.calculate_outs(tiles, tiles) + outs, shanten = ai.calculate_outs(tiles, tiles, False) self.assertEqual(shanten, 1) self.assertEqual(outs[0]['discard'], 23) @@ -34,7 +34,7 @@ def test_outs(self): self.assertEqual(outs[0]['tiles_count'], 16) tiles = self._string_to_136_array(sou='11145677', pin='345', man='569') - outs, shanten = ai.calculate_outs(tiles, tiles) + outs, shanten = ai.calculate_outs(tiles, tiles, False) self.assertEqual(shanten, 0) self.assertEqual(outs[0]['discard'], 8) @@ -42,7 +42,7 @@ def test_outs(self): self.assertEqual(outs[0]['tiles_count'], 8) tiles = self._string_to_136_array(sou='11145677', pin='345', man='456') - outs, shanten = ai.calculate_outs(tiles, tiles) + outs, shanten = ai.calculate_outs(tiles, tiles, False) self.assertEqual(shanten, Shanten.AGARI_STATE) self.assertEqual(len(outs), 0) @@ -125,6 +125,10 @@ def test_not_open_hand_in_riichi(self): self.assertEqual(meld, None) def test_chose_right_set_to_open_hand(self): + """ + Different test cases to open hand and chose correct set to open hand. + Based on real examples of incorrect opened hands + """ table = Table() player = Player(0, 0, table) @@ -134,8 +138,7 @@ def test_chose_right_set_to_open_hand(self): meld, _ = player.try_to_call_meld(tile, 3) self.assertNotEqual(meld, None) self.assertEqual(meld.type, Meld.PON) - # 555m - self.assertEqual(meld.tiles, [16, 16, 17]) + self.assertEqual(self._to_string(meld.tiles), '555m') tiles = self._string_to_136_array(man='335666', pin='22', sou='345', honors='55') tile = self._string_to_136_tile(man='4') @@ -143,8 +146,15 @@ def test_chose_right_set_to_open_hand(self): meld, _ = player.try_to_call_meld(tile, 3) self.assertNotEqual(meld, None) self.assertEqual(meld.type, Meld.CHI) - # 345m - self.assertEqual(meld.tiles, [8, 12, 16]) + self.assertEqual(self._to_string(meld.tiles), '345m') + + tiles = self._string_to_136_array(man='23557', pin='556788', honors='22') + tile = self._string_to_136_tile(pin='5') + player.init_hand(tiles) + meld, _ = player.try_to_call_meld(tile, 3) + self.assertNotEqual(meld, None) + self.assertEqual(meld.type, Meld.PON) + self.assertEqual(self._to_string(meld.tiles), '555p') def test_chose_strategy_and_reset_strategy(self): table = Table() diff --git a/project/utils/tests.py b/project/utils/tests.py index 12e0d7ec..111aa17d 100644 --- a/project/utils/tests.py +++ b/project/utils/tests.py @@ -29,6 +29,9 @@ def _string_to_136_tile(self, sou='', pin='', man='', honors=''): def _to_34_array(self, tiles): return TilesConverter.to_34_array(tiles) + def _to_string(self, tiles_136): + return TilesConverter.to_one_line_string(tiles_136) + def _hand(self, tiles, hand_index=0): hand_divider = HandDivider() return hand_divider.divide_hand(tiles, [], [])[hand_index] From 82c3b78cb2f55767838b4c0f645286dcc0859fc2 Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Wed, 18 Jan 2017 16:52:10 +0800 Subject: [PATCH 14/80] Improve the way to count shanten in open hand. I hope it should fix everything --- project/game/game_manager.py | 10 ++-- project/game/tests.py | 8 +-- project/mahjong/ai/main.py | 4 +- project/mahjong/ai/shanten.py | 33 ++++++++++- project/mahjong/ai/strategies/main.py | 68 +++-------------------- project/mahjong/ai/tests/tests_shanten.py | 10 ++++ project/mahjong/utils.py | 21 ++++++- 7 files changed, 80 insertions(+), 74 deletions(-) diff --git a/project/game/game_manager.py b/project/game/game_manager.py index 54660f99..a8156870 100644 --- a/project/game/game_manager.py +++ b/project/game/game_manager.py @@ -238,15 +238,15 @@ def play_round(self): current_client = self._get_current_client() self.players_with_open_hands.append(self.current_client_seat) - logger.info('Hand: {} + {}'.format( - TilesConverter.to_one_line_string(current_client.player.tiles), - TilesConverter.to_one_line_string([tile]) - )) - current_client.add_called_meld(meld) current_client.player.tiles.append(tile) logger.info('Called meld: {} by {}'.format(meld, current_client.player.name)) + logger.info('With hand: {} + {}'.format( + TilesConverter.to_one_line_string(current_client.player.tiles), + TilesConverter.to_one_line_string([tile]) + )) + self.replay.open_meld(current_client.seat, meld.type, meld.tiles) # we need to double validate that we are doing fine diff --git a/project/game/tests.py b/project/game/tests.py index 2b13bfd2..75702485 100644 --- a/project/game/tests.py +++ b/project/game/tests.py @@ -15,15 +15,15 @@ def setUp(self): logger.disabled = False # def test_debug(self): - # game.game_manager.shuffle_seed = lambda: 0.5910323486646264 + # game.game_manager.shuffle_seed = lambda: 0.8442731992460081 # # clients = [Client() for _ in range(0, 4)] # # clients = [Client(use_previous_ai_version=True) for _ in range(0, 3)] # # clients += [Client(use_previous_ai_version=False)] # manager = GameManager(clients) # manager.init_game() - # manager.set_dealer(1) - # manager._unique_dealers = 1 + # manager.set_dealer(0) + # manager._unique_dealers = 4 # manager.init_round() # # result = manager.play_round() @@ -248,7 +248,7 @@ def test_play_round_and_open_yakuhai_hand(self): result = manager.play_round() - self.assertEqual(len(result['players_with_open_hands']), 1) + self.assertEqual(len(result['players_with_open_hands']), 2) def test_scores_calculations_after_retake(self): clients = [Client() for _ in range(0, 4)] diff --git a/project/mahjong/ai/main.py b/project/mahjong/ai/main.py index de957616..aded0689 100644 --- a/project/mahjong/ai/main.py +++ b/project/mahjong/ai/main.py @@ -83,7 +83,7 @@ def calculate_outs(self, tiles, closed_hand, is_open_hand=False): """ tiles_34 = TilesConverter.to_34_array(tiles) closed_tiles_34 = TilesConverter.to_34_array(closed_hand) - shanten = self.shanten.calculate_shanten(tiles_34, is_open_hand) + shanten = self.shanten.calculate_shanten(tiles_34, is_open_hand, self.player.meld_tiles) # win if shanten == Shanten.AGARI_STATE: @@ -109,7 +109,7 @@ def calculate_outs(self, tiles, closed_hand, is_open_hand=False): continue tiles_34[j] += 1 - if self.shanten.calculate_shanten(tiles_34, is_open_hand) == shanten - 1: + if self.shanten.calculate_shanten(tiles_34, is_open_hand, self.player.meld_tiles) == shanten - 1: raw_data[i].append(j) tiles_34[j] -= 1 diff --git a/project/mahjong/ai/shanten.py b/project/mahjong/ai/shanten.py index c0304d8d..5e1463a0 100644 --- a/project/mahjong/ai/shanten.py +++ b/project/mahjong/ai/shanten.py @@ -1,6 +1,10 @@ # -*- coding: utf-8 -*- import math +import copy + +from mahjong.utils import find_isolated_tile_indices + class Shanten(object): AGARI_STATE = -1 @@ -14,20 +18,45 @@ class Shanten(object): number_isolated_tiles = 0 min_shanten = 0 - def calculate_shanten(self, tiles, is_open_hand=False): + def calculate_shanten(self, tiles, is_open_hand=False, melds=None): """ Return the count of tiles before tempai :param tiles: 34 tiles format array :param is_open_hand: + :param melds: array of array of 136 tiles format :return: int """ + + # we will modify them later, so we need to use a copy + melds = copy.deepcopy(melds) + tiles = copy.deepcopy(tiles) + self._init(tiles) - count_of_tiles = sum(self.tiles) + count_of_tiles = sum(tiles) if count_of_tiles > 14: return -2 + # With open hand we need to remove open sets from hand and replace them with isolated pon sets + # it will allow to calculate count of shanten correctly + if melds: + isolated_tiles = find_isolated_tile_indices(tiles) + for meld in melds: + if not isolated_tiles: + break + + isolated_tile = isolated_tiles.pop() + + meld[0] //= 4 + meld[1] //= 4 + meld[2] //= 4 + + tiles[meld[0]] -= 1 + tiles[meld[1]] -= 1 + tiles[meld[2]] -= 1 + tiles[isolated_tile] = 3 + if not is_open_hand: self.min_shanten = self._scan_chitoitsu_and_kokushi() diff --git a/project/mahjong/ai/strategies/main.py b/project/mahjong/ai/strategies/main.py index 011440d4..e6ad5441 100644 --- a/project/mahjong/ai/strategies/main.py +++ b/project/mahjong/ai/strategies/main.py @@ -2,7 +2,7 @@ from mahjong.constants import HONOR_INDICES from mahjong.meld import Meld from mahjong.tile import TilesConverter -from mahjong.utils import is_man, is_pin, is_sou, is_chi, is_pon +from mahjong.utils import is_man, is_pin, is_sou, is_chi, is_pon, find_isolated_tile_indices class BaseStrategy(object): @@ -194,16 +194,15 @@ def _find_best_meld_to_open(self, possible_melds, closed_hand_34, first_limit, s if len(possible_melds) == 1: return possible_melds[0] - completed_hand_34 = TilesConverter.to_34_array(completed_hand) - tile_to_replace = None - for tile in HONOR_INDICES: - if tile not in completed_hand: - tile_to_replace = tile - break - # For now 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) + tile_to_replace = None + isolated_tiles = find_isolated_tile_indices(completed_hand_34) + if isolated_tiles: + tile_to_replace = isolated_tiles[0] + if not tile_to_replace: return possible_melds[0] @@ -215,59 +214,8 @@ def _find_best_meld_to_open(self, possible_melds, closed_hand_34, first_limit, s temp_hand_34[meld[2]] -= 1 temp_hand_34[tile_to_replace] = 3 # open hand always should be true to exclude chitoitsu hands from calculations - shanten = self.player.ai.shanten.calculate_shanten(temp_hand_34, is_open_hand=True) + shanten = self.player.ai.shanten.calculate_shanten(temp_hand_34, True, self.player.meld_tiles) results.append({'shanten': shanten, 'meld': meld}) results = sorted(results, key=lambda i: i['shanten']) return results[0]['meld'] - - # best_meld = None - # best_option = -2 - # - # for combination in possible_melds: - # remaining_hand = [] - # local_closed_hand_34 = closed_hand_34[:] - # - # # remove combination from hand and let's see what we will hand in the end - # local_closed_hand_34[combination[0]] -= 1 - # local_closed_hand_34[combination[1]] -= 1 - # local_closed_hand_34[combination[2]] -= 1 - # - # pair_indices = self.player.ai.hand_divider.find_pairs(local_closed_hand_34, - # first_limit, - # second_limit) - # - # if pair_indices: - # for pair_index in pair_indices: - # pair_34 = local_closed_hand_34[:] - # pair_34[pair_index] -= 2 - # - # hand = [[[pair_index] * 2]] - # - # pair_combinations = self.player.ai.hand_divider.find_valid_combinations(pair_34, - # first_limit, - # second_limit, True) - # if pair_combinations: - # hand.append(pair_combinations) - # - # remaining_hand.append(hand) - # - # local_combinations = self.player.ai.hand_divider.find_valid_combinations(local_closed_hand_34, - # first_limit, - # second_limit, True) - # - # if local_combinations: - # for pair_index in pair_indices: - # local_combinations.append([[pair_index] * 2]) - # remaining_hand.append(local_combinations) - # - # most_long_hand = -1 - # for item in remaining_hand: - # if len(item) > most_long_hand: - # most_long_hand = len(item) - # - # if most_long_hand > best_option: - # best_option = most_long_hand - # best_meld = combination - # - # return best_meld diff --git a/project/mahjong/ai/tests/tests_shanten.py b/project/mahjong/ai/tests/tests_shanten.py index d6eebd54..5aca286d 100644 --- a/project/mahjong/ai/tests/tests_shanten.py +++ b/project/mahjong/ai/tests/tests_shanten.py @@ -69,3 +69,13 @@ def test_shanten_number_and_kokushi_musou(self): tiles = self._string_to_136_array(sou='129', pin='129', man='129', honors='12345') self.assertEqual(shanten.calculate_shanten(self._to_34_array(tiles)), 2) + + def test_shanten_number_and_open_sets(self): + shanten = Shanten() + + tiles = self._string_to_136_array(sou='44467778', pin='222567') + open_sets = [] + self.assertEqual(shanten.calculate_shanten(self._to_34_array(tiles), melds=open_sets), Shanten.AGARI_STATE) + + open_sets = [self._string_to_open_34_set(sou='777')] + self.assertEqual(shanten.calculate_shanten(self._to_34_array(tiles), melds=open_sets), 2) diff --git a/project/mahjong/utils.py b/project/mahjong/utils.py index 27df478e..b9152449 100644 --- a/project/mahjong/utils.py +++ b/project/mahjong/utils.py @@ -1,4 +1,4 @@ -from mahjong.constants import EAST, FIVE_RED_MAN, FIVE_RED_PIN, FIVE_RED_SOU +from mahjong.constants import EAST, FIVE_RED_MAN, FIVE_RED_PIN, FIVE_RED_SOU, HONOR_INDICES from utils.settings_handler import settings @@ -130,3 +130,22 @@ def simplify(tile): :return: tile: 0-8 presentation """ return tile - 9 * (tile // 9) + + +def find_isolated_tile_indices(hand_34): + """ + :param hand_34: array of tiles in 34 tile format + :return: array of isolated tiles indices + """ + isolated_indices = [] + for x in range(1, 27): + # TODO handle 1-9 tiles situation to have more isolated tiles + if hand_34[x] == 0 and hand_34[x - 1] == 0 and hand_34[x + 1] == 0: + isolated_indices.append(x) + + # for honor tiles we don't need to check nearby tiles + for x in HONOR_INDICES: + if hand_34[x] == 0: + isolated_indices.append(x) + + return isolated_indices From dae8e5c83aa6bbf71964714d4e5445bd7024ce1e Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Wed, 1 Feb 2017 13:27:38 +0800 Subject: [PATCH 15/80] Remove vagrant integration --- .gitignore | 2 +- .travis.yml | 1 + README.md | 8 ++------ Vagrantfile | 18 ------------------ 4 files changed, 4 insertions(+), 25 deletions(-) delete mode 100644 Vagrantfile diff --git a/.gitignore b/.gitignore index becf592d..6f2638d6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ .idea -.vagrant +env *.py[cod] __pycache__ diff --git a/.travis.yml b/.travis.yml index 5a2e511f..dad4a5be 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,7 @@ language: python python: - "3.5" + - "3.6" before_script: - cd project install: "pip install -r project/requirements.txt" diff --git a/README.md b/README.md index ba158d28..3e954f49 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ [![Build Status](https://travis-ci.org/MahjongRepository/tenhou-python-bot.svg?branch=master)](https://travis-ci.org/MahjongRepository/tenhou-python-bot) -For now only **Python 3.5** is supported. +For now only **Python 3.5+** is supported. # What do we have here? @@ -61,11 +61,7 @@ and run `bots_battle.py`. ## How to run it? -1. Install [VirtualBox](https://www.virtualbox.org/wiki/Downloads) -2. Install [Vagrant](https://www.vagrantup.com/downloads.html) -3. Run `vagrant up`. It will take a while, also it will ask your system root password to setup NFS -4. Run `vagrant ssh` -5. Run `python main.py` it will connect to the tenhou.net and will play a match. +Run `pythone main.py` it will connect to the tenhou.net and will play a match. After the end of the match it will close connection to the server ## Configuration instructions diff --git a/Vagrantfile b/Vagrantfile deleted file mode 100644 index be2114e5..00000000 --- a/Vagrantfile +++ /dev/null @@ -1,18 +0,0 @@ -# -*- mode: ruby -*- -# vi: set ft=ruby : - -Vagrant.configure(2) do |config| - config.vm.box = "ubuntu/xenial64" - - config.vm.network "forwarded_port", guest: 8080, host: 8080 - - # it is here for better performance - config.vm.network "private_network", type: "dhcp" - config.vm.synced_folder ".", "/vagrant", type: "nfs", mount_options: ["rw", "vers=3", "tcp", "fsc" ,"actimeo=2"] - - config.vm.provider "virtualbox" do |v| - v.memory = 512 - end - - config.vm.provision :shell, path: "bin/vagrant/install.sh" -end \ No newline at end of file From 8a434a77a94315c2fcc8c2eaeceaa1cc35361a66 Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Thu, 2 Feb 2017 14:55:09 +0800 Subject: [PATCH 16/80] Improve the yakuhai strategy --- project/game/game_manager.py | 7 ++-- project/game/tests.py | 8 ++--- project/mahjong/ai/main.py | 27 +++----------- project/mahjong/ai/strategies/honitsu.py | 28 +++++++++++++++ project/mahjong/ai/strategies/main.py | 28 +++++++++------ project/mahjong/ai/strategies/yakuhai.py | 15 ++++++++ project/mahjong/ai/tests/tests_strategies.py | 38 +++++++++++++++----- project/mahjong/tile.py | 2 +- 8 files changed, 104 insertions(+), 49 deletions(-) diff --git a/project/game/game_manager.py b/project/game/game_manager.py index a8156870..d97d2d24 100644 --- a/project/game/game_manager.py +++ b/project/game/game_manager.py @@ -238,15 +238,15 @@ def play_round(self): current_client = self._get_current_client() self.players_with_open_hands.append(self.current_client_seat) - current_client.add_called_meld(meld) - current_client.player.tiles.append(tile) - logger.info('Called meld: {} by {}'.format(meld, current_client.player.name)) logger.info('With hand: {} + {}'.format( TilesConverter.to_one_line_string(current_client.player.tiles), TilesConverter.to_one_line_string([tile]) )) + current_client.add_called_meld(meld) + current_client.player.tiles.append(tile) + self.replay.open_meld(current_client.seat, meld.type, meld.tiles) # we need to double validate that we are doing fine @@ -255,6 +255,7 @@ def play_round(self): current_client.discard_tile(tile_to_discard) self.replay.discard(current_client.seat, tile) + logger.info('Discard tile: {}'.format(TilesConverter.to_one_line_string([tile_to_discard]))) # the end of the round result = self.check_clients_possible_ron(current_client, tile_to_discard) diff --git a/project/game/tests.py b/project/game/tests.py index 75702485..2668b562 100644 --- a/project/game/tests.py +++ b/project/game/tests.py @@ -15,15 +15,15 @@ def setUp(self): logger.disabled = False # def test_debug(self): - # game.game_manager.shuffle_seed = lambda: 0.8442731992460081 + # game.game_manager.shuffle_seed = lambda: 0.34296402629849 # - # clients = [Client() for _ in range(0, 4)] + # clients = [Client(use_previous_ai_version=False) for _ in range(0, 4)] # # clients = [Client(use_previous_ai_version=True) for _ in range(0, 3)] # # clients += [Client(use_previous_ai_version=False)] # manager = GameManager(clients) # manager.init_game() - # manager.set_dealer(0) - # manager._unique_dealers = 4 + # manager.set_dealer(1) + # manager._unique_dealers = 1 # manager.init_round() # # result = manager.play_round() diff --git a/project/mahjong/ai/main.py b/project/mahjong/ai/main.py index aded0689..9660f411 100644 --- a/project/mahjong/ai/main.py +++ b/project/mahjong/ai/main.py @@ -68,11 +68,14 @@ def discard_tile(self): # in that case let's do tsumogiri if not results: return self.player.last_draw + + if self.current_strategy: + tile_to_discard = self.current_strategy.determine_what_to_discard(self.player.closed_hand, results, shanten) else: tile34 = results[0]['discard'] - tile_in_hand = TilesConverter.find_34_tile_in_136_array(tile34, self.player.tiles) + tile_to_discard = TilesConverter.find_34_tile_in_136_array(tile34, self.player.tiles) - return tile_in_hand + return tile_to_discard def calculate_outs(self, tiles, closed_hand, is_open_hand=False): """ @@ -122,22 +125,6 @@ def calculate_outs(self, tiles, closed_hand, is_open_hand=False): 'waiting': raw_data[i] } - # in honitsu mode we should discard tiles from other suit, even if it is better to save them - if self.current_strategy and self.current_strategy.type == BaseStrategy.HONITSU: - for i in range(0, 34): - if not tiles_34[i]: - continue - - if not closed_tiles_34[i]: - continue - - if not self.current_strategy.is_tile_suitable(i * 4): - raw_data[i] = { - 'tile': i, - 'tiles_count': 1, - 'waiting': [] - } - results = [] tiles_34 = TilesConverter.to_34_array(self.player.tiles) for tile in range(0, len(tiles_34)): @@ -159,10 +146,6 @@ def calculate_outs(self, tiles, closed_hand, is_open_hand=False): # we need to discard honor tile first results = sorted(results, key=lambda x: (x['tiles_count'], x['discard']), reverse=True) - # in honitsu mode we should discard tiles from other suit, even if it is better to save them - if self.current_strategy and self.current_strategy.type == BaseStrategy.HONITSU: - results = sorted(results, key=lambda x: self.current_strategy.is_tile_suitable(x['discard'] * 4), reverse=False) - return results, shanten def count_tiles(self, raw_data, tiles): diff --git a/project/mahjong/ai/strategies/honitsu.py b/project/mahjong/ai/strategies/honitsu.py index c8d8ab62..1847888d 100644 --- a/project/mahjong/ai/strategies/honitsu.py +++ b/project/mahjong/ai/strategies/honitsu.py @@ -59,3 +59,31 @@ def is_tile_suitable(self, tile): """ tile //= 4 return self.chosen_suit(tile) or is_honor(tile) + + def determine_what_to_discard(self, closed_hand, outs_results, shanten): + """ + In honitsu mode we should discard tiles from other suit, + even if it is better to save them + """ + for i in closed_hand: + i //= 4 + + if not self.is_tile_suitable(i * 4): + item_was_found = False + for j in outs_results: + if j['discard'] == i: + item_was_found = True + j['tiles_count'] = 0 + j['waiting'] = [] + + if not item_was_found: + outs_results.append({ + 'discard': i, + 'tiles_count': 1, + 'waiting': [] + }) + + outs_results = sorted(outs_results, key=lambda x: x['tiles_count'], reverse=True) + outs_results = sorted(outs_results, key=lambda x: self.is_tile_suitable(x['discard'] * 4), reverse=False) + + return super(HonitsuStrategy, self).determine_what_to_discard(closed_hand, outs_results, shanten) diff --git a/project/mahjong/ai/strategies/main.py b/project/mahjong/ai/strategies/main.py index e6ad5441..4363c39b 100644 --- a/project/mahjong/ai/strategies/main.py +++ b/project/mahjong/ai/strategies/main.py @@ -44,6 +44,21 @@ def is_tile_suitable(self, tile): """ raise NotImplemented() + def determine_what_to_discard(self, closed_hand, outs_results, shanten): + """ + :param closed_hand: array of 136 tiles format + :param outs_results: dict + :param shanten: number of shanten + :return: tile in 136 format or none + """ + tile_to_discard = None + for out_result in outs_results: + tile_34 = out_result['discard'] + tile_to_discard = TilesConverter.find_34_tile_in_136_array(tile_34, closed_hand) + if tile_to_discard: + break + return tile_to_discard + def try_to_call_meld(self, tile, enemy_seat): """ Determine should we call a meld or not. @@ -168,16 +183,9 @@ def try_to_call_meld(self, tile, enemy_seat): meld.type = meld_type meld.tiles = sorted(tiles) - tile_to_discard = None - # we need to find possible tile to discard - # it can be that first result already on our set - for out_result in outs_results: - tile_34 = out_result['discard'] - tile_to_discard = TilesConverter.find_34_tile_in_136_array(tile_34, closed_hand) - if tile_to_discard: - break - - return meld, tile_to_discard + tile_to_discard = self.determine_what_to_discard(closed_hand, outs_results, shanten) + if tile_to_discard: + return meld, tile_to_discard return None, None diff --git a/project/mahjong/ai/strategies/yakuhai.py b/project/mahjong/ai/strategies/yakuhai.py index 99b027e5..f1ad0cc7 100644 --- a/project/mahjong/ai/strategies/yakuhai.py +++ b/project/mahjong/ai/strategies/yakuhai.py @@ -20,3 +20,18 @@ def is_tile_suitable(self, tile): :return: True """ return True + + def determine_what_to_discard(self, closed_hand, outs_results, shanten): + tiles_34 = TilesConverter.to_34_array(self.player.tiles) + valued_pairs = [x for x in self.player.ai.valued_honors if tiles_34[x] == 2] + + if shanten == 0 and valued_pairs: + valued_pair = valued_pairs[0] + tile_to_discard = None + for item in outs_results: + if valued_pair in item['waiting']: + tile_to_discard = item['discard'] + tile_to_discard = TilesConverter.find_34_tile_in_136_array(tile_to_discard, closed_hand) + return tile_to_discard + else: + return super(YakuhaiStrategy, self).determine_what_to_discard(closed_hand, outs_results, shanten) diff --git a/project/mahjong/ai/tests/tests_strategies.py b/project/mahjong/ai/tests/tests_strategies.py index b2eb9d74..44eef6f6 100644 --- a/project/mahjong/ai/tests/tests_strategies.py +++ b/project/mahjong/ai/tests/tests_strategies.py @@ -45,8 +45,7 @@ def test_open_hand_with_yakuhai_pair_in_hand(self): player = Player(0, 0, table) tiles = self._string_to_136_array(sou='123678', pin='25899', honors='44') - # 4 honor - tile = 122 + tile = self._string_to_136_tile(honors='4') player.init_hand(tiles) # we don't need to open hand with not our wind @@ -55,7 +54,7 @@ def test_open_hand_with_yakuhai_pair_in_hand(self): # 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 = 122 + tile = self._string_to_136_tile(honors='4') player.init_hand(tiles) meld, _ = player.try_to_call_meld(tile, 3) self.assertNotEqual(meld, None) @@ -63,19 +62,19 @@ def test_open_hand_with_yakuhai_pair_in_hand(self): player.tiles.append(tile) self.assertEqual(meld.type, Meld.PON) - self.assertEqual(meld.tiles, [120, 121, 122]) + 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 = 126 + tile = self._string_to_136_tile(honors='5') meld, _ = player.try_to_call_meld(tile, 3) self.assertNotEqual(meld, None) player.add_called_meld(meld) player.tiles.append(tile) self.assertEqual(meld.type, Meld.PON) - self.assertEqual(meld.tiles, [124, 125, 126]) + self.assertEqual(self._to_string(meld.tiles), '555z') self.assertEqual(len(player.closed_hand), 8) self.assertEqual(len(player.tiles), 14) player.discard_tile() @@ -91,10 +90,32 @@ def test_open_hand_with_yakuhai_pair_in_hand(self): player.tiles.append(tile) self.assertEqual(meld.type, Meld.CHI) - self.assertEqual(meld.tiles, [92, 96, 100]) + 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 = Player(0, 0, table) + + tiles = self._string_to_136_array(sou='123', pin='678', man='34468', honors='66') + tile = self._string_to_136_tile(man='5') + player.init_hand(tiles) + + # we will not get tempai on yakuhai pair with this hand, so let's skip this call + meld, _ = player.try_to_call_meld(tile, 3) + self.assertEqual(meld, None) + + tile = self._string_to_136_tile(man='7') + meld, tile_to_discard = player.try_to_call_meld(tile, 3) + self.assertNotEqual(meld, None) + self.assertEqual(meld.type, Meld.CHI) + self.assertEqual(self._to_string(meld.tiles), '678m') + class HonitsuStrategyTestCase(unittest.TestCase, TestMixin): @@ -161,8 +182,7 @@ def test_open_hand_and_discard_tiles_logic(self): tile_to_discard = player.discard_tile() # we are in honitsu mode, so we should discard man suits - # 8 == 3m - self.assertEqual(tile_to_discard, 8) + self.assertEqual(self._to_string([tile_to_discard]), '2m') class TanyaoStrategyTestCase(unittest.TestCase, TestMixin): diff --git a/project/mahjong/tile.py b/project/mahjong/tile.py index 68c17058..21cccdf8 100644 --- a/project/mahjong/tile.py +++ b/project/mahjong/tile.py @@ -116,7 +116,7 @@ def find_34_tile_in_136_array(tile34, tiles): For example we had 0 tile from 34 array in 136 array it can be present as 0, 1, 2, 3 """ - if tile34 > 33: + if tile34 is None or tile34 > 33: return None tile = tile34 * 4 From 99292eb60b62be6b32a60fd3fc8500e944fe835a Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Thu, 2 Feb 2017 16:51:30 +0800 Subject: [PATCH 17/80] Don't go for yakuhai with chitoitsu-like hand --- project/bots_battle.py | 7 ++++--- project/game/tests.py | 4 ++-- project/mahjong/ai/strategies/yakuhai.py | 8 ++++++-- project/mahjong/ai/tests/tests_strategies.py | 5 +++++ 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/project/bots_battle.py b/project/bots_battle.py index 92d7459c..f7cbf25e 100644 --- a/project/bots_battle.py +++ b/project/bots_battle.py @@ -10,7 +10,7 @@ from game.game_manager import GameManager from mahjong.client import Client -TOTAL_HANCHANS = 10 +TOTAL_HANCHANS = 20 def main(): @@ -20,8 +20,9 @@ def main(): # let's load three bots with old logic # and one copy with new logic - clients = [Client(use_previous_ai_version=True) for _ in range(0, 3)] - clients += [Client(use_previous_ai_version=False)] + # clients = [Client(use_previous_ai_version=True) for _ in range(0, 3)] + # clients += [Client(use_previous_ai_version=False)] + clients = [Client(use_previous_ai_version=False) for _ in range(0, 4)] manager = GameManager(clients) total_results = {} diff --git a/project/game/tests.py b/project/game/tests.py index 2668b562..65fb33cc 100644 --- a/project/game/tests.py +++ b/project/game/tests.py @@ -15,14 +15,14 @@ def setUp(self): logger.disabled = False # def test_debug(self): - # game.game_manager.shuffle_seed = lambda: 0.34296402629849 + # game.game_manager.shuffle_seed = lambda: 0.4803161863382872 # # clients = [Client(use_previous_ai_version=False) for _ in range(0, 4)] # # clients = [Client(use_previous_ai_version=True) for _ in range(0, 3)] # # clients += [Client(use_previous_ai_version=False)] # manager = GameManager(clients) # manager.init_game() - # manager.set_dealer(1) + # manager.set_dealer(0) # manager._unique_dealers = 1 # manager.init_round() # diff --git a/project/mahjong/ai/strategies/yakuhai.py b/project/mahjong/ai/strategies/yakuhai.py index f1ad0cc7..4aca092e 100644 --- a/project/mahjong/ai/strategies/yakuhai.py +++ b/project/mahjong/ai/strategies/yakuhai.py @@ -7,11 +7,14 @@ class YakuhaiStrategy(BaseStrategy): def should_activate_strategy(self): """ - We can go for yakuhai strategy if we have at least one yakuhai pair in the hand + We can go for yakuhai strategy if we have at least one yakuhai pair in the hand, + but with 5+ pairs in hand we don't need to go for yakuhai :return: boolean """ tiles_34 = TilesConverter.to_34_array(self.player.tiles) - return any([tiles_34[x] >= 2 for x in self.player.ai.valued_honors]) + count_of_pairs = len([x for x in range(0, 34) if tiles_34[x] >= 2]) + has_valued_pairs = any([tiles_34[x] >= 2 for x in self.player.ai.valued_honors]) + return has_valued_pairs and count_of_pairs < 4 def is_tile_suitable(self, tile): """ @@ -27,6 +30,7 @@ def determine_what_to_discard(self, closed_hand, outs_results, shanten): if shanten == 0 and valued_pairs: valued_pair = valued_pairs[0] + tile_to_discard = None for item in outs_results: if valued_pair in item['waiting']: diff --git a/project/mahjong/ai/tests/tests_strategies.py b/project/mahjong/ai/tests/tests_strategies.py index 44eef6f6..466cef21 100644 --- a/project/mahjong/ai/tests/tests_strategies.py +++ b/project/mahjong/ai/tests/tests_strategies.py @@ -31,6 +31,11 @@ def test_should_activate_strategy(self): 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_suitable_tiles(self): table = Table() player = Player(0, 0, table) From 6ccfdf58d5e294ffa7d9203b1b2cc7352a511e69 Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Fri, 3 Feb 2017 11:13:05 +0800 Subject: [PATCH 18/80] Fix issue with yakuhai hand and tempai state --- project/game/tests.py | 6 +++--- project/mahjong/ai/main.py | 5 ++++- project/mahjong/ai/strategies/honitsu.py | 4 ++-- project/mahjong/ai/strategies/main.py | 5 +++-- project/mahjong/ai/strategies/yakuhai.py | 12 ++++++++---- 5 files changed, 20 insertions(+), 12 deletions(-) diff --git a/project/game/tests.py b/project/game/tests.py index 65fb33cc..2c4e5f11 100644 --- a/project/game/tests.py +++ b/project/game/tests.py @@ -15,15 +15,15 @@ def setUp(self): logger.disabled = False # def test_debug(self): - # game.game_manager.shuffle_seed = lambda: 0.4803161863382872 + # game.game_manager.shuffle_seed = lambda: 0.4504654144106681 # # clients = [Client(use_previous_ai_version=False) for _ in range(0, 4)] # # clients = [Client(use_previous_ai_version=True) for _ in range(0, 3)] # # clients += [Client(use_previous_ai_version=False)] # manager = GameManager(clients) # manager.init_game() - # manager.set_dealer(0) - # manager._unique_dealers = 1 + # manager.set_dealer(3) + # manager._unique_dealers = 4 # manager.init_round() # # result = manager.play_round() diff --git a/project/mahjong/ai/main.py b/project/mahjong/ai/main.py index 9660f411..f0f971f1 100644 --- a/project/mahjong/ai/main.py +++ b/project/mahjong/ai/main.py @@ -70,7 +70,10 @@ def discard_tile(self): return self.player.last_draw if self.current_strategy: - tile_to_discard = self.current_strategy.determine_what_to_discard(self.player.closed_hand, results, shanten) + tile_to_discard = self.current_strategy.determine_what_to_discard(self.player.closed_hand, + results, + shanten, + False) else: tile34 = results[0]['discard'] tile_to_discard = TilesConverter.find_34_tile_in_136_array(tile34, self.player.tiles) diff --git a/project/mahjong/ai/strategies/honitsu.py b/project/mahjong/ai/strategies/honitsu.py index 1847888d..b14924f4 100644 --- a/project/mahjong/ai/strategies/honitsu.py +++ b/project/mahjong/ai/strategies/honitsu.py @@ -60,7 +60,7 @@ def is_tile_suitable(self, tile): tile //= 4 return self.chosen_suit(tile) or is_honor(tile) - def determine_what_to_discard(self, closed_hand, outs_results, shanten): + def determine_what_to_discard(self, closed_hand, outs_results, shanten, for_open_hand): """ In honitsu mode we should discard tiles from other suit, even if it is better to save them @@ -86,4 +86,4 @@ def determine_what_to_discard(self, closed_hand, outs_results, shanten): outs_results = sorted(outs_results, key=lambda x: x['tiles_count'], reverse=True) outs_results = sorted(outs_results, key=lambda x: self.is_tile_suitable(x['discard'] * 4), reverse=False) - return super(HonitsuStrategy, self).determine_what_to_discard(closed_hand, outs_results, shanten) + return super(HonitsuStrategy, self).determine_what_to_discard(closed_hand, outs_results, shanten, for_open_hand) diff --git a/project/mahjong/ai/strategies/main.py b/project/mahjong/ai/strategies/main.py index 4363c39b..b0289961 100644 --- a/project/mahjong/ai/strategies/main.py +++ b/project/mahjong/ai/strategies/main.py @@ -44,11 +44,12 @@ def is_tile_suitable(self, tile): """ raise NotImplemented() - def determine_what_to_discard(self, closed_hand, outs_results, shanten): + def determine_what_to_discard(self, closed_hand, outs_results, shanten, for_open_hand): """ :param closed_hand: array of 136 tiles format :param outs_results: dict :param shanten: number of shanten + :param for_open_hand: boolean :return: tile in 136 format or none """ tile_to_discard = None @@ -183,7 +184,7 @@ def try_to_call_meld(self, tile, enemy_seat): meld.type = meld_type meld.tiles = sorted(tiles) - tile_to_discard = self.determine_what_to_discard(closed_hand, outs_results, shanten) + tile_to_discard = self.determine_what_to_discard(closed_hand, outs_results, shanten, True) if tile_to_discard: return meld, tile_to_discard diff --git a/project/mahjong/ai/strategies/yakuhai.py b/project/mahjong/ai/strategies/yakuhai.py index 4aca092e..a45a4b9b 100644 --- a/project/mahjong/ai/strategies/yakuhai.py +++ b/project/mahjong/ai/strategies/yakuhai.py @@ -18,17 +18,18 @@ def should_activate_strategy(self): def is_tile_suitable(self, tile): """ - For yakuhai we don't have limits + For yakuhai we don't have any limits :param tile: 136 tiles format :return: True """ return True - def determine_what_to_discard(self, closed_hand, outs_results, shanten): + def determine_what_to_discard(self, closed_hand, outs_results, shanten, for_open_hand): tiles_34 = TilesConverter.to_34_array(self.player.tiles) valued_pairs = [x for x in self.player.ai.valued_honors if tiles_34[x] == 2] - if shanten == 0 and valued_pairs: + # 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: valued_pair = valued_pairs[0] tile_to_discard = None @@ -38,4 +39,7 @@ def determine_what_to_discard(self, closed_hand, outs_results, shanten): tile_to_discard = TilesConverter.find_34_tile_in_136_array(tile_to_discard, closed_hand) return tile_to_discard else: - return super(YakuhaiStrategy, self).determine_what_to_discard(closed_hand, outs_results, shanten) + return super(YakuhaiStrategy, self).determine_what_to_discard(closed_hand, + outs_results, + shanten, + for_open_hand) From b6ac465d029852d179bd4b5b74bd8ede99b322aa Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Fri, 3 Feb 2017 12:24:46 +0800 Subject: [PATCH 19/80] Make Agari to work with open sets and small refactoring --- project/game/game_manager.py | 4 +- project/game/tests.py | 6 +- project/mahjong/ai/agari.py | 24 +++++- project/mahjong/ai/shanten.py | 8 +- project/mahjong/ai/tests/tests_agari.py | 7 ++ project/mahjong/ai/tests/tests_shanten.py | 82 ++++++++++--------- project/mahjong/hand.py | 12 +-- project/mahjong/player.py | 14 +++- .../mahjong/tests/tests_yaku_calculation.py | 76 ++++++++--------- .../tests/tests_yakuman_calculation.py | 2 +- 10 files changed, 133 insertions(+), 102 deletions(-) diff --git a/project/game/game_manager.py b/project/game/game_manager.py index d97d2d24..cc8c96f6 100644 --- a/project/game/game_manager.py +++ b/project/game/game_manager.py @@ -155,7 +155,7 @@ def play_round(self): current_client.draw_tile(tile) tiles = current_client.player.tiles - is_win = self.agari.is_agari(TilesConverter.to_34_array(tiles)) + is_win = self.agari.is_agari(TilesConverter.to_34_array(tiles), current_client.player.meld_tiles) # win by tsumo after tile draw if is_win: @@ -376,7 +376,7 @@ def can_call_ron(self, client, win_tile): round_wind=client.player.table.round_wind) return result['error'] is None - is_ron = self.agari.is_agari(TilesConverter.to_34_array(tiles + [win_tile])) + is_ron = self.agari.is_agari(TilesConverter.to_34_array(tiles + [win_tile]), client.player.meld_tiles) return is_ron def call_riichi(self, client): diff --git a/project/game/tests.py b/project/game/tests.py index 2c4e5f11..3a152a8c 100644 --- a/project/game/tests.py +++ b/project/game/tests.py @@ -15,15 +15,15 @@ def setUp(self): logger.disabled = False # def test_debug(self): - # game.game_manager.shuffle_seed = lambda: 0.4504654144106681 + # game.game_manager.shuffle_seed = lambda: 0.2903675156646588 # # clients = [Client(use_previous_ai_version=False) for _ in range(0, 4)] # # clients = [Client(use_previous_ai_version=True) for _ in range(0, 3)] # # clients += [Client(use_previous_ai_version=False)] # manager = GameManager(clients) # manager.init_game() - # manager.set_dealer(3) - # manager._unique_dealers = 4 + # manager.set_dealer(0) + # manager._unique_dealers = 8 # manager.init_round() # # result = manager.play_round() diff --git a/project/mahjong/ai/agari.py b/project/mahjong/ai/agari.py index e8db7725..8a5b92f6 100644 --- a/project/mahjong/ai/agari.py +++ b/project/mahjong/ai/agari.py @@ -1,14 +1,36 @@ # -*- coding: utf-8 -*- +import copy + +from mahjong.utils import find_isolated_tile_indices class Agari(object): - def is_agari(self, tiles): + def is_agari(self, tiles, melds=None): """ Determine was it win or not :param tiles: 34 tiles format array + :param melds: array of array of 34 tiles format :return: boolean """ + # we will modify them later, so we need to use a copy + tiles = copy.deepcopy(tiles) + + # With open hand we need to remove open sets from hand and replace them with isolated pon sets + # it will allow to determine agari state correctly + if melds: + isolated_tiles = find_isolated_tile_indices(tiles) + for meld in melds: + if not isolated_tiles: + break + + isolated_tile = isolated_tiles.pop() + + tiles[meld[0]] -= 1 + tiles[meld[1]] -= 1 + tiles[meld[2]] -= 1 + tiles[isolated_tile] = 3 + j = (1 << tiles[27]) | (1 << tiles[28]) | (1 << tiles[29]) | (1 << tiles[30]) | \ (1 << tiles[31]) | (1 << tiles[32]) | (1 << tiles[33]) diff --git a/project/mahjong/ai/shanten.py b/project/mahjong/ai/shanten.py index 5e1463a0..affc1f9e 100644 --- a/project/mahjong/ai/shanten.py +++ b/project/mahjong/ai/shanten.py @@ -23,12 +23,10 @@ def calculate_shanten(self, tiles, is_open_hand=False, melds=None): Return the count of tiles before tempai :param tiles: 34 tiles format array :param is_open_hand: - :param melds: array of array of 136 tiles format + :param melds: array of array of 34 tiles format :return: int """ - # we will modify them later, so we need to use a copy - melds = copy.deepcopy(melds) tiles = copy.deepcopy(tiles) self._init(tiles) @@ -48,10 +46,6 @@ def calculate_shanten(self, tiles, is_open_hand=False, melds=None): isolated_tile = isolated_tiles.pop() - meld[0] //= 4 - meld[1] //= 4 - meld[2] //= 4 - tiles[meld[0]] -= 1 tiles[meld[1]] -= 1 tiles[meld[2]] -= 1 diff --git a/project/mahjong/ai/tests/tests_agari.py b/project/mahjong/ai/tests/tests_agari.py index 9a334d9c..0c9fecb2 100644 --- a/project/mahjong/ai/tests/tests_agari.py +++ b/project/mahjong/ai/tests/tests_agari.py @@ -69,3 +69,10 @@ def test_is_kokushi_musou_agari(self): tiles = self._string_to_136_array(sou='19', pin='19', man='19', honors='11134567') self.assertFalse(agari.is_agari(self._to_34_array(tiles))) + + def test_is_agari_and_open_hand(self): + agari = Agari() + + tiles = self._string_to_136_array(sou='23455567', pin='222', man='345') + open_sets = [self._string_to_open_34_set(man='345'), self._string_to_open_34_set(sou='555')] + self.assertFalse(agari.is_agari(self._to_34_array(tiles), open_sets)) diff --git a/project/mahjong/ai/tests/tests_shanten.py b/project/mahjong/ai/tests/tests_shanten.py index 5aca286d..91d2108b 100644 --- a/project/mahjong/ai/tests/tests_shanten.py +++ b/project/mahjong/ai/tests/tests_shanten.py @@ -10,72 +10,76 @@ class ShantenTestCase(unittest.TestCase, TestMixin): def test_shanten_number(self): shanten = Shanten() - tiles = self._string_to_136_array(sou='111234567', pin='11', man='567') - self.assertEqual(shanten.calculate_shanten(self._to_34_array(tiles)), Shanten.AGARI_STATE) + tiles = self._string_to_34_array(sou='111234567', pin='11', man='567') + self.assertEqual(shanten.calculate_shanten(tiles), Shanten.AGARI_STATE) - tiles = self._string_to_136_array(sou='111345677', pin='11', man='567') - self.assertEqual(shanten.calculate_shanten(self._to_34_array(tiles)), 0) + tiles = self._string_to_34_array(sou='111345677', pin='11', man='567') + self.assertEqual(shanten.calculate_shanten(tiles), 0) - tiles = self._string_to_136_array(sou='111345677', pin='15', man='567') - self.assertEqual(shanten.calculate_shanten(self._to_34_array(tiles)), 1) + tiles = self._string_to_34_array(sou='111345677', pin='15', man='567') + self.assertEqual(shanten.calculate_shanten(tiles), 1) - tiles = self._string_to_136_array(sou='11134567', pin='15', man='1578') - self.assertEqual(shanten.calculate_shanten(self._to_34_array(tiles)), 2) + tiles = self._string_to_34_array(sou='11134567', pin='15', man='1578') + self.assertEqual(shanten.calculate_shanten(tiles), 2) - tiles = self._string_to_136_array(sou='113456', pin='1358', man='1358') - self.assertEqual(shanten.calculate_shanten(self._to_34_array(tiles)), 3) + tiles = self._string_to_34_array(sou='113456', pin='1358', man='1358') + self.assertEqual(shanten.calculate_shanten(tiles), 3) - tiles = self._string_to_136_array(sou='1589', pin='13588', man='1358', honors='1') - self.assertEqual(shanten.calculate_shanten(self._to_34_array(tiles)), 4) + tiles = self._string_to_34_array(sou='1589', pin='13588', man='1358', honors='1') + self.assertEqual(shanten.calculate_shanten(tiles), 4) - tiles = self._string_to_136_array(sou='159', pin='13588', man='1358', honors='12') - self.assertEqual(shanten.calculate_shanten(self._to_34_array(tiles)), 5) + tiles = self._string_to_34_array(sou='159', pin='13588', man='1358', honors='12') + self.assertEqual(shanten.calculate_shanten(tiles), 5) - tiles = self._string_to_136_array(sou='1589', pin='258', man='1358', honors='123') - self.assertEqual(shanten.calculate_shanten(self._to_34_array(tiles)), 6) + tiles = self._string_to_34_array(sou='1589', pin='258', man='1358', honors='123') + self.assertEqual(shanten.calculate_shanten(tiles), 6) - tiles = self._string_to_136_array(sou='11123456788999') - self.assertEqual(shanten.calculate_shanten(self._to_34_array(tiles)), Shanten.AGARI_STATE) + tiles = self._string_to_34_array(sou='11123456788999') + self.assertEqual(shanten.calculate_shanten(tiles), Shanten.AGARI_STATE) - tiles = self._string_to_136_array(sou='11122245679999') - self.assertEqual(shanten.calculate_shanten(self._to_34_array(tiles)), 0) + tiles = self._string_to_34_array(sou='11122245679999') + self.assertEqual(shanten.calculate_shanten(tiles), 0) def test_shanten_number_and_chitoitsu(self): shanten = Shanten() - tiles = self._string_to_136_array(sou='114477', pin='114477', man='77') - self.assertEqual(shanten.calculate_shanten(self._to_34_array(tiles)), Shanten.AGARI_STATE) + tiles = self._string_to_34_array(sou='114477', pin='114477', man='77') + self.assertEqual(shanten.calculate_shanten(tiles), Shanten.AGARI_STATE) - tiles = self._string_to_136_array(sou='114477', pin='114477', man='76') - self.assertEqual(shanten.calculate_shanten(self._to_34_array(tiles)), 0) + tiles = self._string_to_34_array(sou='114477', pin='114477', man='76') + self.assertEqual(shanten.calculate_shanten(tiles), 0) - tiles = self._string_to_136_array(sou='114477', pin='114479', man='76') - self.assertEqual(shanten.calculate_shanten(self._to_34_array(tiles)), 1) + tiles = self._string_to_34_array(sou='114477', pin='114479', man='76') + self.assertEqual(shanten.calculate_shanten(tiles), 1) - tiles = self._string_to_136_array(sou='114477', pin='14479', man='76', honors='1') - self.assertEqual(shanten.calculate_shanten(self._to_34_array(tiles)), 2) + tiles = self._string_to_34_array(sou='114477', pin='14479', man='76', honors='1') + self.assertEqual(shanten.calculate_shanten(tiles), 2) def test_shanten_number_and_kokushi_musou(self): shanten = Shanten() - tiles = self._string_to_136_array(sou='19', pin='19', man='19', honors='12345677') - self.assertEqual(shanten.calculate_shanten(self._to_34_array(tiles)), Shanten.AGARI_STATE) + tiles = self._string_to_34_array(sou='19', pin='19', man='19', honors='12345677') + self.assertEqual(shanten.calculate_shanten(tiles), Shanten.AGARI_STATE) - tiles = self._string_to_136_array(sou='129', pin='19', man='19', honors='1234567') - self.assertEqual(shanten.calculate_shanten(self._to_34_array(tiles)), 0) + tiles = self._string_to_34_array(sou='129', pin='19', man='19', honors='1234567') + self.assertEqual(shanten.calculate_shanten(tiles), 0) - tiles = self._string_to_136_array(sou='129', pin='129', man='19', honors='123456') - self.assertEqual(shanten.calculate_shanten(self._to_34_array(tiles)), 1) + tiles = self._string_to_34_array(sou='129', pin='129', man='19', honors='123456') + self.assertEqual(shanten.calculate_shanten(tiles), 1) - tiles = self._string_to_136_array(sou='129', pin='129', man='129', honors='12345') - self.assertEqual(shanten.calculate_shanten(self._to_34_array(tiles)), 2) + tiles = self._string_to_34_array(sou='129', pin='129', man='129', honors='12345') + self.assertEqual(shanten.calculate_shanten(tiles), 2) def test_shanten_number_and_open_sets(self): shanten = Shanten() - tiles = self._string_to_136_array(sou='44467778', pin='222567') + tiles = self._string_to_34_array(sou='44467778', pin='222567') open_sets = [] - self.assertEqual(shanten.calculate_shanten(self._to_34_array(tiles), melds=open_sets), Shanten.AGARI_STATE) + self.assertEqual(shanten.calculate_shanten(tiles, melds=open_sets), Shanten.AGARI_STATE) open_sets = [self._string_to_open_34_set(sou='777')] - self.assertEqual(shanten.calculate_shanten(self._to_34_array(tiles), melds=open_sets), 2) + self.assertEqual(shanten.calculate_shanten(tiles, melds=open_sets), 0) + + tiles = self._string_to_34_array(sou='23455567', pin='222', man='345') + open_sets = [self._string_to_open_34_set(man='345'), self._string_to_open_34_set(sou='555')] + self.assertEqual(shanten.calculate_shanten(tiles, melds=open_sets), 0) diff --git a/project/mahjong/hand.py b/project/mahjong/hand.py index 2fc7b6b2..f935c47c 100644 --- a/project/mahjong/hand.py +++ b/project/mahjong/hand.py @@ -52,7 +52,7 @@ def estimate_hand_value(self, :param is_chiihou: :param is_daburu_riichi: :param is_nagashi_mangan: - :param open_sets: array of array with open sets in 136-tile format + :param open_sets: array of array with open sets in 34-tile format :param dora_indicators: array of tiles in 136-tile format :param called_kan_indices: array of tiles in 136-tile format :param player_wind: index of player wind @@ -64,14 +64,6 @@ def estimate_hand_value(self, """ if not open_sets: open_sets = [] - else: - # it is important to work with copy of list - open_sets = copy.deepcopy(open_sets) - # cast 136 format to 34 format - for item in open_sets: - item[0] //= 4 - item[1] //= 4 - item[2] //= 4 is_open_hand = len(open_sets) > 0 if not dora_indicators: @@ -123,7 +115,7 @@ def return_response(): tiles_34 = TilesConverter.to_34_array(tiles) divider = HandDivider() - if not agari.is_agari(tiles_34): + if not agari.is_agari(tiles_34, open_sets): error = 'Hand is not winning' return return_response() diff --git a/project/mahjong/player.py b/project/mahjong/player.py index fa3b9e9a..ecde1f7c 100644 --- a/project/mahjong/player.py +++ b/project/mahjong/player.py @@ -2,6 +2,8 @@ import logging from functools import reduce +import copy + from mahjong.constants import EAST, SOUTH, WEST, NORTH from utils.settings_handler import settings from mahjong.ai.shanten import Shanten @@ -166,4 +168,14 @@ def closed_hand(self): @property def meld_tiles(self): - return [x.tiles for x in self.melds][:] + """ + Array of array with 34 tiles indices + :return: array + """ + melds = [x.tiles for x in self.melds] + melds = copy.deepcopy(melds) + for meld in melds: + meld[0] //= 4 + meld[1] //= 4 + meld[2] //= 4 + return melds diff --git a/project/mahjong/tests/tests_yaku_calculation.py b/project/mahjong/tests/tests_yaku_calculation.py index 0220a2cd..fa001a4c 100644 --- a/project/mahjong/tests/tests_yaku_calculation.py +++ b/project/mahjong/tests/tests_yaku_calculation.py @@ -218,8 +218,8 @@ def test_hands_calculation(self): tiles = self._string_to_136_array(pin='112233999', honors='11177') win_tile = self._string_to_136_tile(pin='9') - open_sets = [self._string_to_136_array(honors='111'), self._string_to_136_array(pin='123'), - self._string_to_136_array(pin='123')] + open_sets = [self._string_to_open_34_set(honors='111'), self._string_to_open_34_set(pin='123'), + self._string_to_open_34_set(pin='123')] result = hand.estimate_hand_value(tiles, win_tile, open_sets=open_sets) self.assertEqual(result['fu'], 30) @@ -240,7 +240,7 @@ def test_hands_calculation(self): # one more bug with with dora tiles tiles = self._string_to_136_array(sou='123456678', honors='11555') win_tile = self._string_to_136_tile(sou='6') - open_sets = [self._string_to_136_array(sou='456'), self._string_to_136_array(honors='555')] + open_sets = [self._string_to_open_34_set(sou='456'), self._string_to_open_34_set(honors='555')] dora_indicators = [self._string_to_136_tile(sou='9')] result = hand.estimate_hand_value(tiles, win_tile, is_tsumo=True, open_sets=open_sets, dora_indicators=dora_indicators) @@ -282,7 +282,7 @@ def test_hands_calculation(self): tiles = self._string_to_136_array(sou='111123666789', honors='11') win_tile = self._string_to_136_tile(sou='1') - open_sets = [self._string_to_136_array(sou='666')] + open_sets = [self._string_to_open_34_set(sou='666')] dora_indicators = [self._string_to_136_tile(honors='4')] result = hand.estimate_hand_value(tiles, win_tile, open_sets=open_sets, dora_indicators=dora_indicators, player_wind=player_wind) @@ -291,7 +291,7 @@ def test_hands_calculation(self): tiles = self._string_to_136_array(pin='12333', sou='567', honors='666777') win_tile = self._string_to_136_tile(pin='3') - open_sets = [self._string_to_136_array(honors='666'), self._string_to_136_array(honors='777')] + open_sets = [self._string_to_open_34_set(honors='666'), self._string_to_open_34_set(honors='777')] result = hand.estimate_hand_value(tiles, win_tile, open_sets=open_sets) self.assertEqual(result['fu'], 30) self.assertEqual(result['han'], 2) @@ -304,8 +304,8 @@ def test_hands_calculation(self): tiles = self._string_to_136_array(man='11156677899', honors='777') win_tile = self._string_to_136_tile(man='7') - open_sets = [self._string_to_136_array(honors='777'), self._string_to_136_array(man='111'), - self._string_to_136_array(man='678')] + open_sets = [self._string_to_open_34_set(honors='777'), self._string_to_open_34_set(man='111'), + self._string_to_open_34_set(man='678')] called_kan = [self._string_to_136_tile(honors='7')] result = hand.estimate_hand_value(tiles, win_tile, open_sets=open_sets, called_kan_indices=called_kan) self.assertEqual(result['fu'], 40) @@ -313,22 +313,22 @@ def test_hands_calculation(self): tiles = self._string_to_136_array(man='122223777888', honors='66') win_tile = self._string_to_136_tile(man='2') - open_sets = [self._string_to_136_array(man='123'), self._string_to_136_array(man='777')] + open_sets = [self._string_to_open_34_set(man='123'), self._string_to_open_34_set(man='777')] result = hand.estimate_hand_value(tiles, win_tile, open_sets=open_sets) self.assertEqual(result['fu'], 30) self.assertEqual(result['han'], 2) tiles = self._string_to_136_array(pin='11144678888', honors='444') win_tile = self._string_to_136_tile(pin='8') - open_sets = [self._string_to_136_array(honors='444'), self._string_to_136_array(pin='111'), - self._string_to_136_array(pin='888')] + open_sets = [self._string_to_open_34_set(honors='444'), self._string_to_open_34_set(pin='111'), + self._string_to_open_34_set(pin='888')] result = hand.estimate_hand_value(tiles, win_tile, open_sets=open_sets) self.assertEqual(result['fu'], 30) self.assertEqual(result['han'], 2) tiles = self._string_to_136_array(sou='67778', man='345', pin='999', honors='222') win_tile = self._string_to_136_tile(sou='7') - open_sets = [self._string_to_136_array(sou='123')] + open_sets = [self._string_to_open_34_set(sou='123')] result = hand.estimate_hand_value(tiles, win_tile, is_tsumo=True) self.assertEqual(result['fu'], 40) self.assertEqual(result['han'], 1) @@ -341,7 +341,7 @@ def test_hands_calculation(self): tiles = self._string_to_136_array(pin='112233667788', honors='22') win_tile = self._string_to_136_tile(pin='3') - open_sets = [self._string_to_136_array(pin='123')] + open_sets = [self._string_to_open_34_set(pin='123')] result = hand.estimate_hand_value(tiles, win_tile, open_sets=open_sets) self.assertEqual(result['fu'], 30) self.assertEqual(result['han'], 2) @@ -353,29 +353,29 @@ def test_hands_calculation(self): self.assertEqual(result['han'], 2) tiles = self._string_to_136_array(sou='11123456777888') - open_sets = [self._string_to_136_array(sou='123'), self._string_to_136_array(sou='777'), - self._string_to_136_array(sou='888')] + open_sets = [self._string_to_open_34_set(sou='123'), self._string_to_open_34_set(sou='777'), + self._string_to_open_34_set(sou='888')] win_tile = self._string_to_136_tile(sou='4') result = hand.estimate_hand_value(tiles, win_tile, is_tsumo=True, open_sets=open_sets) self.assertEqual(result['fu'], 30) self.assertEqual(result['han'], 5) tiles = self._string_to_136_array(sou='112233789', honors='55777') - open_sets = [self._string_to_136_array(sou='123')] + open_sets = [self._string_to_open_34_set(sou='123')] win_tile = self._string_to_136_tile(sou='2') result = hand.estimate_hand_value(tiles, win_tile, open_sets=open_sets) self.assertEqual(result['fu'], 40) self.assertEqual(result['han'], 4) tiles = self._string_to_136_array(pin='234777888999', honors='22') - open_sets = [self._string_to_136_array(pin='234'), self._string_to_136_array(pin='789')] + open_sets = [self._string_to_open_34_set(pin='234'), self._string_to_open_34_set(pin='789')] win_tile = self._string_to_136_tile(pin='9') result = hand.estimate_hand_value(tiles, win_tile, open_sets=open_sets) self.assertEqual(result['fu'], 30) self.assertEqual(result['han'], 2) tiles = self._string_to_136_array(pin='77888899', honors='777', man='444') - open_sets = [self._string_to_136_array(honors='777'), self._string_to_136_array(man='444')] + open_sets = [self._string_to_open_34_set(honors='777'), self._string_to_open_34_set(man='444')] win_tile = self._string_to_136_tile(pin='8') result = hand.estimate_hand_value(tiles, win_tile, open_sets=open_sets, is_tsumo=True) self.assertEqual(result['fu'], 30) @@ -389,7 +389,7 @@ def test_hands_calculation(self): tiles = self._string_to_136_array(pin='34567777889', honors='555') win_tile = self._string_to_136_tile(pin='7') - open_sets = [self._string_to_136_array(pin='345')] + open_sets = [self._string_to_open_34_set(pin='345')] result = hand.estimate_hand_value(tiles, win_tile, open_sets=open_sets) self.assertEqual(result['fu'], 30) self.assertEqual(result['han'], 3) @@ -413,7 +413,7 @@ def test_is_riichi(self): self.assertEqual(result['fu'], 40) self.assertEqual(len(result['hand_yaku']), 1) - open_sets = [self._string_to_136_array(sou='123')] + open_sets = [self._string_to_open_34_set(sou='123')] result = hand.estimate_hand_value(tiles, win_tile, is_riichi=True, open_sets=open_sets) self.assertNotEqual(result['error'], None) @@ -430,7 +430,7 @@ def test_is_tsumo(self): self.assertEqual(len(result['hand_yaku']), 1) # with open hand tsumo not giving yaku - open_sets = [self._string_to_136_array(sou='123')] + open_sets = [self._string_to_open_34_set(sou='123')] result = hand.estimate_hand_value(tiles, win_tile, is_tsumo=True, open_sets=open_sets) self.assertNotEqual(result['error'], None) @@ -574,7 +574,7 @@ def test_is_tanyao(self): tiles = self._string_to_136_array(sou='234567', man='234567', pin='22') win_tile = self._string_to_136_tile(man='7') - open_sets = [self._string_to_136_array(sou='234')] + open_sets = [self._string_to_open_34_set(sou='234')] result = hand.estimate_hand_value(tiles, win_tile, open_sets=open_sets) self.assertEqual(result['error'], None) self.assertEqual(result['han'], 1) @@ -585,7 +585,7 @@ def test_is_tanyao(self): tiles = self._string_to_136_array(sou='234567', man='234567', pin='22') win_tile = self._string_to_136_tile(man='7') - open_sets = [self._string_to_136_array(sou='234')] + open_sets = [self._string_to_open_34_set(sou='234')] result = hand.estimate_hand_value(tiles, win_tile, open_sets=open_sets) self.assertNotEqual(result['error'], None) @@ -651,7 +651,7 @@ def test_is_pinfu_hand(self): # open hand tiles = self._string_to_136_array(sou='12399', man='123456', pin='456') win_tile = self._string_to_136_tile(sou='1') - open_sets = [self._string_to_136_array(sou='123')] + open_sets = [self._string_to_open_34_set(sou='123')] result = hand.estimate_hand_value(tiles, win_tile, open_sets=open_sets) self.assertNotEqual(result['error'], None) @@ -670,7 +670,7 @@ def test_is_iipeiko(self): self.assertEqual(result['fu'], 40) self.assertEqual(len(result['hand_yaku']), 1) - open_sets = [self._string_to_136_array(sou='123')] + open_sets = [self._string_to_open_34_set(sou='123')] result = hand.estimate_hand_value(tiles, win_tile, open_sets=open_sets) self.assertNotEqual(result['error'], None) @@ -695,7 +695,7 @@ def test_is_ryanpeiko(self): self.assertEqual(result['fu'], 40) self.assertEqual(len(result['hand_yaku']), 1) - open_sets = [self._string_to_136_array(sou='123')] + open_sets = [self._string_to_open_34_set(sou='123')] result = hand.estimate_hand_value(tiles, win_tile, open_sets=open_sets) self.assertNotEqual(result['error'], None) @@ -717,7 +717,7 @@ def test_is_sanshoku(self): self.assertEqual(result['fu'], 40) self.assertEqual(len(result['hand_yaku']), 1) - open_sets = [self._string_to_136_array(sou='123')] + open_sets = [self._string_to_open_34_set(sou='123')] result = hand.estimate_hand_value(tiles, win_tile, open_sets=open_sets) self.assertEqual(result['error'], None) self.assertEqual(result['han'], 1) @@ -734,7 +734,7 @@ def test_is_sanshoku_douko(self): self.assertFalse(hand.is_sanshoku_douko(self._hand(tiles, 0))) tiles = self._string_to_136_array(sou='222', man='222', pin='22245699') - open_sets = [self._string_to_136_array(sou='222')] + open_sets = [self._string_to_open_34_set(sou='222')] win_tile = self._string_to_136_tile(pin='9') result = hand.estimate_hand_value(tiles, win_tile, open_sets=open_sets) @@ -753,7 +753,7 @@ def test_is_toitoi(self): self.assertTrue(hand.is_toitoi(self._hand(tiles, 1))) tiles = self._string_to_136_array(sou='111333', man='333', pin='44555') - open_sets = [self._string_to_136_array(sou='111'), self._string_to_136_array(sou='333')] + open_sets = [self._string_to_open_34_set(sou='111'), self._string_to_open_34_set(sou='333')] win_tile = self._string_to_136_tile(pin='5') result = hand.estimate_hand_value(tiles, win_tile, open_sets=open_sets) @@ -763,7 +763,7 @@ def test_is_toitoi(self): self.assertEqual(len(result['hand_yaku']), 1) tiles = self._string_to_136_array(sou='777', pin='777888999', honors='44') - open_sets = [self._string_to_136_array(sou='777')] + open_sets = [self._string_to_open_34_set(sou='777')] win_tile = self._string_to_136_tile(pin='9') result = hand.estimate_hand_value(tiles, win_tile, open_sets=open_sets) @@ -781,7 +781,7 @@ def test_is_sankantsu(self): self.assertTrue(hand.is_sankantsu(self._hand(tiles, 0), called_kan_indices)) tiles = self._string_to_136_array(sou='111333', man='123', pin='44666') - open_sets = [self._string_to_136_array(sou='111'), self._string_to_136_array(sou='333')] + open_sets = [self._string_to_open_34_set(sou='111'), self._string_to_open_34_set(sou='333')] win_tile = self._string_to_136_tile(man='3') called_kan_indices = [self._string_to_136_tile(sou='1'), self._string_to_136_tile(sou='3'), @@ -804,7 +804,7 @@ def test_is_honroto(self): tiles = self._string_to_136_array(sou='111999', man='111', honors='11222') win_tile = self._string_to_136_tile(honors='2') - open_sets = [self._string_to_136_array(sou='111')] + open_sets = [self._string_to_open_34_set(sou='111')] result = hand.estimate_hand_value(tiles, win_tile, open_sets=open_sets) self.assertEqual(result['error'], None) @@ -840,7 +840,7 @@ def test_is_sanankou(self): self.assertFalse(hand.is_sanankou(win_tile, self._hand(tiles, 0), open_sets, False)) tiles = self._string_to_136_array(sou='123444', man='333', pin='44555') - open_sets = [self._string_to_136_array(sou='123')] + open_sets = [self._string_to_open_34_set(sou='123')] win_tile = self._string_to_136_tile(pin='5') result = hand.estimate_hand_value(tiles, win_tile, open_sets=open_sets, is_tsumo=True) @@ -885,7 +885,7 @@ def test_is_chanta(self): self.assertEqual(result['fu'], 40) self.assertEqual(len(result['hand_yaku']), 1) - open_sets = [self._string_to_136_array(sou='123')] + open_sets = [self._string_to_open_34_set(sou='123')] result = hand.estimate_hand_value(tiles, win_tile, open_sets=open_sets) self.assertEqual(result['error'], None) self.assertEqual(result['han'], 1) @@ -913,7 +913,7 @@ def test_is_junchan(self): self.assertEqual(result['fu'], 40) self.assertEqual(len(result['hand_yaku']), 1) - open_sets = [self._string_to_136_array(sou='789')] + open_sets = [self._string_to_open_34_set(sou='789')] result = hand.estimate_hand_value(tiles, win_tile, open_sets=open_sets) self.assertEqual(result['error'], None) self.assertEqual(result['han'], 2) @@ -941,7 +941,7 @@ def test_is_honitsu(self): self.assertEqual(result['fu'], 40) self.assertEqual(len(result['hand_yaku']), 1) - open_sets = [self._string_to_136_array(man='123')] + open_sets = [self._string_to_open_34_set(man='123')] result = hand.estimate_hand_value(tiles, win_tile, open_sets=open_sets) self.assertEqual(result['error'], None) self.assertEqual(result['han'], 2) @@ -966,7 +966,7 @@ def test_is_chinitsu(self): self.assertEqual(result['fu'], 40) self.assertEqual(len(result['hand_yaku']), 1) - open_sets = [self._string_to_136_array(man='678')] + open_sets = [self._string_to_open_34_set(man='678')] result = hand.estimate_hand_value(tiles, win_tile, open_sets=open_sets) self.assertEqual(result['error'], None) self.assertEqual(result['han'], 5) @@ -994,7 +994,7 @@ def test_is_ittsu(self): self.assertEqual(result['fu'], 40) self.assertEqual(len(result['hand_yaku']), 1) - open_sets = [self._string_to_136_array(man='123')] + open_sets = [self._string_to_open_34_set(man='123')] result = hand.estimate_hand_value(tiles, win_tile, open_sets=open_sets) self.assertEqual(result['error'], None) self.assertEqual(result['han'], 1) @@ -1185,7 +1185,7 @@ def test_dora_in_hand(self): tiles = self._string_to_136_array(sou='345678', man='456789', honors='55') win_tile = self._string_to_136_tile(sou='5') dora_indicators = [self._string_to_136_tile(sou='5')] - open_sets = [self._string_to_136_array(sou='678')] + open_sets = [self._string_to_open_34_set(sou='678')] result = hand.estimate_hand_value(tiles, win_tile, dora_indicators=dora_indicators, open_sets=open_sets) self.assertNotEqual(result['error'], None) diff --git a/project/mahjong/tests/tests_yakuman_calculation.py b/project/mahjong/tests/tests_yakuman_calculation.py index c8c15b5c..58082c63 100644 --- a/project/mahjong/tests/tests_yakuman_calculation.py +++ b/project/mahjong/tests/tests_yakuman_calculation.py @@ -239,7 +239,7 @@ def test_is_suukantsu(self): tiles = self._string_to_136_array(sou='111333', man='222', pin='44555') win_tile = self._string_to_136_tile(pin='4') - open_sets = [self._string_to_136_array(sou='111'), self._string_to_136_array(sou='333')] + open_sets = [self._string_to_open_34_set(sou='111'), self._string_to_open_34_set(sou='333')] called_kan_indices = [self._string_to_136_tile(sou='1'), self._string_to_136_tile(sou='3'), self._string_to_136_tile(pin='5'), self._string_to_136_tile(man='2')] From 004df1a2a5f23c49e2d49c182edff4133fe643f9 Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Fri, 3 Feb 2017 21:25:39 +0800 Subject: [PATCH 20/80] Update libraries --- project/requirements.txt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/project/requirements.txt b/project/requirements.txt index 5517d796..12b4fdc7 100644 --- a/project/requirements.txt +++ b/project/requirements.txt @@ -1,5 +1,5 @@ -beautifulsoup4==4.4.1 -requests==2.10.0 -terminaltables==3.0.0 -tqdm==4.7.4 -flake8==3.0.4 \ No newline at end of file +beautifulsoup4==4.5.3 +requests==2.13.0 +terminaltables==3.1.0 +tqdm==4.11.2 +flake8==3.2.1 \ No newline at end of file From 267af520c721ab7296706fe288b46c8bdcc3018f Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Sat, 4 Feb 2017 22:30:12 +0800 Subject: [PATCH 21/80] Remove vagrant installation script --- bin/vagrant/install.sh | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 bin/vagrant/install.sh diff --git a/bin/vagrant/install.sh b/bin/vagrant/install.sh deleted file mode 100644 index ed3d1f8c..00000000 --- a/bin/vagrant/install.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/sh -e - -# we need to do it, to have latest version of packages -sudo apt-get update -sudo apt-get -y upgrade - -sudo apt-get -y install python-pip -sudo apt-get -y install python-virtualenv - -# install python libraries -virtualenv --python=python3.5 /home/ubuntu/env/ -/home/ubuntu/env/bin/pip install --upgrade pip -/home/ubuntu/env/bin/pip install -r /vagrant/project/requirements.txt - -# activate virtualenv on login and go to working dir -sh -c "echo 'source /home/ubuntu/env/bin/activate' >> /home/ubuntu/.profile" -sh -c "echo 'cd /vagrant/project' >> /home/ubuntu/.profile" \ No newline at end of file From 7966ca5d33bae5a4c314a55cbf61accfb0537f1b Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Mon, 6 Feb 2017 18:43:12 +0800 Subject: [PATCH 22/80] Allow to save local game in tenhou format --- project/game/game_manager.py | 26 ++++--- project/game/replay.py | 87 --------------------- project/game/replays/__init__.py | 1 + project/game/replays/base.py | 44 +++++++++++ project/game/replays/tenhou.py | 125 +++++++++++++++++++++++++++++++ project/mahjong/yaku.py | 102 ++++++++++++------------- 6 files changed, 239 insertions(+), 146 deletions(-) delete mode 100644 project/game/replay.py create mode 100644 project/game/replays/__init__.py create mode 100644 project/game/replays/base.py create mode 100644 project/game/replays/tenhou.py diff --git a/project/game/game_manager.py b/project/game/game_manager.py index cc8c96f6..7b38753c 100644 --- a/project/game/game_manager.py +++ b/project/game/game_manager.py @@ -1,11 +1,10 @@ # -*- coding: utf-8 -*- import logging from collections import deque - from random import randint, shuffle, random from game.logger import set_up_logging -from game.replay import Replay +from game.replays.tenhou import TenhouReplay as Replay from mahjong.ai.agari import Agari from mahjong.client import Client from mahjong.hand import FinishedHand @@ -54,7 +53,7 @@ def __init__(self, clients): self.agari = Agari() self.finished_hand = FinishedHand() - self.replay = Replay() + self.replay = Replay(self.clients) def init_game(self): """ @@ -136,7 +135,11 @@ def init_round(self): )) logger.info('Players: {0}'.format(self.players_sorted_by_scores())) - self.replay.init_round(self.clients, seed_value, self.dora_indicators[:], self.dealer) + self.replay.init_round(self.dealer, + self.round_number, + self.honba_sticks, + self.riichi_sticks, + self.dora_indicators[0]) def play_round(self): continue_to_play = True @@ -427,9 +430,10 @@ def process_the_end_of_the_round(self, tiles, win_tile, winner, loser, is_tsumo) self.round_number += 1 if winner: + ura_dora = [] # add one more dora for riichi win if winner.player.in_riichi: - self.dora_indicators.append(self.dead_wall[9]) + ura_dora.append(self.dead_wall[9]) hand_value = self.finished_hand.estimate_hand_value(tiles=tiles + [win_tile], win_tile=win_tile, @@ -437,7 +441,7 @@ def process_the_end_of_the_round(self, tiles, win_tile, winner, loser, is_tsumo) is_riichi=winner.player.in_riichi, is_dealer=winner.player.is_dealer, open_sets=winner.player.meld_tiles, - dora_indicators=self.dora_indicators, + dora_indicators=self.dora_indicators + ura_dora, player_wind=winner.player.player_wind, round_wind=winner.player.table.round_wind) @@ -453,11 +457,15 @@ def process_the_end_of_the_round(self, tiles, win_tile, winner, loser, is_tsumo) self.replay.win(winner.seat, loser and loser.seat or winner.seat, + win_tile, + self.honba_sticks, + self.riichi_sticks, hand_value['han'], hand_value['fu'], hand_value['cost'], - ', '.join(str(x) for x in hand_value['hand_yaku']) - ) + hand_value['hand_yaku'], + self.dora_indicators, + ura_dora) riichi_bonus = self.riichi_sticks * 1000 self.riichi_sticks = 0 @@ -531,7 +539,7 @@ def process_the_end_of_the_round(self, tiles, win_tile, winner, loser, is_tsumo) else: client.player.scores -= 3000 / (4 - tempai_users_count) - self.replay.retake(tempai_users) + self.replay.retake(tempai_users, self.honba_sticks, self.riichi_sticks) # if someone has negative scores, # we need to end the game diff --git a/project/game/replay.py b/project/game/replay.py deleted file mode 100644 index 8379b033..00000000 --- a/project/game/replay.py +++ /dev/null @@ -1,87 +0,0 @@ -# -*- coding: utf-8 -*- -import json - -import os -import time - -replays_directory = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'data') -if not os.path.exists(replays_directory): - os.mkdir(replays_directory) - - -class Replay(object): - replay_name = '' - tags = [] - - def init_game(self): - self.tags = [] - self.replay_name = '{}.json'.format(int(time.time())) - - def end_game(self, clients): - self.tags.append({ - 'tag': 'end', - 'scores': [x.player.scores for x in clients] - }) - - with open(os.path.join(replays_directory, self.replay_name), 'w') as f: - f.write(json.dumps(self.tags)) - - def init_round(self, clients, seed, dora_indicators, dealer): - self.tags.append({ - 'tag': 'init', - 'seed': seed, - 'dora': dora_indicators, - 'dealer': dealer, - 'players': [{ - 'seat': x.seat, - 'name': x.player.name, - 'scores': x.player.scores, - 'tiles': x.player.tiles - } for x in clients] - }) - - def draw(self, who, tile): - self.tags.append({ - 'tag': 'draw', - 'who': who, - 'tile': tile - }) - - def discard(self, who, tile): - self.tags.append({ - 'tag': 'discard', - 'who': who, - 'tile': tile - }) - - def riichi(self, who, step): - self.tags.append({ - 'tag': 'riichi', - 'who': who, - 'step': step - }) - - def open_meld(self, who, meld_type, tiles): - self.tags.append({ - 'tag': 'meld', - 'who': who, - 'type': meld_type, - 'tiles': tiles - }) - - def retake(self, tempai_players): - self.tags.append({ - 'tag': 'retake', - 'tempai_players': tempai_players, - }) - - def win(self, who, from_who, han, fu, scores, yaku): - self.tags.append({ - 'tag': 'win', - 'who': who, - 'from_who': from_who, - 'han': han, - 'fu': fu, - 'scores': scores, - 'yaku': yaku, - }) diff --git a/project/game/replays/__init__.py b/project/game/replays/__init__.py new file mode 100644 index 00000000..40a96afc --- /dev/null +++ b/project/game/replays/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/project/game/replays/base.py b/project/game/replays/base.py new file mode 100644 index 00000000..a8104106 --- /dev/null +++ b/project/game/replays/base.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +import os + +replays_directory = os.path.join(os.path.dirname(os.path.realpath(__file__)), '..', 'data') +if not os.path.exists(replays_directory): + os.mkdir(replays_directory) + + +class Replay(object): + replays_directory = '' + replay_name = '' + tags = [] + clients = [] + + def __init__(self, clients): + self.replays_directory = replays_directory + self.clients = clients + + def init_game(self): + raise NotImplemented() + + def end_game(self, clients): + raise NotImplemented() + + def init_round(self, dealer, round_number, honba_sticks, riichi_sticks, dora): + raise NotImplemented() + + def draw(self, who, tile): + raise NotImplemented() + + def discard(self, who, tile): + raise NotImplemented() + + def riichi(self, who, step): + raise NotImplemented() + + def open_meld(self, who, meld_type, tiles): + raise NotImplemented() + + def retake(self, tempai_players, honba_sticks, riichi_sticks): + raise NotImplemented() + + def win(self, who, from_who, win_tile, honba_sticks, riichi_sticks, han, fu, cost, yaku_list, dora, ura_dora): + raise NotImplemented() diff --git a/project/game/replays/tenhou.py b/project/game/replays/tenhou.py new file mode 100644 index 00000000..3d8a87d9 --- /dev/null +++ b/project/game/replays/tenhou.py @@ -0,0 +1,125 @@ +# -*- coding: utf-8 -*- +import os +import time + +from game.replays.base import Replay + + +class TenhouReplay(Replay): + + def init_game(self): + self.tags = [] + self.replay_name = '{}.log'.format(int(time.time())) + + self.tags.append('') + self.tags.append('') + self.tags.append('') + + self.tags.append(''.format(self.clients[0].player.name, + self.clients[1].player.name, + self.clients[2].player.name, + self.clients[3].player.name)) + + self.tags.append('') + + def end_game(self, players): + self.tags.append('') + + with open(os.path.join(self.replays_directory, self.replay_name), 'w') as f: + f.write(''.join(self.tags)) + + def init_round(self, dealer, round_number, honba_sticks, riichi_sticks, dora): + self.tags.append('' + .format(round_number, + honba_sticks, + riichi_sticks, + dora, + self._players_scores(), + dealer, + ','.join([str(x) for x in self.clients[0].player.tiles]), + ','.join([str(x) for x in self.clients[1].player.tiles]), + ','.join([str(x) for x in self.clients[2].player.tiles]), + ','.join([str(x) for x in self.clients[3].player.tiles]))) + + def draw(self, who, tile): + letters = ['T', 'U', 'V', 'W'] + self.tags.append('<{}{}/>'.format(letters[who], tile)) + + def discard(self, who, tile): + letters = ['D', 'E', 'F', 'G'] + self.tags.append('<{}{}/>'.format(letters[who], tile)) + + def riichi(self, who, step): + if step == 1: + self.tags.append(''.format(who)) + else: + self.tags.append(''.format(who, self._players_scores())) + + def open_meld(self, who, meld_type, tiles): + pass + + def retake(self, tempai_players, honba_sticks, riichi_sticks): + hands = '' + for seat in sorted(tempai_players): + hands += 'hai{}="{}" '.format(seat, ','.join(str(x) for x in self.clients[seat].player.closed_hand)) + + scores_results = [] + if len(tempai_players) > 0 and len(tempai_players) != 4: + for client in self.clients: + scores = int(client.player.scores // 100) + if client.seat in tempai_players: + sign = '+' + scores_to_pay = int(30 / len(tempai_players)) + else: + sign = '-' + scores_to_pay = int(30 / (4 - len(tempai_players))) + scores_results.append('{},{}{}'.format(scores, sign, scores_to_pay)) + else: + for client in self.clients: + scores = int(client.player.scores // 100) + scores_results.append('{},0'.format(scores)) + + self.tags.append(''.format( + honba_sticks, + riichi_sticks, + ','.join(scores_results), + hands)) + + def win(self, who, from_who, win_tile, honba_sticks, riichi_sticks, han, fu, cost, yaku_list, dora, ura_dora): + han_key = self.clients[who].player.closed_hand and 'closed' or 'open' + scores = [] + for client in self.clients: + if client.seat == who: + scores.append('{},+{}'.format(int(client.player.scores // 100), int(cost['main'] // 100))) + elif client.seat == from_who and client.seat == who: + payment = int((cost['main'] + cost['additional'] * 2) // 100) + scores.append('{},+{}'.format(int(client.player.scores // 100), payment)) + elif client.seat == from_who: + scores.append('{},-{}'.format(int(client.player.scores // 100), int(cost['main'] // 100))) + else: + scores.append('{},0'.format(int(client.player.scores // 100))) + + yaku_string = ','.join(['{},{}'.format(x.id, x.han[han_key]) for x in yaku_list]) + self.tags.append('' + .format(honba_sticks, + riichi_sticks, + ','.join([str(x) for x in self.clients[who].player.tiles]), + win_tile, + fu, + cost['main'], + yaku_string, + ','.join([str(x) for x in dora]), + ','.join([str(x) for x in ura_dora]), + who, + from_who, + ','.join(scores) + )) + + def _players_scores(self): + return '{},{},{},{}'.format(int(self.clients[0].player.scores // 100), + int(self.clients[1].player.scores // 100), + int(self.clients[2].player.scores // 100), + int(self.clients[3].player.scores // 100)) diff --git a/project/mahjong/yaku.py b/project/mahjong/yaku.py index 4cb8f228..a17b79a3 100644 --- a/project/mahjong/yaku.py +++ b/project/mahjong/yaku.py @@ -1,9 +1,11 @@ class Yaku(object): + yaku_id = None name = '' han = {'open': None, 'closed': None} is_yakuman = False - def __init__(self, name, open_value, closed_value, is_yakuman=False): + def __init__(self, yaku_id, name, open_value, closed_value, is_yakuman=False): + self.id = yaku_id self.name = name self.han = {'open': open_value, 'closed': closed_value} self.is_yakuman = is_yakuman @@ -19,68 +21,68 @@ def __repr__(self): return self.__str__() # Yaku situations -tsumo = Yaku('Menzen Tsumo', None, 1) -riichi = Yaku('Riichi', None, 1) -ippatsu = Yaku('Ippatsu', None, 1) -daburu_riichi = Yaku('Double Riichi', None, 2) -haitei = Yaku('Haitei Raoyue', 1, 1) -houtei = Yaku('Houtei Raoyui', 1, 1) -rinshan = Yaku('Rinshan Kaihou', 1, 1) -chankan = Yaku('Chankan', 1, 1) -nagashi_mangan = Yaku('Nagashi Mangan', 5, 5) -renhou = Yaku('Renhou', None, 5) +tsumo = Yaku(0, 'Menzen Tsumo', None, 1) +riichi = Yaku(1, 'Riichi', None, 1) +ippatsu = Yaku(2, 'Ippatsu', None, 1) +chankan = Yaku(3, 'Chankan', 1, 1) +rinshan = Yaku(4, 'Rinshan Kaihou', 1, 1) +haitei = Yaku(5, 'Haitei Raoyue', 1, 1) +houtei = Yaku(6, 'Houtei Raoyui', 1, 1) +daburu_riichi = Yaku(21, 'Double Riichi', None, 2) +nagashi_mangan = Yaku(-1, 'Nagashi Mangan', 5, 5) +renhou = Yaku(36, 'Renhou', None, 5) # Yaku 1 Hands -pinfu = Yaku('Pinfu', None, 1) -tanyao = Yaku('Tanyao', 1, 1) -iipeiko = Yaku('Iipeiko', None, 1) -haku = Yaku('Yakuhai (haku)', 1, 1) -hatsu = Yaku('Yakuhai (hatsu)', 1, 1) -chun = Yaku('Yakuhai (chun)', 1, 1) -yakuhai_place = Yaku('Yakuhai (wind of place)', 1, 1) -yakuhai_round = Yaku('Yakuhai (wind of round)', 1, 1) +pinfu = Yaku(7, 'Pinfu', None, 1) +tanyao = Yaku(8, 'Tanyao', 1, 1) +iipeiko = Yaku(9, 'Iipeiko', None, 1) +haku = Yaku(18, 'Yakuhai (haku)', 1, 1) +hatsu = Yaku(19, 'Yakuhai (hatsu)', 1, 1) +chun = Yaku(20, 'Yakuhai (chun)', 1, 1) +yakuhai_place = Yaku(10, 'Yakuhai (wind of place)', 1, 1) +yakuhai_round = Yaku(11, 'Yakuhai (wind of round)', 1, 1) # Yaku 2 Hands -sanshoku = Yaku('Sanshoku Doujun', 1, 2) -ittsu = Yaku('Ittsu', 1, 2) -chanta = Yaku('Chanta', 1, 2) -honroto = Yaku('Honroutou', 2, 2) -toitoi = Yaku('Toitoi', 2, 2) -sanankou = Yaku('San Ankou', 2, 2) -sankantsu = Yaku('San Kantsu', 2, 2) -sanshoku_douko = Yaku('Sanshoku Doukou', 2, 2) -chiitoitsu = Yaku('Chiitoitsu', None, 2) -shosangen = Yaku('Shou Sangen', 2, 2) +sanshoku = Yaku(25, 'Sanshoku Doujun', 1, 2) +ittsu = Yaku(24, 'Ittsu', 1, 2) +chanta = Yaku(23, 'Chanta', 1, 2) +honroto = Yaku(31, 'Honroutou', 2, 2) +toitoi = Yaku(28, 'Toitoi', 2, 2) +sanankou = Yaku(29, 'San Ankou', 2, 2) +sankantsu = Yaku(27, 'San Kantsu', 2, 2) +sanshoku_douko = Yaku(26, 'Sanshoku Doukou', 2, 2) +chiitoitsu = Yaku(22, 'Chiitoitsu', None, 2) +shosangen = Yaku(30, 'Shou Sangen', 2, 2) # Yaku 3 Hands -honitsu = Yaku('Honitsu', 2, 3) -junchan = Yaku('Junchan', 2, 3) -ryanpeiko = Yaku('Ryanpeikou', None, 3) +honitsu = Yaku(34, 'Honitsu', 2, 3) +junchan = Yaku(33, 'Junchan', 2, 3) +ryanpeiko = Yaku(32, 'Ryanpeikou', None, 3) # Yaku 6 Hands -chinitsu = Yaku('Chinitsu', 5, 6) +chinitsu = Yaku(35, 'Chinitsu', 5, 6) # Yakuman list -kokushi = Yaku('Kokushi musou', None, 13, True) -chuuren_poutou = Yaku('Chuuren Poutou', None, 13, True) -suuankou = Yaku('Suu ankou', None, 13, True) -daisangen = Yaku('Daisangen', 13, 13, True) -shosuushi = Yaku('Shousuushii', 13, 13, True) -ryuisou = Yaku('Ryuuiisou', 13, 13, True) -suukantsu = Yaku('Suu kantsu', 13, 13, True) -tsuisou = Yaku('Tsuu iisou', 13, 13, True) -chinroto = Yaku('Chinroutou', 13, 13, True) +kokushi = Yaku(47, 'Kokushi musou', None, 13, True) +chuuren_poutou = Yaku(45, 'Chuuren Poutou', None, 13, True) +suuankou = Yaku(40, 'Suu ankou', None, 13, True) +daisangen = Yaku(39, 'Daisangen', 13, 13, True) +shosuushi = Yaku(50, 'Shousuushii', 13, 13, True) +ryuisou = Yaku(43, 'Ryuuiisou', 13, 13, True) +suukantsu = Yaku(51, 'Suu kantsu', 13, 13, True) +tsuisou = Yaku(42, 'Tsuu iisou', 13, 13, True) +chinroto = Yaku(44, 'Chinroutou', 13, 13, True) # Double yakuman -daisuushi = Yaku('Dai Suushii', 26, 26, True) -daburu_kokushi = Yaku('Daburu Kokushi musou', None, 26, True) -suuankou_tanki = Yaku('Suu ankou tanki', None, 26, True) -daburu_chuuren_poutou = Yaku('Daburu Chuuren Poutou', None, 26, True) +daisuushi = Yaku(49, 'Dai Suushii', 26, 26, True) +daburu_kokushi = Yaku(48, 'Daburu Kokushi musou', None, 26, True) +suuankou_tanki = Yaku(41, 'Suu ankou tanki', None, 26, True) +daburu_chuuren_poutou = Yaku(46, 'Daburu Chuuren Poutou', None, 26, True) # Yakuman situations -tenhou = Yaku('Tenhou', None, 13, True) -chiihou = Yaku('Chiihou', None, 13, True) +tenhou = Yaku(37, 'Tenhou', None, 13, True) +chiihou = Yaku(38, 'Chiihou', None, 13, True) # Other -dora = Yaku('Dora', 1, 1) -aka_dora = Yaku('Aka Dora', 1, 1) +dora = Yaku(52, 'Dora', 1, 1) +aka_dora = Yaku(54, 'Aka Dora', 1, 1) From 317a92528846383b8eb329c0cbac735f428a3f0e Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Wed, 8 Feb 2017 12:18:12 +0800 Subject: [PATCH 23/80] Encode melds to the tenhou format --- project/game/game_manager.py | 3 +- project/game/replays/base.py | 2 +- project/game/replays/tenhou.py | 73 +++++++++++++++++++++++++++++++++- project/game/replays/tests.py | 26 ++++++++++++ 4 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 project/game/replays/tests.py diff --git a/project/game/game_manager.py b/project/game/game_manager.py index 7b38753c..67c34e23 100644 --- a/project/game/game_manager.py +++ b/project/game/game_manager.py @@ -223,6 +223,7 @@ def play_round(self): other_client.seat - current_client.seat) if meld: + meld.from_who = other_client.seat possible_melds.append({ 'meld': meld, 'discarded_tile': discarded_tile, @@ -250,7 +251,7 @@ def play_round(self): current_client.add_called_meld(meld) current_client.player.tiles.append(tile) - self.replay.open_meld(current_client.seat, meld.type, meld.tiles) + self.replay.open_meld(current_client.seat, meld) # we need to double validate that we are doing fine if tile_to_discard not in current_client.player.closed_hand: diff --git a/project/game/replays/base.py b/project/game/replays/base.py index a8104106..d5e73266 100644 --- a/project/game/replays/base.py +++ b/project/game/replays/base.py @@ -34,7 +34,7 @@ def discard(self, who, tile): def riichi(self, who, step): raise NotImplemented() - def open_meld(self, who, meld_type, tiles): + def open_meld(self, who, meld): raise NotImplemented() def retake(self, tempai_players, honba_sticks, riichi_sticks): diff --git a/project/game/replays/tenhou.py b/project/game/replays/tenhou.py index 3d8a87d9..e0453752 100644 --- a/project/game/replays/tenhou.py +++ b/project/game/replays/tenhou.py @@ -3,6 +3,7 @@ import time from game.replays.base import Replay +from mahjong.meld import Meld class TenhouReplay(Replay): @@ -57,8 +58,8 @@ def riichi(self, who, step): else: self.tags.append(''.format(who, self._players_scores())) - def open_meld(self, who, meld_type, tiles): - pass + def open_meld(self, who, meld): + self.tags.append(''.format(who, self._encode_meld(meld))) def retake(self, tempai_players, honba_sticks, riichi_sticks): hands = '' @@ -123,3 +124,71 @@ def _players_scores(self): int(self.clients[1].player.scores // 100), int(self.clients[2].player.scores // 100), int(self.clients[3].player.scores // 100)) + + def _encode_meld(self, meld): + if meld.type == Meld.CHI: + return self._encode_chi(meld) + if meld.type == Meld.PON: + return self._encode_pon(meld) + return '' + + def _encode_chi(self, meld): + result = [] + result.insert(0, self._to_binary_string(meld.from_who, 2)) + # it was a chi + result.insert(0, '1') + + tiles = sorted(meld.tiles[:]) + base = int(tiles[0] / 4) + + t0 = tiles[0] - base * 4 + t1 = tiles[1] - 4 - base * 4 + t2 = tiles[2] - 8 - base * 4 + + result.insert(0, self._to_binary_string(t0, 2)) + result.insert(0, self._to_binary_string(t1, 2)) + result.insert(0, self._to_binary_string(t2, 2)) + + # chi format + result.insert(0, '0') + + base_and_called = int(((base / 9) * 7 + base % 9) * 3) + result.insert(0, self._to_binary_string(base_and_called)) + + # convert bytes to int + result = int(''.join(result), 2) + return str(result) + + def _encode_pon(self, meld): + result = [] + result.insert(0, self._to_binary_string(meld.from_who, 2)) + # not a chi + result.insert(0, '0') + # pon + result.insert(0, '1') + # not a chankan + result.insert(0, '0') + # tile for chankan + result.insert(0, '00') + # just zero for format + result.insert(0, '00') + + tiles = sorted(meld.tiles[:]) + base = int(tiles[0] / 4) + + # for us it is not important what tile was called for now + called = 1 + base_and_called = base * 3 + called + result.insert(0, self._to_binary_string(base_and_called)) + + # convert bytes to int + result = int(''.join(result), 2) + return str(result) + + def _to_binary_string(self, number, size=None): + result = bin(number).replace('0b', '') + # some bytes had to be with a fixed size + if size and len(result) < size: + while len(result) < size: + result = '0' + result + return result diff --git a/project/game/replays/tests.py b/project/game/replays/tests.py new file mode 100644 index 00000000..9250e9c6 --- /dev/null +++ b/project/game/replays/tests.py @@ -0,0 +1,26 @@ +import unittest + +from game.replays.tenhou import TenhouReplay +from mahjong.meld import Meld +from utils.tests import TestMixin + + +class TenhouReplayTestCase(unittest.TestCase, TestMixin): + + def test_encode_called_chi(self): + # 234p + meld = self._make_meld(Meld.CHI, [42, 44, 51]) + meld.from_who = 3 + replay = TenhouReplay([]) + + result = replay._encode_meld(meld) + self.assertEqual(result, '27031') + + def test_encode_called_pon(self): + # 555s + meld = self._make_meld(Meld.PON, [89, 90, 91]) + meld.from_who = 2 + replay = TenhouReplay([]) + + result = replay._encode_meld(meld) + self.assertEqual(result, '34314') From 524e7b2e1f1363651a3c028b0b699c17440cd0e9 Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Wed, 8 Feb 2017 12:40:12 +0800 Subject: [PATCH 24/80] Fix an issue with wrong loser displaying in the log --- project/game/game_manager.py | 9 +++++++-- project/game/replays/base.py | 2 +- project/game/replays/tenhou.py | 2 +- project/game/tests.py | 9 ++++++--- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/project/game/game_manager.py b/project/game/game_manager.py index 67c34e23..2533bc1b 100644 --- a/project/game/game_manager.py +++ b/project/game/game_manager.py @@ -340,7 +340,7 @@ def play_game(self, total_results): played_rounds += 1 self.recalculate_players_position() - self.replay.end_game(self.clients) + self.replay.end_game() logger.info('Final Scores: {0}'.format(self.players_sorted_by_scores())) logger.info('The end of the game') @@ -456,8 +456,13 @@ def process_the_end_of_the_round(self, tiles, win_tile, winner, loser, is_tsumo) logger.info('Dora indicators: {}'.format(TilesConverter.to_one_line_string(self.dora_indicators))) logger.info('Hand yaku: {}'.format(', '.join(str(x) for x in hand_value['hand_yaku']))) + if loser is not None: + loser_seat = loser.seat + else: + # tsumo + loser_seat = winner.seat self.replay.win(winner.seat, - loser and loser.seat or winner.seat, + loser_seat, win_tile, self.honba_sticks, self.riichi_sticks, diff --git a/project/game/replays/base.py b/project/game/replays/base.py index d5e73266..94e5e11b 100644 --- a/project/game/replays/base.py +++ b/project/game/replays/base.py @@ -19,7 +19,7 @@ def __init__(self, clients): def init_game(self): raise NotImplemented() - def end_game(self, clients): + def end_game(self): raise NotImplemented() def init_round(self, dealer, round_number, honba_sticks, riichi_sticks, dora): diff --git a/project/game/replays/tenhou.py b/project/game/replays/tenhou.py index e0453752..1b5bb37c 100644 --- a/project/game/replays/tenhou.py +++ b/project/game/replays/tenhou.py @@ -24,7 +24,7 @@ def init_game(self): self.tags.append('') - def end_game(self, players): + def end_game(self): self.tags.append('') with open(os.path.join(self.replays_directory, self.replay_name), 'w') as f: diff --git a/project/game/tests.py b/project/game/tests.py index 3a152a8c..eff73e46 100644 --- a/project/game/tests.py +++ b/project/game/tests.py @@ -15,18 +15,21 @@ def setUp(self): logger.disabled = False # def test_debug(self): - # game.game_manager.shuffle_seed = lambda: 0.2903675156646588 + # game.game_manager.shuffle_seed = lambda: 0.9202601138502954 # # clients = [Client(use_previous_ai_version=False) for _ in range(0, 4)] # # clients = [Client(use_previous_ai_version=True) for _ in range(0, 3)] # # clients += [Client(use_previous_ai_version=False)] # manager = GameManager(clients) + # manager.replay.init_game() # manager.init_game() - # manager.set_dealer(0) - # manager._unique_dealers = 8 + # manager.set_dealer(3) + # manager._unique_dealers = 1 # manager.init_round() # # result = manager.play_round() + # + # manager.replay.end_game() def test_init_game(self): clients = [Client() for _ in range(0, 4)] From cfcf7bf1abc6132cae0ff0bfcf5ed266a367e702 Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Thu, 9 Feb 2017 13:06:39 +0800 Subject: [PATCH 25/80] Various fixes in tenhou logs --- project/bots_battle.py | 2 +- project/game/game_manager.py | 21 ++-- project/game/replays/base.py | 2 +- project/game/replays/tenhou.py | 101 +++++++++++++------ project/game/replays/tests.py | 34 +++++-- project/game/tests.py | 8 +- project/mahjong/ai/main.py | 4 +- project/mahjong/ai/strategies/main.py | 6 +- project/mahjong/ai/tests/tests_ai.py | 8 +- project/mahjong/ai/tests/tests_strategies.py | 24 ++--- project/mahjong/client.py | 2 +- project/mahjong/meld.py | 1 + project/mahjong/player.py | 4 +- project/mahjong/tests/tests_client.py | 3 +- 14 files changed, 140 insertions(+), 80 deletions(-) diff --git a/project/bots_battle.py b/project/bots_battle.py index f7cbf25e..d95b870e 100644 --- a/project/bots_battle.py +++ b/project/bots_battle.py @@ -10,7 +10,7 @@ from game.game_manager import GameManager from mahjong.client import Client -TOTAL_HANCHANS = 20 +TOTAL_HANCHANS = 1 def main(): diff --git a/project/game/game_manager.py b/project/game/game_manager.py index 2533bc1b..8784d6b6 100644 --- a/project/game/game_manager.py +++ b/project/game/game_manager.py @@ -124,6 +124,7 @@ def init_round(self): for client in self.clients: client.player.tiles += self._cut_tiles(1) + client.player.tiles = sorted(client.player.tiles) client.init_hand(client.player.tiles) logger.info('Seed: {0}'.format(shuffle_seed())) @@ -219,15 +220,21 @@ def play_round(self): if other_client == current_client or other_client.player.in_riichi: continue - meld, discarded_tile = other_client.player.try_to_call_meld(tile, - other_client.seat - current_client.seat) + # was a tile discarded by the left player or not + if other_client.seat == 0: + is_kamicha_discard = current_client.seat == 3 + else: + is_kamicha_discard = other_client.seat - current_client.seat == 1 + + meld, discarded_tile = other_client.player.try_to_call_meld(tile, is_kamicha_discard) if meld: - meld.from_who = other_client.seat + meld.from_who = current_client.seat + meld.who = other_client.seat + meld.called_tile = tile possible_melds.append({ 'meld': meld, 'discarded_tile': discarded_tile, - 'who': other_client.seat }) if possible_melds: @@ -238,7 +245,7 @@ def play_round(self): meld = possible_melds[0]['meld'] # we changed current client with called open set - self.current_client_seat = possible_melds[0]['who'] + self.current_client_seat = meld.who current_client = self._get_current_client() self.players_with_open_hands.append(self.current_client_seat) @@ -251,14 +258,14 @@ def play_round(self): current_client.add_called_meld(meld) current_client.player.tiles.append(tile) - self.replay.open_meld(current_client.seat, meld) + self.replay.open_meld(meld) # we need to double validate that we are doing fine if tile_to_discard not in current_client.player.closed_hand: raise ValueError("We can't discard a tile from the opened part of the hand") current_client.discard_tile(tile_to_discard) - self.replay.discard(current_client.seat, tile) + self.replay.discard(current_client.seat, tile_to_discard) logger.info('Discard tile: {}'.format(TilesConverter.to_one_line_string([tile_to_discard]))) # the end of the round diff --git a/project/game/replays/base.py b/project/game/replays/base.py index 94e5e11b..ec2ea189 100644 --- a/project/game/replays/base.py +++ b/project/game/replays/base.py @@ -34,7 +34,7 @@ def discard(self, who, tile): def riichi(self, who, step): raise NotImplemented() - def open_meld(self, who, meld): + def open_meld(self, meld): raise NotImplemented() def retake(self, tempai_players, honba_sticks, riichi_sticks): diff --git a/project/game/replays/tenhou.py b/project/game/replays/tenhou.py index 1b5bb37c..ac11141e 100644 --- a/project/game/replays/tenhou.py +++ b/project/game/replays/tenhou.py @@ -58,8 +58,8 @@ def riichi(self, who, step): else: self.tags.append(''.format(who, self._players_scores())) - def open_meld(self, who, meld): - self.tags.append(''.format(who, self._encode_meld(meld))) + def open_meld(self, meld): + self.tags.append(''.format(meld.who, self._encode_meld(meld))) def retake(self, tempai_players, honba_sticks, riichi_sticks): hands = '' @@ -92,13 +92,29 @@ def win(self, who, from_who, win_tile, honba_sticks, riichi_sticks, han, fu, cos han_key = self.clients[who].player.closed_hand and 'closed' or 'open' scores = [] for client in self.clients: - if client.seat == who: - scores.append('{},+{}'.format(int(client.player.scores // 100), int(cost['main'] // 100))) - elif client.seat == from_who and client.seat == who: - payment = int((cost['main'] + cost['additional'] * 2) // 100) - scores.append('{},+{}'.format(int(client.player.scores // 100), payment)) + # tsumo lose + if from_who == who and client.seat != who: + if client.player.is_dealer: + payment = cost['main'] + honba_sticks * 100 + else: + payment = cost['additional'] + honba_sticks * 100 + scores.append('{},-{}'.format(int(client.player.scores // 100), int(payment // 100))) + # tsumo win + elif client.seat == who and client.seat == from_who: + if client.player.is_dealer: + payment = cost['main'] * 3 + else: + payment = cost['main'] + cost['additional'] * 2 + payment += honba_sticks * 300 + riichi_sticks * 1000 + scores.append('{},+{}'.format(int(client.player.scores // 100), int(payment // 100))) + # ron win + elif client.seat == who: + payment = cost['main'] + honba_sticks * 300 + riichi_sticks * 1000 + scores.append('{},+{}'.format(int(client.player.scores // 100), int(payment // 100))) + # ron lose elif client.seat == from_who: - scores.append('{},-{}'.format(int(client.player.scores // 100), int(cost['main'] // 100))) + payment = cost['main'] + honba_sticks * 300 + scores.append('{},-{}'.format(int(client.player.scores // 100), int(payment // 100))) else: scores.append('{},0'.format(int(client.player.scores // 100))) @@ -134,52 +150,67 @@ def _encode_meld(self, meld): def _encode_chi(self, meld): result = [] - result.insert(0, self._to_binary_string(meld.from_who, 2)) - # it was a chi - result.insert(0, '1') tiles = sorted(meld.tiles[:]) base = int(tiles[0] / 4) + called = tiles.index(meld.called_tile) + base_and_called = int(((base // 9) * 7 + base % 9) * 3) + called + result.append(self._to_binary_string(base_and_called)) + + # chi format + result.append('0') + t0 = tiles[0] - base * 4 t1 = tiles[1] - 4 - base * 4 t2 = tiles[2] - 8 - base * 4 - result.insert(0, self._to_binary_string(t0, 2)) - result.insert(0, self._to_binary_string(t1, 2)) - result.insert(0, self._to_binary_string(t2, 2)) + result.append(self._to_binary_string(t2, 2)) + result.append(self._to_binary_string(t1, 2)) + result.append(self._to_binary_string(t0, 2)) - # chi format - result.insert(0, '0') + # it was a chi + result.append('1') - base_and_called = int(((base / 9) * 7 + base % 9) * 3) - result.insert(0, self._to_binary_string(base_and_called)) + offset = self._from_who_offset(meld.who, meld.from_who) + result.append(self._to_binary_string(offset, 2)) # convert bytes to int result = int(''.join(result), 2) + return str(result) def _encode_pon(self, meld): result = [] - result.insert(0, self._to_binary_string(meld.from_who, 2)) - # not a chi - result.insert(0, '0') - # pon - result.insert(0, '1') - # not a chankan - result.insert(0, '0') - # tile for chankan - result.insert(0, '00') - # just zero for format - result.insert(0, '00') tiles = sorted(meld.tiles[:]) base = int(tiles[0] / 4) - # for us it is not important what tile was called for now - called = 1 + called = tiles.index(meld.called_tile) base_and_called = base * 3 + called - result.insert(0, self._to_binary_string(base_and_called)) + result.append(self._to_binary_string(base_and_called)) + + # just zero for format + result.append('00') + + delta_array = [[1, 2, 3], [0, 2, 3], [0, 1, 3], [0, 1, 2]] + delta = [] + for x in range(0, 3): + delta.append(tiles[x] - base * 4) + delta_index = delta_array.index(delta) + result.append(self._to_binary_string(delta_index, 2)) + + # not a chankan + result.append('0') + + # pon + result.append('1') + + # not a chi + result.append('0') + + offset = self._from_who_offset(meld.who, meld.from_who) + result.append(self._to_binary_string(offset, 2)) # convert bytes to int result = int(''.join(result), 2) @@ -192,3 +223,9 @@ def _to_binary_string(self, number, size=None): while len(result) < size: result = '0' + result return result + + def _from_who_offset(self, who, from_who): + result = from_who - who + if result < 0: + result += 4 + return result diff --git a/project/game/replays/tests.py b/project/game/replays/tests.py index 9250e9c6..1e9a24c0 100644 --- a/project/game/replays/tests.py +++ b/project/game/replays/tests.py @@ -8,19 +8,39 @@ class TenhouReplayTestCase(unittest.TestCase, TestMixin): def test_encode_called_chi(self): - # 234p - meld = self._make_meld(Meld.CHI, [42, 44, 51]) - meld.from_who = 3 + meld = self._make_meld(Meld.CHI, [26, 29, 35]) + meld.who = 3 + meld.from_who = 2 + meld.called_tile = 29 + replay = TenhouReplay([]) + + result = replay._encode_meld(meld) + self.assertEqual(result, '19895') + + meld = self._make_meld(Meld.CHI, [4, 11, 13]) + meld.who = 1 + meld.from_who = 0 + meld.called_tile = 4 replay = TenhouReplay([]) result = replay._encode_meld(meld) - self.assertEqual(result, '27031') + self.assertEqual(result, '3303') def test_encode_called_pon(self): - # 555s - meld = self._make_meld(Meld.PON, [89, 90, 91]) + meld = self._make_meld(Meld.PON, [104, 105, 107]) + meld.who = 0 + meld.from_who = 1 + meld.called_tile = 105 + replay = TenhouReplay([]) + + result = replay._encode_meld(meld) + self.assertEqual(result, '40521') + + meld = self._make_meld(Meld.PON, [124, 126, 127]) + meld.who = 0 meld.from_who = 2 + meld.called_tile = 124 replay = TenhouReplay([]) result = replay._encode_meld(meld) - self.assertEqual(result, '34314') + self.assertEqual(result, '47658') diff --git a/project/game/tests.py b/project/game/tests.py index eff73e46..5418f82e 100644 --- a/project/game/tests.py +++ b/project/game/tests.py @@ -15,7 +15,7 @@ def setUp(self): logger.disabled = False # def test_debug(self): - # game.game_manager.shuffle_seed = lambda: 0.9202601138502954 + # game.game_manager.shuffle_seed = lambda: 0.08673742015192998 # # clients = [Client(use_previous_ai_version=False) for _ in range(0, 4)] # # clients = [Client(use_previous_ai_version=True) for _ in range(0, 3)] @@ -23,8 +23,8 @@ def setUp(self): # manager = GameManager(clients) # manager.replay.init_game() # manager.init_game() - # manager.set_dealer(3) - # manager._unique_dealers = 1 + # manager.set_dealer(2) + # manager._unique_dealers = 4 # manager.init_round() # # result = manager.play_round() @@ -251,7 +251,7 @@ def test_play_round_and_open_yakuhai_hand(self): result = manager.play_round() - self.assertEqual(len(result['players_with_open_hands']), 2) + self.assertEqual(len(result['players_with_open_hands']), 1) def test_scores_calculations_after_retake(self): clients = [Client() for _ in range(0, 4)] diff --git a/project/mahjong/ai/main.py b/project/mahjong/ai/main.py index f0f971f1..8280abec 100644 --- a/project/mahjong/ai/main.py +++ b/project/mahjong/ai/main.py @@ -157,11 +157,11 @@ def count_tiles(self, raw_data, tiles): n += 4 - tiles[raw_data[i]] return n - def try_to_call_meld(self, tile, enemy_seat): + def try_to_call_meld(self, tile, is_kamicha_discard): if not self.current_strategy: return None, None - return self.current_strategy.try_to_call_meld(tile, enemy_seat) + return self.current_strategy.try_to_call_meld(tile, is_kamicha_discard) def determine_strategy(self): # for already opened hand we don't need to give up on selected strategy diff --git a/project/mahjong/ai/strategies/main.py b/project/mahjong/ai/strategies/main.py index b0289961..3b0feb34 100644 --- a/project/mahjong/ai/strategies/main.py +++ b/project/mahjong/ai/strategies/main.py @@ -60,7 +60,7 @@ def determine_what_to_discard(self, closed_hand, outs_results, shanten, for_open break return tile_to_discard - def try_to_call_meld(self, tile, enemy_seat): + def try_to_call_meld(self, tile, is_kamicha_discard): """ Determine should we call a meld or not. If yes, it will return Meld object and tile to discard @@ -82,8 +82,6 @@ def try_to_call_meld(self, tile, enemy_seat): return None, None discarded_tile = tile // 4 - # previous player - is_kamicha_discard = self.player.seat - 1 == enemy_seat or self.player.seat == 0 and enemy_seat == 3 new_tiles = self.player.tiles[:] + [tile] # we need to calculate count of shanten with open hand condition @@ -179,8 +177,6 @@ def try_to_call_meld(self, tile, enemy_seat): ] meld = Meld() - meld.who = self.player.seat - meld.from_who = enemy_seat meld.type = meld_type meld.tiles = sorted(tiles) diff --git a/project/mahjong/ai/tests/tests_ai.py b/project/mahjong/ai/tests/tests_ai.py index 44a32437..5437387b 100644 --- a/project/mahjong/ai/tests/tests_ai.py +++ b/project/mahjong/ai/tests/tests_ai.py @@ -121,7 +121,7 @@ def test_not_open_hand_in_riichi(self): tiles = self._string_to_136_array(sou='12368', pin='2358', honors='4455') tile = self._string_to_136_tile(honors='5') player.init_hand(tiles) - meld, _ = player.try_to_call_meld(tile, 3) + meld, _ = player.try_to_call_meld(tile, False) self.assertEqual(meld, None) def test_chose_right_set_to_open_hand(self): @@ -135,7 +135,7 @@ def test_chose_right_set_to_open_hand(self): tiles = self._string_to_136_array(man='23455', pin='3445678', honors='1') tile = self._string_to_136_tile(man='5') player.init_hand(tiles) - meld, _ = player.try_to_call_meld(tile, 3) + meld, _ = player.try_to_call_meld(tile, True) self.assertNotEqual(meld, None) self.assertEqual(meld.type, Meld.PON) self.assertEqual(self._to_string(meld.tiles), '555m') @@ -143,7 +143,7 @@ def test_chose_right_set_to_open_hand(self): tiles = self._string_to_136_array(man='335666', pin='22', sou='345', honors='55') tile = self._string_to_136_tile(man='4') player.init_hand(tiles) - meld, _ = player.try_to_call_meld(tile, 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), '345m') @@ -151,7 +151,7 @@ def test_chose_right_set_to_open_hand(self): tiles = self._string_to_136_array(man='23557', pin='556788', honors='22') tile = self._string_to_136_tile(pin='5') player.init_hand(tiles) - meld, _ = player.try_to_call_meld(tile, 3) + meld, _ = player.try_to_call_meld(tile, True) self.assertNotEqual(meld, None) self.assertEqual(meld.type, Meld.PON) self.assertEqual(self._to_string(meld.tiles), '555p') diff --git a/project/mahjong/ai/tests/tests_strategies.py b/project/mahjong/ai/tests/tests_strategies.py index 466cef21..a34c9910 100644 --- a/project/mahjong/ai/tests/tests_strategies.py +++ b/project/mahjong/ai/tests/tests_strategies.py @@ -54,14 +54,14 @@ def test_open_hand_with_yakuhai_pair_in_hand(self): player.init_hand(tiles) # we don't need to open hand with not our wind - meld, _ = player.try_to_call_meld(tile, 3) + 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, 3) + meld, _ = player.try_to_call_meld(tile, False) self.assertNotEqual(meld, None) player.add_called_meld(meld) player.tiles.append(tile) @@ -73,7 +73,7 @@ def test_open_hand_with_yakuhai_pair_in_hand(self): player.discard_tile() tile = self._string_to_136_tile(honors='5') - meld, _ = player.try_to_call_meld(tile, 3) + meld, _ = player.try_to_call_meld(tile, False) self.assertNotEqual(meld, None) player.add_called_meld(meld) player.tiles.append(tile) @@ -86,10 +86,10 @@ def test_open_hand_with_yakuhai_pair_in_hand(self): tile = self._string_to_136_tile(sou='7') # we can call chi only from left player - meld, _ = player.try_to_call_meld(tile, 2) + meld, _ = player.try_to_call_meld(tile, False) self.assertEqual(meld, None) - meld, _ = player.try_to_call_meld(tile, 3) + meld, _ = player.try_to_call_meld(tile, True) self.assertNotEqual(meld, None) player.add_called_meld(meld) player.tiles.append(tile) @@ -112,11 +112,11 @@ def test_force_yakuhai_pair_waiting_for_tempai_hand(self): player.init_hand(tiles) # we will not get tempai on yakuhai pair with this hand, so let's skip this call - meld, _ = player.try_to_call_meld(tile, 3) + meld, _ = player.try_to_call_meld(tile, False) self.assertEqual(meld, None) tile = self._string_to_136_tile(man='7') - meld, tile_to_discard = player.try_to_call_meld(tile, 3) + meld, tile_to_discard = 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') @@ -174,12 +174,12 @@ def test_open_hand_and_discard_tiles_logic(self): # 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, 3) + 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, 3) + meld, _ = player.try_to_call_meld(tile, False) self.assertNotEqual(meld, None) tile = self._string_to_136_tile(man='1') @@ -292,14 +292,14 @@ def test_dont_open_hand_with_high_shanten(self): 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, 3) + 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, 3) + meld, _ = player.try_to_call_meld(tile, True) self.assertNotEqual(meld, None) def test_dont_open_hand_with_not_suitable_melds(self): @@ -309,5 +309,5 @@ def test_dont_open_hand_with_not_suitable_melds(self): 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, 3) + meld, _ = player.try_to_call_meld(tile, False) self.assertEqual(meld, None) diff --git a/project/mahjong/client.py b/project/mahjong/client.py index 50625bb5..20dafdc2 100644 --- a/project/mahjong/client.py +++ b/project/mahjong/client.py @@ -39,7 +39,7 @@ def add_called_meld(self, meld): # that he discards tile from hand, not from wall self.table.count_of_remaining_tiles += 1 - return self.table.get_player(meld.who).add_called_meld(meld) + return self.player.add_called_meld(meld) def enemy_discard(self, tile, player_seat): """ diff --git a/project/mahjong/meld.py b/project/mahjong/meld.py index ef9162b2..0e0631f1 100644 --- a/project/mahjong/meld.py +++ b/project/mahjong/meld.py @@ -13,6 +13,7 @@ class Meld(object): tiles = [] type = None from_who = None + called_tile = None def __str__(self): return 'Type: {}, Tiles: {} {}'.format(self.type, TilesConverter.to_one_line_string(self.tiles), self.tiles) diff --git a/project/mahjong/player.py b/project/mahjong/player.py index ecde1f7c..39897bf6 100644 --- a/project/mahjong/player.py +++ b/project/mahjong/player.py @@ -135,8 +135,8 @@ def can_call_riichi(self): self.table.count_of_remaining_tiles > 4 ]) - def try_to_call_meld(self, tile, enemy_seat): - return self.ai.try_to_call_meld(tile, enemy_seat) + def try_to_call_meld(self, tile, is_kamicha_discard): + return self.ai.try_to_call_meld(tile, is_kamicha_discard) @property def player_wind(self): diff --git a/project/mahjong/tests/tests_client.py b/project/mahjong/tests/tests_client.py index e033e102..63f975a4 100644 --- a/project/mahjong/tests/tests_client.py +++ b/project/mahjong/tests/tests_client.py @@ -39,11 +39,10 @@ def test_call_meld(self): self.assertEqual(client.table.count_of_remaining_tiles, 70) meld = Meld() - meld.who = 3 client.add_called_meld(meld) - self.assertEqual(len(client.table.get_player(3).melds), 1) + self.assertEqual(len(client.player.melds), 1) self.assertEqual(client.table.count_of_remaining_tiles, 71) def test_enemy_discard(self): From 9e9e33691857a8cc533dbc5edd8c694b231682e4 Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Thu, 9 Feb 2017 13:52:46 +0800 Subject: [PATCH 26/80] Don't try to open hand with 5+ pairs --- project/game/game_manager.py | 15 ++++++++++++--- project/mahjong/ai/strategies/honitsu.py | 4 ++++ project/mahjong/ai/strategies/main.py | 7 +++++-- project/mahjong/ai/strategies/tanyao.py | 4 ++++ project/mahjong/ai/strategies/yakuhai.py | 10 ++++++---- project/mahjong/ai/tests/tests_strategies.py | 14 ++++++++++++++ 6 files changed, 45 insertions(+), 9 deletions(-) diff --git a/project/game/game_manager.py b/project/game/game_manager.py index 8784d6b6..066e1b90 100644 --- a/project/game/game_manager.py +++ b/project/game/game_manager.py @@ -250,10 +250,16 @@ def play_round(self): self.players_with_open_hands.append(self.current_client_seat) logger.info('Called meld: {} by {}'.format(meld, current_client.player.name)) - logger.info('With hand: {} + {}'.format( - TilesConverter.to_one_line_string(current_client.player.tiles), + hand_string = 'With hand: {} + {}'.format( + TilesConverter.to_one_line_string(current_client.player.closed_hand), TilesConverter.to_one_line_string([tile]) - )) + ) + if current_client.player.is_open_hand: + melds = [] + for meld in current_client.player.melds: + melds.append('{}'.format(TilesConverter.to_one_line_string(meld.tiles))) + hand_string += ' [{}]'.format(', '.join(melds)) + logger.info(hand_string) current_client.add_called_meld(meld) current_client.player.tiles.append(tile) @@ -400,6 +406,9 @@ def call_riichi(self, client): client.enemy_riichi(who_called_riichi - client.seat) logger.info('Riichi: {0} -1,000'.format(self.clients[who_called_riichi].player.name)) + logger.info('With hand: {}'.format( + TilesConverter.to_one_line_string(client.player.closed_hand) + )) def set_dealer(self, dealer): self.dealer = dealer diff --git a/project/mahjong/ai/strategies/honitsu.py b/project/mahjong/ai/strategies/honitsu.py index b14924f4..b5cbef02 100644 --- a/project/mahjong/ai/strategies/honitsu.py +++ b/project/mahjong/ai/strategies/honitsu.py @@ -15,6 +15,10 @@ def should_activate_strategy(self): :return: boolean """ + result = super(HonitsuStrategy, self).should_activate_strategy() + if not result: + return False + suits = [ {'count': 0, 'name': 'sou', 'function': is_sou}, {'count': 0, 'name': 'man', 'function': is_man}, diff --git a/project/mahjong/ai/strategies/main.py b/project/mahjong/ai/strategies/main.py index 3b0feb34..0ddcb5d6 100644 --- a/project/mahjong/ai/strategies/main.py +++ b/project/mahjong/ai/strategies/main.py @@ -31,10 +31,13 @@ def __str__(self): def should_activate_strategy(self): """ Based on player hand and table situation - we can determine should we use this strategy or not + we can determine should we use this strategy or not. + For now default rule for all strategies: don't open hand with 5+ pairs :return: boolean """ - raise NotImplemented() + 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 count_of_pairs < 5 def is_tile_suitable(self, tile): """ diff --git a/project/mahjong/ai/strategies/tanyao.py b/project/mahjong/ai/strategies/tanyao.py index a8930c3b..3eb5c463 100644 --- a/project/mahjong/ai/strategies/tanyao.py +++ b/project/mahjong/ai/strategies/tanyao.py @@ -16,6 +16,10 @@ def should_activate_strategy(self): :return: boolean """ + result = super(TanyaoStrategy, self).should_activate_strategy() + if not result: + return False + tiles = TilesConverter.to_34_array(self.player.tiles) count_of_terminal_pon_sets = 0 count_of_terminal_pairs = 0 diff --git a/project/mahjong/ai/strategies/yakuhai.py b/project/mahjong/ai/strategies/yakuhai.py index a45a4b9b..68553a39 100644 --- a/project/mahjong/ai/strategies/yakuhai.py +++ b/project/mahjong/ai/strategies/yakuhai.py @@ -7,14 +7,16 @@ class YakuhaiStrategy(BaseStrategy): def should_activate_strategy(self): """ - We can go for yakuhai strategy if we have at least one yakuhai pair in the hand, - but with 5+ pairs in hand we don't need to go for yakuhai + 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() + if not result: + return False + 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]) has_valued_pairs = any([tiles_34[x] >= 2 for x in self.player.ai.valued_honors]) - return has_valued_pairs and count_of_pairs < 4 + return has_valued_pairs def is_tile_suitable(self, tile): """ diff --git a/project/mahjong/ai/tests/tests_strategies.py b/project/mahjong/ai/tests/tests_strategies.py index a34c9910..3b34576d 100644 --- a/project/mahjong/ai/tests/tests_strategies.py +++ b/project/mahjong/ai/tests/tests_strategies.py @@ -143,6 +143,11 @@ def test_should_activate_strategy(self): 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 = Player(0, 0, table) @@ -222,6 +227,15 @@ def test_should_activate_strategy_and_terminal_pairs(self): player.init_hand(tiles) self.assertEqual(strategy.should_activate_strategy(), True) + def test_should_activate_strategy_and_chitoitsu_like_hand(self): + table = Table() + player = Player(0, 0, table) + 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 = Table() player = Player(0, 0, table) From 6a34e6a1218103d104764bd9bd4610bc76ba08d3 Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Thu, 9 Feb 2017 23:09:33 +0800 Subject: [PATCH 27/80] Update previous shanten number after meld opening --- project/game/game_manager.py | 4 ++- project/game/tests.py | 4 +-- project/mahjong/ai/main.py | 2 +- project/mahjong/ai/strategies/main.py | 16 +++++------ project/mahjong/ai/tests/tests_ai.py | 29 +++++++++++++++++--- project/mahjong/ai/tests/tests_strategies.py | 24 ++++++++-------- 6 files changed, 51 insertions(+), 28 deletions(-) diff --git a/project/game/game_manager.py b/project/game/game_manager.py index 066e1b90..81a917e0 100644 --- a/project/game/game_manager.py +++ b/project/game/game_manager.py @@ -226,7 +226,7 @@ def play_round(self): else: is_kamicha_discard = other_client.seat - current_client.seat == 1 - meld, discarded_tile = other_client.player.try_to_call_meld(tile, is_kamicha_discard) + meld, discarded_tile, shanten = other_client.player.try_to_call_meld(tile, is_kamicha_discard) if meld: meld.from_who = current_client.seat @@ -235,6 +235,7 @@ def play_round(self): possible_melds.append({ 'meld': meld, 'discarded_tile': discarded_tile, + 'shanten': shanten }) if possible_melds: @@ -263,6 +264,7 @@ def play_round(self): current_client.add_called_meld(meld) current_client.player.tiles.append(tile) + current_client.player.ai.previous_shanten = possible_melds[0]['shanten'] self.replay.open_meld(meld) diff --git a/project/game/tests.py b/project/game/tests.py index 5418f82e..62ab1efb 100644 --- a/project/game/tests.py +++ b/project/game/tests.py @@ -15,7 +15,7 @@ def setUp(self): logger.disabled = False # def test_debug(self): - # game.game_manager.shuffle_seed = lambda: 0.08673742015192998 + # game.game_manager.shuffle_seed = lambda: 0.39152594264879603 # # clients = [Client(use_previous_ai_version=False) for _ in range(0, 4)] # # clients = [Client(use_previous_ai_version=True) for _ in range(0, 3)] @@ -24,7 +24,7 @@ def setUp(self): # manager.replay.init_game() # manager.init_game() # manager.set_dealer(2) - # manager._unique_dealers = 4 + # manager._unique_dealers = 1 # manager.init_round() # # result = manager.play_round() diff --git a/project/mahjong/ai/main.py b/project/mahjong/ai/main.py index 8280abec..ebb52d5a 100644 --- a/project/mahjong/ai/main.py +++ b/project/mahjong/ai/main.py @@ -159,7 +159,7 @@ def count_tiles(self, raw_data, tiles): def try_to_call_meld(self, tile, is_kamicha_discard): if not self.current_strategy: - return None, None + return None, None, None return self.current_strategy.try_to_call_meld(tile, is_kamicha_discard) diff --git a/project/mahjong/ai/strategies/main.py b/project/mahjong/ai/strategies/main.py index 0ddcb5d6..a5bbf993 100644 --- a/project/mahjong/ai/strategies/main.py +++ b/project/mahjong/ai/strategies/main.py @@ -69,20 +69,20 @@ def try_to_call_meld(self, tile, is_kamicha_discard): If yes, it will return Meld object and tile to discard :param tile: 136 format tile :param enemy_seat: 1, 2, 3 - :return: meld and tile to discard after called open set + :return: meld and tile to discard after called open set, and new shanten count """ if self.player.in_riichi: - return None, None + return None, None, None closed_hand = self.player.closed_hand[:] # we opened all our hand if len(closed_hand) == 1: - return None, None + return None, None, None # we can't use this tile for our chosen strategy if not self.is_tile_suitable(tile): - return None, None + return None, None, None discarded_tile = tile // 4 @@ -93,11 +93,11 @@ def try_to_call_meld(self, tile, is_kamicha_discard): # each strategy can use their own value to min shanten number if shanten > self.min_shanten: - return None, None + return None, None, None # we can't improve hand, so we don't need to open it if not outs_results: - return None, None + return None, None, None # tile will decrease the count of shanten in hand # so let's call opened set with it @@ -185,9 +185,9 @@ def try_to_call_meld(self, tile, is_kamicha_discard): tile_to_discard = self.determine_what_to_discard(closed_hand, outs_results, shanten, True) if tile_to_discard: - return meld, tile_to_discard + return meld, tile_to_discard, shanten - return None, None + return None, None, None def _find_best_meld_to_open(self, possible_melds, closed_hand_34, first_limit, second_limit, completed_hand): """ diff --git a/project/mahjong/ai/tests/tests_ai.py b/project/mahjong/ai/tests/tests_ai.py index 5437387b..b60b1cd7 100644 --- a/project/mahjong/ai/tests/tests_ai.py +++ b/project/mahjong/ai/tests/tests_ai.py @@ -121,7 +121,7 @@ def test_not_open_hand_in_riichi(self): tiles = self._string_to_136_array(sou='12368', pin='2358', honors='4455') tile = self._string_to_136_tile(honors='5') player.init_hand(tiles) - meld, _ = player.try_to_call_meld(tile, False) + meld, _, _ = player.try_to_call_meld(tile, False) self.assertEqual(meld, None) def test_chose_right_set_to_open_hand(self): @@ -135,7 +135,7 @@ def test_chose_right_set_to_open_hand(self): tiles = self._string_to_136_array(man='23455', pin='3445678', honors='1') tile = self._string_to_136_tile(man='5') player.init_hand(tiles) - meld, _ = player.try_to_call_meld(tile, True) + meld, _, _ = player.try_to_call_meld(tile, True) self.assertNotEqual(meld, None) self.assertEqual(meld.type, Meld.PON) self.assertEqual(self._to_string(meld.tiles), '555m') @@ -143,7 +143,7 @@ def test_chose_right_set_to_open_hand(self): tiles = self._string_to_136_array(man='335666', pin='22', sou='345', honors='55') tile = self._string_to_136_tile(man='4') player.init_hand(tiles) - meld, _ = player.try_to_call_meld(tile, True) + 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') @@ -151,11 +151,32 @@ def test_chose_right_set_to_open_hand(self): tiles = self._string_to_136_array(man='23557', pin='556788', honors='22') tile = self._string_to_136_tile(pin='5') player.init_hand(tiles) - meld, _ = player.try_to_call_meld(tile, True) + meld, _, _ = player.try_to_call_meld(tile, True) self.assertNotEqual(meld, None) self.assertEqual(meld.type, Meld.PON) self.assertEqual(self._to_string(meld.tiles), '555p') + def test_not_open_hand_for_not_needed_set(self): + """ + We don't need to open hand if it is not improve the hand. + It was a bug related to it + """ + table = Table() + player = Player(0, 0, table) + + tiles = self._string_to_136_array(man='22457', sou='12234', pin='9', honors='55') + tile = self._string_to_136_tile(sou='3') + player.init_hand(tiles) + meld, tile_to_discard, shanten = player.try_to_call_meld(tile, True) + self.assertEqual(self._to_string(meld.tiles), '123s') + player.add_called_meld(meld) + player.discard_tile(tile_to_discard) + player.ai.previous_shanten = shanten + + tile = self._string_to_136_tile(sou='3') + meld, tile_to_discard, shanten = player.try_to_call_meld(tile, True) + self.assertIsNone(meld) + def test_chose_strategy_and_reset_strategy(self): table = Table() player = Player(0, 0, table) diff --git a/project/mahjong/ai/tests/tests_strategies.py b/project/mahjong/ai/tests/tests_strategies.py index 3b34576d..db739eac 100644 --- a/project/mahjong/ai/tests/tests_strategies.py +++ b/project/mahjong/ai/tests/tests_strategies.py @@ -54,14 +54,14 @@ def test_open_hand_with_yakuhai_pair_in_hand(self): player.init_hand(tiles) # we don't need to open hand with not our wind - meld, _ = player.try_to_call_meld(tile, False) + 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) + meld, _, _ = player.try_to_call_meld(tile, False) self.assertNotEqual(meld, None) player.add_called_meld(meld) player.tiles.append(tile) @@ -73,7 +73,7 @@ def test_open_hand_with_yakuhai_pair_in_hand(self): player.discard_tile() tile = self._string_to_136_tile(honors='5') - meld, _ = player.try_to_call_meld(tile, False) + meld, _, _ = player.try_to_call_meld(tile, False) self.assertNotEqual(meld, None) player.add_called_meld(meld) player.tiles.append(tile) @@ -86,10 +86,10 @@ def test_open_hand_with_yakuhai_pair_in_hand(self): tile = self._string_to_136_tile(sou='7') # we can call chi only from left player - meld, _ = player.try_to_call_meld(tile, False) + meld, _, _ = player.try_to_call_meld(tile, False) self.assertEqual(meld, None) - meld, _ = player.try_to_call_meld(tile, True) + meld, _, _ = player.try_to_call_meld(tile, True) self.assertNotEqual(meld, None) player.add_called_meld(meld) player.tiles.append(tile) @@ -112,11 +112,11 @@ def test_force_yakuhai_pair_waiting_for_tempai_hand(self): player.init_hand(tiles) # we will not get tempai on yakuhai pair with this hand, so let's skip this call - meld, _ = player.try_to_call_meld(tile, False) + meld, _, _ = player.try_to_call_meld(tile, False) self.assertEqual(meld, None) tile = self._string_to_136_tile(man='7') - meld, tile_to_discard = player.try_to_call_meld(tile, True) + meld, tile_to_discard, _ = 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') @@ -179,12 +179,12 @@ def test_open_hand_and_discard_tiles_logic(self): # 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) + 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) + meld, _, _ = player.try_to_call_meld(tile, False) self.assertNotEqual(meld, None) tile = self._string_to_136_tile(man='1') @@ -306,14 +306,14 @@ def test_dont_open_hand_with_high_shanten(self): 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) + 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) + meld, _, _ = player.try_to_call_meld(tile, True) self.assertNotEqual(meld, None) def test_dont_open_hand_with_not_suitable_melds(self): @@ -323,5 +323,5 @@ def test_dont_open_hand_with_not_suitable_melds(self): 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) + meld, _, _ = player.try_to_call_meld(tile, False) self.assertEqual(meld, None) From 3bb9489922cb2159404990c68abe4d73dfd5c3cc Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Thu, 9 Feb 2017 23:37:52 +0800 Subject: [PATCH 28/80] Enable red fives in local games --- project/game/game_manager.py | 3 +++ project/game/tests.py | 31 ++++++++++++++++++------------- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/project/game/game_manager.py b/project/game/game_manager.py index 81a917e0..0cfb2a84 100644 --- a/project/game/game_manager.py +++ b/project/game/game_manager.py @@ -10,6 +10,9 @@ from mahjong.hand import FinishedHand from mahjong.meld import Meld from mahjong.tile import TilesConverter +from utils.settings_handler import settings + +settings.FIVE_REDS = True # we need to have it # to be able repeat our tests with needed random diff --git a/project/game/tests.py b/project/game/tests.py index 62ab1efb..ad42b67f 100644 --- a/project/game/tests.py +++ b/project/game/tests.py @@ -6,6 +6,7 @@ from game.game_manager import GameManager from mahjong.client import Client from utils.tests import TestMixin +from utils.settings_handler import settings class GameManagerTestCase(unittest.TestCase, TestMixin): @@ -339,6 +340,8 @@ def test_retake_and_honba_increment(self): self.assertEqual(manager.honba_sticks, 1) def test_win_by_ron_and_scores_calculation(self): + settings.FIVE_REDS = False + clients = [Client() for _ in range(0, 4)] manager = GameManager(clients) manager.init_game() @@ -348,7 +351,7 @@ def test_win_by_ron_and_scores_calculation(self): winner = clients[0] loser = clients[1] - # only 1500 hand + # 1500 hand tiles = self._string_to_136_array(sou='123567', pin='12345', man='11') win_tile = self._string_to_136_tile(pin='6') manager.process_the_end_of_the_round(tiles, win_tile, winner, loser, False) @@ -382,6 +385,8 @@ def test_win_by_ron_and_scores_calculation(self): self.assertEqual(loser.player.scores, 22900) self.assertEqual(manager.honba_sticks, 3) + settings.FIVE_REDS = True + def test_win_by_tsumo_and_scores_calculation(self): clients = [Client() for _ in range(0, 4)] manager = GameManager(clients) @@ -400,12 +405,12 @@ def test_win_by_tsumo_and_scores_calculation(self): win_tile = self._string_to_136_tile(pin='6') manager.process_the_end_of_the_round(tiles, win_tile, winner, None, True) - # 2400 + riichi stick (1000) = 3400 - # 700 from each other player + 100 honba payment - self.assertEqual(winner.player.scores, 28400) - self.assertEqual(clients[1].player.scores, 24200) - self.assertEqual(clients[2].player.scores, 24200) - self.assertEqual(clients[3].player.scores, 24200) + # 8100 + riichi stick (1000) = 9100 + # 2600 from each other player + 100 honba payment + self.assertEqual(winner.player.scores, 34100) + self.assertEqual(clients[1].player.scores, 22300) + self.assertEqual(clients[2].player.scores, 22300) + self.assertEqual(clients[3].player.scores, 22300) for client in clients: client.player.scores = 25000 @@ -420,11 +425,11 @@ def test_win_by_tsumo_and_scores_calculation(self): win_tile = self._string_to_136_tile(pin='6') manager.process_the_end_of_the_round(tiles, win_tile, winner, None, True) - # 700 from dealer and 400 from other players - self.assertEqual(winner.player.scores, 26500) - self.assertEqual(clients[1].player.scores, 24600) - self.assertEqual(clients[2].player.scores, 24300) - self.assertEqual(clients[3].player.scores, 24600) + # 2600 from dealer and 1300 from other players + self.assertEqual(winner.player.scores, 30200) + self.assertEqual(clients[1].player.scores, 23700) + self.assertEqual(clients[2].player.scores, 22400) + self.assertEqual(clients[3].player.scores, 23700) def test_change_dealer_after_end_of_the_round(self): clients = [Client() for _ in range(0, 4)] @@ -471,7 +476,7 @@ def test_is_game_end_by_negative_scores(self): tiles = self._string_to_136_array(sou='123567', pin='12345', man='11') win_tile = self._string_to_136_tile(pin='6') result = manager.process_the_end_of_the_round(tiles, win_tile, winner, loser, False) - self.assertEqual(loser.player.scores, -1500) + self.assertEqual(loser.player.scores, -5800) self.assertEqual(result['is_game_end'], True) def test_is_game_end_by_eight_winds(self): From 5675bc87f61d4027eef36e8f842373f6e7589477 Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Fri, 10 Feb 2017 18:00:41 +0800 Subject: [PATCH 29/80] Don't go for tanyao with valuable pair in the hand --- project/mahjong/ai/strategies/tanyao.py | 13 +++++++++++-- project/mahjong/ai/tests/tests_strategies.py | 13 +++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/project/mahjong/ai/strategies/tanyao.py b/project/mahjong/ai/strategies/tanyao.py index 3eb5c463..a512c156 100644 --- a/project/mahjong/ai/strategies/tanyao.py +++ b/project/mahjong/ai/strategies/tanyao.py @@ -23,22 +23,31 @@ def should_activate_strategy(self): tiles = TilesConverter.to_34_array(self.player.tiles) count_of_terminal_pon_sets = 0 count_of_terminal_pairs = 0 + count_of_valued_pairs = 0 for x in range(0, 34): tile = tiles[x] if not tile: continue - if x in self.not_suitable_tiles and tile >= 3: + if x in self.not_suitable_tiles and tile == 3: count_of_terminal_pon_sets += 1 - if x in self.not_suitable_tiles and tile >= 2: + if x in self.not_suitable_tiles and tile == 2: count_of_terminal_pairs += 1 + if x in self.player.ai.valued_honors: + count_of_valued_pairs += 1 + # if we already have pon of honor\terminal tiles # we don't need to open hand for tanyao if count_of_terminal_pon_sets > 0: return False + # with valued pair (yakuhai wind or dragon) + # we don't need to go for tanyao + if count_of_valued_pairs > 0: + return False + # one pair is ok in tanyao pair # but 2+ pairs can't be suitable if count_of_terminal_pairs > 1: diff --git a/project/mahjong/ai/tests/tests_strategies.py b/project/mahjong/ai/tests/tests_strategies.py index db739eac..7c03550c 100644 --- a/project/mahjong/ai/tests/tests_strategies.py +++ b/project/mahjong/ai/tests/tests_strategies.py @@ -227,6 +227,19 @@ def test_should_activate_strategy_and_terminal_pairs(self): player.init_hand(tiles) self.assertEqual(strategy.should_activate_strategy(), True) + def test_should_activate_strategy_and_valued_pair(self): + table = Table() + player = Player(0, 0, table) + 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 = Table() player = Player(0, 0, table) From 6526bea068deec597b8b4068381bb410183e2801 Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Fri, 10 Feb 2017 20:08:51 +0800 Subject: [PATCH 30/80] Fix an issue with redefining of meld --- project/game/game_manager.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/project/game/game_manager.py b/project/game/game_manager.py index 0cfb2a84..3f0d8303 100644 --- a/project/game/game_manager.py +++ b/project/game/game_manager.py @@ -260,8 +260,8 @@ def play_round(self): ) if current_client.player.is_open_hand: melds = [] - for meld in current_client.player.melds: - melds.append('{}'.format(TilesConverter.to_one_line_string(meld.tiles))) + for item in current_client.player.melds: + melds.append('{}'.format(TilesConverter.to_one_line_string(item.tiles))) hand_string += ' [{}]'.format(', '.join(melds)) logger.info(hand_string) From 79edc298824fbcc51b6331b7394fe233f8e98312 Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Fri, 10 Feb 2017 22:16:10 +0800 Subject: [PATCH 31/80] Discard terminals and honors for ranyao mode --- project/mahjong/ai/strategies/honitsu.py | 28 -------------------- project/mahjong/ai/strategies/main.py | 25 +++++++++++++++++ project/mahjong/ai/tests/tests_strategies.py | 22 +++++++++++++++ 3 files changed, 47 insertions(+), 28 deletions(-) diff --git a/project/mahjong/ai/strategies/honitsu.py b/project/mahjong/ai/strategies/honitsu.py index b5cbef02..69600c7b 100644 --- a/project/mahjong/ai/strategies/honitsu.py +++ b/project/mahjong/ai/strategies/honitsu.py @@ -63,31 +63,3 @@ def is_tile_suitable(self, tile): """ tile //= 4 return self.chosen_suit(tile) or is_honor(tile) - - def determine_what_to_discard(self, closed_hand, outs_results, shanten, for_open_hand): - """ - In honitsu mode we should discard tiles from other suit, - even if it is better to save them - """ - for i in closed_hand: - i //= 4 - - if not self.is_tile_suitable(i * 4): - item_was_found = False - for j in outs_results: - if j['discard'] == i: - item_was_found = True - j['tiles_count'] = 0 - j['waiting'] = [] - - if not item_was_found: - outs_results.append({ - 'discard': i, - 'tiles_count': 1, - 'waiting': [] - }) - - outs_results = sorted(outs_results, key=lambda x: x['tiles_count'], reverse=True) - outs_results = sorted(outs_results, key=lambda x: self.is_tile_suitable(x['discard'] * 4), reverse=False) - - return super(HonitsuStrategy, self).determine_what_to_discard(closed_hand, outs_results, shanten, for_open_hand) diff --git a/project/mahjong/ai/strategies/main.py b/project/mahjong/ai/strategies/main.py index a5bbf993..91d9a607 100644 --- a/project/mahjong/ai/strategies/main.py +++ b/project/mahjong/ai/strategies/main.py @@ -55,12 +55,37 @@ def determine_what_to_discard(self, closed_hand, outs_results, shanten, for_open :param for_open_hand: boolean :return: tile in 136 format or none """ + + # mark all not suitable tiles as ready to discard + # even if they not should be discarded by uke-ire + for i in closed_hand: + i //= 4 + + if not self.is_tile_suitable(i * 4): + item_was_found = False + for j in outs_results: + if j['discard'] == i: + item_was_found = True + j['tiles_count'] = 0 + j['waiting'] = [] + + if not item_was_found: + outs_results.append({ + 'discard': i, + 'tiles_count': 1, + 'waiting': [] + }) + + outs_results = sorted(outs_results, key=lambda x: x['tiles_count'], reverse=True) + outs_results = sorted(outs_results, key=lambda x: self.is_tile_suitable(x['discard'] * 4), reverse=False) + tile_to_discard = None for out_result in outs_results: tile_34 = out_result['discard'] tile_to_discard = TilesConverter.find_34_tile_in_136_array(tile_34, closed_hand) if tile_to_discard: break + return tile_to_discard def try_to_call_meld(self, tile, is_kamicha_discard): diff --git a/project/mahjong/ai/tests/tests_strategies.py b/project/mahjong/ai/tests/tests_strategies.py index 7c03550c..93112fe0 100644 --- a/project/mahjong/ai/tests/tests_strategies.py +++ b/project/mahjong/ai/tests/tests_strategies.py @@ -338,3 +338,25 @@ def test_dont_open_hand_with_not_suitable_melds(self): 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 = Table() + player = Player(0, 0, table) + + # 2345779m1p256s44z + tiles = self._string_to_136_array(man='22345777', sou='238', 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, tile_to_discard, _ = player.try_to_call_meld(tile, True) + self.assertNotEqual(meld, None) + self.assertEqual(self._to_string([tile_to_discard]), '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') From d3cd96660609f459fdf40abff0373aeb318f91b4 Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Sat, 11 Feb 2017 00:10:36 +0800 Subject: [PATCH 32/80] Add special conditions to the local games (tenhou, ippatsu, haitei and etc.) --- project/game/game_manager.py | 46 +++++++++++++++++++++++++++++++++++- project/game/tests.py | 9 +++++-- project/mahjong/player.py | 5 ++++ 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/project/game/game_manager.py b/project/game/game_manager.py index 3f0d8303..6d627496 100644 --- a/project/game/game_manager.py +++ b/project/game/game_manager.py @@ -196,6 +196,9 @@ def play_round(self): # and discard it later tiles.append(tile) + # we had to clear ippatsu, after tile draw + current_client.player._is_ippatsu = False + # if not in riichi, let's decide what tile to discard if not current_client.player.in_riichi: tile = current_client.discard_tile() @@ -248,6 +251,10 @@ def play_round(self): # if tile_to_discard: meld = possible_melds[0]['meld'] + # clear ippatsu after called meld + for client_item in self.clients: + client_item.player._is_ippatsu = False + # we changed current client with called open set self.current_client_seat = meld.who current_client = self._get_current_client() @@ -406,6 +413,12 @@ def call_riichi(self, client): client.player.scores -= 1000 self.riichi_sticks += 1 + if client.player.discards: + client.player._is_daburi = True + # we will set it to False after next draw + # or called meld + client.player._is_ippatsu = True + who_called_riichi = client.seat for client in self.clients: client.enemy_riichi(who_called_riichi - client.seat) @@ -457,6 +470,29 @@ def process_the_end_of_the_round(self, tiles, win_tile, winner, loser, is_tsumo) if winner.player.in_riichi: ura_dora.append(self.dead_wall[9]) + is_tenhou = False + is_renhou = False + is_chiihou = False + # win on the first draw\discard + # we can win after daburi riichi in that case we will have one tile in discard + # that's why we have < 2 condition (not == 0) + if not self.players_with_open_hands and len(winner.player.discards) < 2: + if is_tsumo: + if winner.player.is_dealer: + is_tenhou = True + else: + is_chiihou = True + else: + is_renhou = True + + is_haitei = False + is_houtei = False + if not self.tiles: + if is_tsumo: + is_haitei = True + else: + is_houtei = True + hand_value = self.finished_hand.estimate_hand_value(tiles=tiles + [win_tile], win_tile=win_tile, is_tsumo=is_tsumo, @@ -465,7 +501,14 @@ def process_the_end_of_the_round(self, tiles, win_tile, winner, loser, is_tsumo) open_sets=winner.player.meld_tiles, dora_indicators=self.dora_indicators + ura_dora, player_wind=winner.player.player_wind, - round_wind=winner.player.table.round_wind) + round_wind=winner.player.table.round_wind, + is_tenhou=is_tenhou, + is_renhou=is_renhou, + is_chiihou=is_chiihou, + is_daburu_riichi=winner.player._is_daburi, + is_ippatsu=winner.player._is_ippatsu, + is_haitei=is_haitei, + is_houtei=is_houtei) if hand_value['error']: logger.error("Can't estimate a hand: {}. Error: {}".format( @@ -482,6 +525,7 @@ def process_the_end_of_the_round(self, tiles, win_tile, winner, loser, is_tsumo) else: # tsumo loser_seat = winner.seat + self.replay.win(winner.seat, loser_seat, win_tile, diff --git a/project/game/tests.py b/project/game/tests.py index ad42b67f..f036ab3f 100644 --- a/project/game/tests.py +++ b/project/game/tests.py @@ -16,7 +16,7 @@ def setUp(self): logger.disabled = False # def test_debug(self): - # game.game_manager.shuffle_seed = lambda: 0.39152594264879603 + # game.game_manager.shuffle_seed = lambda: 0.9974277798778228 # # clients = [Client(use_previous_ai_version=False) for _ in range(0, 4)] # # clients = [Client(use_previous_ai_version=True) for _ in range(0, 3)] @@ -24,7 +24,7 @@ def setUp(self): # manager = GameManager(clients) # manager.replay.init_game() # manager.init_game() - # manager.set_dealer(2) + # manager.set_dealer(1) # manager._unique_dealers = 1 # manager.init_round() # @@ -349,6 +349,7 @@ def test_win_by_ron_and_scores_calculation(self): manager.set_dealer(0) winner = clients[0] + winner.player.discards = [1, 2] loser = clients[1] # 1500 hand @@ -400,6 +401,7 @@ def test_win_by_tsumo_and_scores_calculation(self): manager.dora_indicators = [100] # to avoid ura-dora, because of this test can fail winner.player.in_riichi = False + winner.player.discards = [1, 2] tiles = self._string_to_136_array(sou='123567', pin='12345', man='11') win_tile = self._string_to_136_tile(pin='6') @@ -475,6 +477,9 @@ def test_is_game_end_by_negative_scores(self): tiles = self._string_to_136_array(sou='123567', pin='12345', man='11') win_tile = self._string_to_136_tile(pin='6') + # discards to erase renhou + winner.player.discards = [1, 2] + result = manager.process_the_end_of_the_round(tiles, win_tile, winner, loser, False) self.assertEqual(loser.player.scores, -5800) self.assertEqual(result['is_game_end'], True) diff --git a/project/mahjong/player.py b/project/mahjong/player.py index 39897bf6..9e35da43 100644 --- a/project/mahjong/player.py +++ b/project/mahjong/player.py @@ -36,6 +36,11 @@ class Player(object): in_riichi = False in_defence_mode = False + # system fields + # for local games emulation + _is_daburi = False + _is_ippatsu = False + def __init__(self, seat, dealer_seat, table, use_previous_ai_version=False): self.discards = [] self.melds = [] From 853fc9b9030ec080c15b9a2f16db5b1eeacd6a8d Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Sat, 11 Feb 2017 00:23:15 +0800 Subject: [PATCH 33/80] Fix payment display for tenhou log --- project/game/replays/tenhou.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/project/game/replays/tenhou.py b/project/game/replays/tenhou.py index ac11141e..30534f17 100644 --- a/project/game/replays/tenhou.py +++ b/project/game/replays/tenhou.py @@ -118,6 +118,16 @@ def win(self, who, from_who, win_tile, honba_sticks, riichi_sticks, han, fu, cos else: scores.append('{},0'.format(int(client.player.scores // 100))) + # tsumo + if who == from_who: + if self.clients[who].player.is_dealer: + payment = cost['main'] * 3 + else: + payment = cost['main'] + cost['additional'] * 2 + # ron + else: + payment = cost['main'] + yaku_string = ','.join(['{},{}'.format(x.id, x.han[han_key]) for x in yaku_list]) self.tags.append('' @@ -126,7 +136,7 @@ def win(self, who, from_who, win_tile, honba_sticks, riichi_sticks, han, fu, cos ','.join([str(x) for x in self.clients[who].player.tiles]), win_tile, fu, - cost['main'], + payment, yaku_string, ','.join([str(x) for x in dora]), ','.join([str(x) for x in ura_dora]), From 8a3264696cbfd3db42d6100a9e844c77f832f8c3 Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Sat, 11 Feb 2017 00:23:59 +0800 Subject: [PATCH 34/80] Fix round number for tenhou log --- project/game/game_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/game/game_manager.py b/project/game/game_manager.py index 6d627496..9740d4b9 100644 --- a/project/game/game_manager.py +++ b/project/game/game_manager.py @@ -139,7 +139,7 @@ def init_round(self): )) logger.info('Players: {0}'.format(self.players_sorted_by_scores())) - self.replay.init_round(self.dealer, + self.replay.init_round(self._unique_dealers, self.round_number, self.honba_sticks, self.riichi_sticks, From ac7ca76d174d1d37ce01e1cf0974674dd16b2d03 Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Sat, 11 Feb 2017 00:44:10 +0800 Subject: [PATCH 35/80] Tenhou log. Display open sets in agari mode --- project/game/replays/tenhou.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/project/game/replays/tenhou.py b/project/game/replays/tenhou.py index 30534f17..d2ab36b1 100644 --- a/project/game/replays/tenhou.py +++ b/project/game/replays/tenhou.py @@ -89,7 +89,8 @@ def retake(self, tempai_players, honba_sticks, riichi_sticks): hands)) def win(self, who, from_who, win_tile, honba_sticks, riichi_sticks, han, fu, cost, yaku_list, dora, ura_dora): - han_key = self.clients[who].player.closed_hand and 'closed' or 'open' + winner = self.clients[who].player + han_key = winner.is_open_hand and 'open' or 'closed' scores = [] for client in self.clients: # tsumo lose @@ -120,7 +121,7 @@ def win(self, who, from_who, win_tile, honba_sticks, riichi_sticks, han, fu, cos # tsumo if who == from_who: - if self.clients[who].player.is_dealer: + if winner.is_dealer: payment = cost['main'] * 3 else: payment = cost['main'] + cost['additional'] * 2 @@ -128,13 +129,20 @@ def win(self, who, from_who, win_tile, honba_sticks, riichi_sticks, han, fu, cos else: payment = cost['main'] + melds = [] + if winner.is_open_hand: + for meld in winner.melds: + melds.append(self._encode_meld(meld)) + melds = ','.join(melds) + yaku_string = ','.join(['{},{}'.format(x.id, x.han[han_key]) for x in yaku_list]) - self.tags.append('' .format(honba_sticks, riichi_sticks, - ','.join([str(x) for x in self.clients[who].player.tiles]), + ','.join([str(x) for x in winner.tiles]), win_tile, + melds, fu, payment, yaku_string, From 82545cbbaa21cbcbb5def76813570167c8b6502e Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Sat, 11 Feb 2017 00:53:56 +0800 Subject: [PATCH 36/80] Fix issue with daburi riichi and local games --- project/game/game_manager.py | 8 ++++---- project/mahjong/player.py | 3 +++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/project/game/game_manager.py b/project/game/game_manager.py index 9740d4b9..79bb8622 100644 --- a/project/game/game_manager.py +++ b/project/game/game_manager.py @@ -413,11 +413,11 @@ def call_riichi(self, client): client.player.scores -= 1000 self.riichi_sticks += 1 - if client.player.discards: + if not client.player.discards: client.player._is_daburi = True - # we will set it to False after next draw - # or called meld - client.player._is_ippatsu = True + # we will set it to False after next draw + # or called meld + client.player._is_ippatsu = True who_called_riichi = client.seat for client in self.clients: diff --git a/project/mahjong/player.py b/project/mahjong/player.py index 9e35da43..24b77c6e 100644 --- a/project/mahjong/player.py +++ b/project/mahjong/player.py @@ -95,6 +95,9 @@ def erase_state(self): self.ai.erase_state() + self._is_daburi = False + self._is_ippatsu = False + def add_called_meld(self, meld): self.melds.append(meld) From d93e21e39cd7eac8a6632936fd5e309d7706dd70 Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Sat, 11 Feb 2017 00:55:23 +0800 Subject: [PATCH 37/80] Fix an issue with displaying open sets and agari in tenhou logs --- project/game/replays/tenhou.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/project/game/replays/tenhou.py b/project/game/replays/tenhou.py index d2ab36b1..98014964 100644 --- a/project/game/replays/tenhou.py +++ b/project/game/replays/tenhou.py @@ -28,7 +28,7 @@ def end_game(self): self.tags.append('') with open(os.path.join(self.replays_directory, self.replay_name), 'w') as f: - f.write(''.join(self.tags)) + f.write('\n'.join(self.tags)) def init_round(self, dealer, round_number, honba_sticks, riichi_sticks, dora): self.tags.append('' .format(honba_sticks, riichi_sticks, - ','.join([str(x) for x in winner.tiles]), + ','.join([str(x) for x in winner.closed_hand]), win_tile, melds, fu, From a1bb76515ff8b392cdaa2a1b25a562a756a7f4a4 Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Sat, 11 Feb 2017 00:55:48 +0800 Subject: [PATCH 38/80] Remove debug --- project/game/replays/tenhou.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/game/replays/tenhou.py b/project/game/replays/tenhou.py index 98014964..0569e611 100644 --- a/project/game/replays/tenhou.py +++ b/project/game/replays/tenhou.py @@ -28,7 +28,7 @@ def end_game(self): self.tags.append('') with open(os.path.join(self.replays_directory, self.replay_name), 'w') as f: - f.write('\n'.join(self.tags)) + f.write(''.join(self.tags)) def init_round(self, dealer, round_number, honba_sticks, riichi_sticks, dora): self.tags.append(' Date: Sat, 11 Feb 2017 08:35:47 +0800 Subject: [PATCH 39/80] Fixes for daburi riichi --- project/game/game_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/game/game_manager.py b/project/game/game_manager.py index 79bb8622..2a6b2ac1 100644 --- a/project/game/game_manager.py +++ b/project/game/game_manager.py @@ -413,7 +413,7 @@ def call_riichi(self, client): client.player.scores -= 1000 self.riichi_sticks += 1 - if not client.player.discards: + if len(client.player.discards) == 1: client.player._is_daburi = True # we will set it to False after next draw # or called meld From da6e637824e631253951b2b37fabcf81bebca4e3 Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Sat, 11 Feb 2017 20:13:35 +0800 Subject: [PATCH 40/80] Tenhou log. Fix oya position and round numbers --- project/game/game_manager.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/project/game/game_manager.py b/project/game/game_manager.py index 2a6b2ac1..f9bf119c 100644 --- a/project/game/game_manager.py +++ b/project/game/game_manager.py @@ -67,8 +67,9 @@ def init_game(self): for i in range(0, len(self.clients)): self.clients[i].seat = i - dealer = randint(0, 3) - self.set_dealer(dealer) + # oya should be always first player + # to have compatibility with tenhou format + self.set_dealer(0) for client in self.clients: client.player.scores = 25000 @@ -130,17 +131,17 @@ def init_round(self): client.player.tiles = sorted(client.player.tiles) client.init_hand(client.player.tiles) - logger.info('Seed: {0}'.format(shuffle_seed())) - logger.info('Dealer: {0}'.format(self.dealer)) - logger.info('Wind: {0}. Riichi sticks: {1}. Honba sticks: {2}'.format( + logger.info('Seed: {}'.format(shuffle_seed())) + logger.info('Dealer: {}, {}'.format(self.dealer, self.clients[self.dealer].player.name)) + logger.info('Wind: {}. Riichi sticks: {}. Honba sticks: {}'.format( self._unique_dealers, self.riichi_sticks, self.honba_sticks )) logger.info('Players: {0}'.format(self.players_sorted_by_scores())) - self.replay.init_round(self._unique_dealers, - self.round_number, + self.replay.init_round(self.dealer, + self._unique_dealers - 1, self.honba_sticks, self.riichi_sticks, self.dora_indicators[0]) From 8fc896b09e22cc4f4bbd8a417c59b66fcddcbd3b Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Sat, 11 Feb 2017 20:29:32 +0800 Subject: [PATCH 41/80] Fix issues with retake logic --- project/game/game_manager.py | 19 ++++++++++++++----- project/game/tests.py | 16 ++++++++++------ 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/project/game/game_manager.py b/project/game/game_manager.py index f9bf119c..58871f4c 100644 --- a/project/game/game_manager.py +++ b/project/game/game_manager.py @@ -584,17 +584,17 @@ def process_the_end_of_the_round(self, tiles, win_tile, winner, loser, is_tsumo) else: tempai_users = [] + dealer_was_tempai = False for client in self.clients: if client.player.in_tempai: tempai_users.append(client.seat) + if client.player.is_dealer: + dealer_was_tempai = True + tempai_users_count = len(tempai_users) if tempai_users_count == 0 or tempai_users_count == 4: self.honba_sticks += 1 - # no one in tempai, so deal should move - if tempai_users_count == 0: - new_dealer = self._move_position(self.dealer) - self.set_dealer(new_dealer) else: # 1 tempai user will get 3000 # 2 tempai users will get 1500 each @@ -603,7 +603,7 @@ def process_the_end_of_the_round(self, tiles, win_tile, winner, loser, is_tsumo) for client in self.clients: if client.player.in_tempai: client.player.scores += scores_to_pay - logger.info('{0} + {1:,d}'.format(client.player.name, int(scores_to_pay))) + logger.info('{0} +{1:,d}'.format(client.player.name, int(scores_to_pay))) # dealer was tempai, we need to add honba stick if client.player.is_dealer: @@ -611,6 +611,15 @@ def process_the_end_of_the_round(self, tiles, win_tile, winner, loser, is_tsumo) else: client.player.scores -= 3000 / (4 - tempai_users_count) + # dealer not in tempai, so round should move + if not dealer_was_tempai: + new_dealer = self._move_position(self.dealer) + self.set_dealer(new_dealer) + + # someone was in tempai, we need to add honba here + if tempai_users_count != 0 and tempai_users_count != 4: + self.honba_sticks += 1 + self.replay.retake(tempai_users, self.honba_sticks, self.riichi_sticks) # if someone has negative scores, diff --git a/project/game/tests.py b/project/game/tests.py index f036ab3f..4383df69 100644 --- a/project/game/tests.py +++ b/project/game/tests.py @@ -16,7 +16,7 @@ def setUp(self): logger.disabled = False # def test_debug(self): - # game.game_manager.shuffle_seed = lambda: 0.9974277798778228 + # game.game_manager.shuffle_seed = lambda: 0.11872145794515754 # # clients = [Client(use_previous_ai_version=False) for _ in range(0, 4)] # # clients = [Client(use_previous_ai_version=True) for _ in range(0, 3)] @@ -24,8 +24,8 @@ def setUp(self): # manager = GameManager(clients) # manager.replay.init_game() # manager.init_game() - # manager.set_dealer(1) - # manager._unique_dealers = 1 + # manager.set_dealer(2) + # manager._unique_dealers = 3 # manager.init_round() # # result = manager.play_round() @@ -329,15 +329,19 @@ def test_retake_and_honba_increment(self): clients[0].player.in_tempai = False clients[1].player.in_tempai = True - # dealer NOT in tempai, no honba + self.assertEqual(manager._unique_dealers, 3) + # dealer NOT in tempai + # dealer should be moved and honba should be added manager.process_the_end_of_the_round([], None, None, None, False) - self.assertEqual(manager.honba_sticks, 0) + self.assertEqual(manager.honba_sticks, 1) + self.assertEqual(manager._unique_dealers, 4) clients[0].player.in_tempai = True + manager.set_dealer(0) # dealer in tempai, so honba stick should be added manager.process_the_end_of_the_round([], None, None, None, False) - self.assertEqual(manager.honba_sticks, 1) + self.assertEqual(manager.honba_sticks, 2) def test_win_by_ron_and_scores_calculation(self): settings.FIVE_REDS = False From a52bdd13fa532f27fcdfb917bf1f1c40cfea2290 Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Sun, 12 Feb 2017 14:41:33 +0800 Subject: [PATCH 42/80] Fix #12. Refactor current discard system --- project/game/tests.py | 10 +-- project/mahjong/ai/discard.py | 31 ++++++++ project/mahjong/ai/main.py | 75 +++++------------- project/mahjong/ai/strategies/main.py | 42 +++++----- project/mahjong/ai/strategies/yakuhai.py | 9 +-- project/mahjong/ai/tests/tests_ai.py | 82 -------------------- project/mahjong/ai/tests/tests_discards.py | 80 +++++++++++++++++++ project/mahjong/ai/tests/tests_strategies.py | 2 +- 8 files changed, 161 insertions(+), 170 deletions(-) create mode 100644 project/mahjong/ai/discard.py create mode 100644 project/mahjong/ai/tests/tests_discards.py diff --git a/project/game/tests.py b/project/game/tests.py index 4383df69..ecef9913 100644 --- a/project/game/tests.py +++ b/project/game/tests.py @@ -16,7 +16,7 @@ def setUp(self): logger.disabled = False # def test_debug(self): - # game.game_manager.shuffle_seed = lambda: 0.11872145794515754 + # game.game_manager.shuffle_seed = lambda: 0.4141604442053938 # # clients = [Client(use_previous_ai_version=False) for _ in range(0, 4)] # # clients = [Client(use_previous_ai_version=True) for _ in range(0, 3)] @@ -191,12 +191,12 @@ def test_call_riichi(self): self.assertEqual(clients[3].player.in_riichi, True) def test_play_round_and_win_by_tsumo(self): - game.game_manager.shuffle_seed = lambda: 0.6718028503751606 + game.game_manager.shuffle_seed = lambda: 0.49336094054688884 clients = [Client() for _ in range(0, 4)] manager = GameManager(clients) manager.init_game() - manager.set_dealer(3) + manager.set_dealer(0) manager.init_round() result = manager.play_round() @@ -225,12 +225,12 @@ def test_play_round_and_win_by_ron(self): self.assertNotEqual(result['loser'], None) def test_play_round_with_retake(self): - game.game_manager.shuffle_seed = lambda: 0.5859797343777 + game.game_manager.shuffle_seed = lambda: 0.3939281197763548 clients = [Client() for _ in range(0, 4)] manager = GameManager(clients) manager.init_game() - manager.set_dealer(3) + manager.set_dealer(0) manager.init_round() result = manager.play_round() diff --git a/project/mahjong/ai/discard.py b/project/mahjong/ai/discard.py new file mode 100644 index 00000000..0a857598 --- /dev/null +++ b/project/mahjong/ai/discard.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +from mahjong.tile import TilesConverter + + +class DiscardOption(object): + player = None + + # in 34 tile format + tile_to_discard = None + # array of tiles that will improve our hand + waiting = None + # how much tiles will improve our hand + tiles_count = None + + def __init__(self, player, tile_to_discard, waiting, tiles_count): + """ + :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 + """ + self.player = player + self.tile_to_discard = tile_to_discard + self.waiting = waiting + self.tiles_count = tiles_count + + def find_tile_in_hand(self, closed_hand): + """ + Find and return 136 tile in closed player hand + """ + return TilesConverter.find_34_tile_in_136_array(self.tile_to_discard, closed_hand) diff --git a/project/mahjong/ai/main.py b/project/mahjong/ai/main.py index ebb52d5a..d2d53d12 100644 --- a/project/mahjong/ai/main.py +++ b/project/mahjong/ai/main.py @@ -4,6 +4,7 @@ from mahjong.ai.agari import Agari from mahjong.ai.base import BaseAI from mahjong.ai.defence import Defence +from mahjong.ai.discard import DiscardOption from mahjong.ai.shanten import Shanten from mahjong.ai.strategies.honitsu import HonitsuStrategy from mahjong.ai.strategies.main import BaseStrategy @@ -53,32 +54,19 @@ def discard_tile(self): if shanten == Shanten.AGARI_STATE: return Shanten.AGARI_STATE - # Disable defence for now - # if self.defence.go_to_defence_mode(): - # self.player.in_tempai = False - # tile_in_hand = self.defence.calculate_safe_tile_against_riichi() - # if we wasn't able to find a safe tile, let's discard a random one - # if not tile_in_hand: - # tile_in_hand = self.player.tiles[random.randrange(len(self.player.tiles) - 1)] - # else: - # tile34 = results[0]['discard'] - # tile_in_hand = TilesConverter.find_34_tile_in_136_array(tile34, self.player.tiles) - # we are in agari state, but we can't win because we don't have yaku # in that case let's do tsumogiri if not results: return self.player.last_draw + # current strategy can affect on our discard options if self.current_strategy: - tile_to_discard = self.current_strategy.determine_what_to_discard(self.player.closed_hand, - results, - shanten, - False) - else: - tile34 = results[0]['discard'] - tile_to_discard = TilesConverter.find_34_tile_in_136_array(tile34, self.player.tiles) + results = self.current_strategy.determine_what_to_discard(self.player.closed_hand, + results, + shanten, + False) - return tile_to_discard + return self.chose_tile_to_discard(results, self.player.closed_hand) def calculate_outs(self, tiles, closed_hand, is_open_hand=False): """ @@ -95,7 +83,8 @@ def calculate_outs(self, tiles, closed_hand, is_open_hand=False): if shanten == Shanten.AGARI_STATE: return [], shanten - raw_data = {} + results = [] + for i in range(0, 34): if not tiles_34[i]: continue @@ -103,51 +92,25 @@ def calculate_outs(self, tiles, closed_hand, is_open_hand=False): if not closed_tiles_34[i]: continue - # let's keep valued pair of tiles for later game - if closed_tiles_34[i] >= 2 and i in self.valued_honors: - continue - tiles_34[i] -= 1 - raw_data[i] = [] + waiting = [] for j in range(0, 34): - if i == j or tiles_34[j] >= 4: + if i == j or tiles_34[j] == 4: continue tiles_34[j] += 1 if self.shanten.calculate_shanten(tiles_34, is_open_hand, self.player.meld_tiles) == shanten - 1: - raw_data[i].append(j) + waiting.append(j) tiles_34[j] -= 1 tiles_34[i] += 1 - if raw_data[i]: - raw_data[i] = { - 'tile': i, - 'tiles_count': self.count_tiles(raw_data[i], tiles_34), - 'waiting': raw_data[i] - } - - results = [] - tiles_34 = TilesConverter.to_34_array(self.player.tiles) - for tile in range(0, len(tiles_34)): - if tile in raw_data and raw_data[tile] and raw_data[tile]['tiles_count']: - item = raw_data[tile] - - waiting = [] - - for item2 in item['waiting']: - waiting.append(item2) - - results.append({ - 'discard': item['tile'], - 'waiting': waiting, - 'tiles_count': item['tiles_count'] - }) - - # if we have character and honor candidates to discard with same tiles count, - # we need to discard honor tile first - results = sorted(results, key=lambda x: (x['tiles_count'], x['discard']), reverse=True) + if waiting: + results.append(DiscardOption(player=self.player, + tile_to_discard=i, + waiting=waiting, + tiles_count=self.count_tiles(waiting, tiles_34))) return results, shanten @@ -195,6 +158,10 @@ def determine_strategy(self): return self.current_strategy and True or False + def chose_tile_to_discard(self, results, closed_hand): + results = sorted(results, key=lambda x: x.tiles_count, reverse=True) + return results[0].find_tile_in_hand(closed_hand) + @property def valued_honors(self): return [CHUN, HAKU, HATSU, self.player.table.round_wind, self.player.player_wind] diff --git a/project/mahjong/ai/strategies/main.py b/project/mahjong/ai/strategies/main.py index 91d9a607..ae225889 100644 --- a/project/mahjong/ai/strategies/main.py +++ b/project/mahjong/ai/strategies/main.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -from mahjong.constants import HONOR_INDICES +from mahjong.ai.discard import DiscardOption from mahjong.meld import Meld from mahjong.tile import TilesConverter from mahjong.utils import is_man, is_pin, is_sou, is_chi, is_pon, find_isolated_tile_indices @@ -53,7 +53,7 @@ def determine_what_to_discard(self, closed_hand, outs_results, shanten, for_open :param outs_results: dict :param shanten: number of shanten :param for_open_hand: boolean - :return: tile in 136 format or none + :return: array of DiscardOption """ # mark all not suitable tiles as ready to discard @@ -64,29 +64,18 @@ def determine_what_to_discard(self, closed_hand, outs_results, shanten, for_open if not self.is_tile_suitable(i * 4): item_was_found = False for j in outs_results: - if j['discard'] == i: + if j.tile_to_discard == i: item_was_found = True - j['tiles_count'] = 0 - j['waiting'] = [] + j.tiles_count = 1000 + j.waiting = [] if not item_was_found: - outs_results.append({ - 'discard': i, - 'tiles_count': 1, - 'waiting': [] - }) + outs_results.append(DiscardOption(player=self.player, + tile_to_discard=i, + waiting=[], + tiles_count=1000)) - outs_results = sorted(outs_results, key=lambda x: x['tiles_count'], reverse=True) - outs_results = sorted(outs_results, key=lambda x: self.is_tile_suitable(x['discard'] * 4), reverse=False) - - tile_to_discard = None - for out_result in outs_results: - tile_34 = out_result['discard'] - tile_to_discard = TilesConverter.find_34_tile_in_136_array(tile_34, closed_hand) - if tile_to_discard: - break - - return tile_to_discard + return outs_results def try_to_call_meld(self, tile, is_kamicha_discard): """ @@ -208,8 +197,15 @@ def try_to_call_meld(self, tile, is_kamicha_discard): meld.type = meld_type meld.tiles = sorted(tiles) - tile_to_discard = self.determine_what_to_discard(closed_hand, outs_results, shanten, True) - if tile_to_discard: + results = self.determine_what_to_discard(closed_hand, outs_results, shanten, True) + # we don't have tiles to discard after hand opening + # so, we don't need to open hand + if not results: + return None, None, None + + tile_to_discard = self.player.ai.chose_tile_to_discard(results, closed_hand) + # 0 tile is possible, so we can't just use "if tile_to_discard" + if tile_to_discard is not None: return meld, tile_to_discard, shanten return None, None, None diff --git a/project/mahjong/ai/strategies/yakuhai.py b/project/mahjong/ai/strategies/yakuhai.py index 68553a39..71a12371 100644 --- a/project/mahjong/ai/strategies/yakuhai.py +++ b/project/mahjong/ai/strategies/yakuhai.py @@ -34,12 +34,11 @@ def determine_what_to_discard(self, closed_hand, outs_results, shanten, for_open if shanten == 0 and valued_pairs and for_open_hand: valued_pair = valued_pairs[0] - tile_to_discard = None + results = [] for item in outs_results: - if valued_pair in item['waiting']: - tile_to_discard = item['discard'] - tile_to_discard = TilesConverter.find_34_tile_in_136_array(tile_to_discard, closed_hand) - return tile_to_discard + if valued_pair in item.waiting: + results.append(item) + return results else: return super(YakuhaiStrategy, self).determine_what_to_discard(closed_hand, outs_results, diff --git a/project/mahjong/ai/tests/tests_ai.py b/project/mahjong/ai/tests/tests_ai.py index b60b1cd7..9cb008e0 100644 --- a/project/mahjong/ai/tests/tests_ai.py +++ b/project/mahjong/ai/tests/tests_ai.py @@ -1,8 +1,6 @@ # -*- coding: utf-8 -*- import unittest -from mahjong.ai.main import MainAI -from mahjong.ai.shanten import Shanten from mahjong.ai.strategies.main import BaseStrategy from mahjong.meld import Meld from mahjong.player import Player @@ -12,86 +10,6 @@ class AITestCase(unittest.TestCase, TestMixin): - def test_outs(self): - table = Table() - player = Player(0, 0, table) - ai = MainAI(table, player) - - tiles = self._string_to_136_array(sou='111345677', pin='15', man='569') - outs, shanten = ai.calculate_outs(tiles, tiles, False) - - self.assertEqual(shanten, 2) - self.assertEqual(outs[0]['discard'], 9) - self.assertEqual(outs[0]['waiting'], [3, 6, 7, 8, 11, 12, 13, 14, 15, 18, 19, 20, 21, 22, 23, 24, 25]) - self.assertEqual(outs[0]['tiles_count'], 57) - - tiles = self._string_to_136_array(sou='111345677', pin='45', man='569') - outs, shanten = ai.calculate_outs(tiles, tiles, False) - - self.assertEqual(shanten, 1) - self.assertEqual(outs[0]['discard'], 23) - self.assertEqual(outs[0]['waiting'], [3, 6, 11, 14]) - self.assertEqual(outs[0]['tiles_count'], 16) - - tiles = self._string_to_136_array(sou='11145677', pin='345', man='569') - outs, shanten = ai.calculate_outs(tiles, tiles, False) - - self.assertEqual(shanten, 0) - self.assertEqual(outs[0]['discard'], 8) - self.assertEqual(outs[0]['waiting'], [3, 6]) - self.assertEqual(outs[0]['tiles_count'], 8) - - tiles = self._string_to_136_array(sou='11145677', pin='345', man='456') - outs, shanten = ai.calculate_outs(tiles, tiles, False) - - self.assertEqual(shanten, Shanten.AGARI_STATE) - self.assertEqual(len(outs), 0) - - def test_discard_tile(self): - table = Table() - player = Player(0, 0, table) - - tiles = self._string_to_136_array(sou='11134567', pin='159', man='45') - tile = self._string_to_136_tile(man='9') - player.init_hand(tiles) - player.draw_tile(tile) - - discarded_tile = player.discard_tile() - self.assertEqual(discarded_tile, 68) - - player.draw_tile(self._string_to_136_tile(pin='4')) - discarded_tile = player.discard_tile() - self.assertEqual(discarded_tile, 36) - - player.draw_tile(self._string_to_136_tile(pin='3')) - discarded_tile = player.discard_tile() - self.assertEqual(discarded_tile, 32) - - player.draw_tile(self._string_to_136_tile(man='4')) - discarded_tile = player.discard_tile() - self.assertEqual(discarded_tile, 16) - - player.draw_tile(self._string_to_136_tile(sou='8')) - discarded_tile = player.discard_tile() - self.assertEqual(discarded_tile, Shanten.AGARI_STATE) - - def test_discard_isolated_honor_tiles_first(self): - table = Table() - player = Player(0, 0, table) - - tiles = self._string_to_136_array(sou='8', pin='56688', man='11323', honors='36') - tile = self._string_to_136_array(man='9')[0] - player.init_hand(tiles) - player.draw_tile(tile) - - discarded_tile = player.discard_tile() - self.assertEqual(discarded_tile, 128) - - player.draw_tile(self._string_to_136_array(man='4')[0]) - - discarded_tile = player.discard_tile() - self.assertEqual(discarded_tile, 116) - def test_set_is_tempai_flag_to_the_player(self): table = Table() player = Player(0, 0, table) diff --git a/project/mahjong/ai/tests/tests_discards.py b/project/mahjong/ai/tests/tests_discards.py new file mode 100644 index 00000000..56d98254 --- /dev/null +++ b/project/mahjong/ai/tests/tests_discards.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +import unittest + +from mahjong.ai.main import MainAI +from mahjong.ai.shanten import Shanten +from mahjong.player import Player +from mahjong.table import Table +from utils.tests import TestMixin + + +class DiscardLogicTestCase(unittest.TestCase, TestMixin): + + def test_outs(self): + table = Table() + player = Player(0, 0, table) + ai = MainAI(table, player) + + tiles = self._string_to_136_array(sou='111345677', pin='15', man='569') + player.init_hand(tiles) + outs, shanten = ai.calculate_outs(tiles, tiles, False) + + self.assertEqual(shanten, 2) + tile = self._to_string([outs[0].find_tile_in_hand(player.closed_hand)]) + self.assertEqual(tile, '9m') + self.assertEqual(outs[0].waiting, [3, 6, 9, 10, 11, 12, 13, 14, 15, 18, 19, 20, 21, 22, 23, 24, 25]) + self.assertEqual(outs[0].tiles_count, 57) + + tiles = self._string_to_136_array(sou='111345677', pin='145', man='56') + player.init_hand(tiles) + outs, shanten = ai.calculate_outs(tiles, tiles, False) + + self.assertEqual(shanten, 1) + tile = self._to_string([outs[0].find_tile_in_hand(player.closed_hand)]) + self.assertEqual(tile, '1p') + self.assertEqual(outs[0].waiting, [3, 6, 11, 14]) + self.assertEqual(outs[0].tiles_count, 16) + + tiles = self._string_to_136_array(sou='111345677', pin='345', man='56') + player.init_hand(tiles) + outs, shanten = ai.calculate_outs(tiles, tiles, False) + + self.assertEqual(shanten, 0) + tile = self._to_string([outs[0].find_tile_in_hand(player.closed_hand)]) + self.assertEqual(tile, '3s') + self.assertEqual(outs[0].waiting, [3, 6]) + self.assertEqual(outs[0].tiles_count, 8) + + tiles = self._string_to_136_array(sou='11145677', pin='345', man='456') + outs, shanten = ai.calculate_outs(tiles, tiles, False) + + self.assertEqual(shanten, Shanten.AGARI_STATE) + self.assertEqual(len(outs), 0) + + def test_discard_tile(self): + table = Table() + player = Player(0, 0, table) + + tiles = self._string_to_136_array(sou='11134567', pin='159', man='45') + tile = self._string_to_136_tile(man='9') + player.init_hand(tiles) + player.draw_tile(tile) + + discarded_tile = player.discard_tile() + self.assertEqual(self._to_string([discarded_tile]), '9m') + + player.draw_tile(self._string_to_136_tile(pin='4')) + discarded_tile = player.discard_tile() + self.assertEqual(self._to_string([discarded_tile]), '1p') + + player.draw_tile(self._string_to_136_tile(pin='3')) + discarded_tile = player.discard_tile() + self.assertEqual(self._to_string([discarded_tile]), '9p') + + player.draw_tile(self._string_to_136_tile(man='4')) + discarded_tile = player.discard_tile() + self.assertEqual(self._to_string([discarded_tile]), '5m') + + player.draw_tile(self._string_to_136_tile(sou='8')) + discarded_tile = player.discard_tile() + self.assertEqual(discarded_tile, Shanten.AGARI_STATE) diff --git a/project/mahjong/ai/tests/tests_strategies.py b/project/mahjong/ai/tests/tests_strategies.py index 93112fe0..97b6dbe6 100644 --- a/project/mahjong/ai/tests/tests_strategies.py +++ b/project/mahjong/ai/tests/tests_strategies.py @@ -192,7 +192,7 @@ def test_open_hand_and_discard_tiles_logic(self): 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]), '2m') + self.assertEqual(self._to_string([tile_to_discard]), '1m') class TanyaoStrategyTestCase(unittest.TestCase, TestMixin): From a25a05c9ccf1a4dd6c5f6ea1e5de643f86223f06 Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Sun, 12 Feb 2017 17:01:47 +0800 Subject: [PATCH 43/80] Close #13. Estimate each tile value before doing a discard --- project/game/tests.py | 4 +- project/mahjong/ai/discard.py | 44 ++++++++ project/mahjong/ai/main.py | 5 +- project/mahjong/ai/tests/tests_discards.py | 120 +++++++++++++++++++++ project/mahjong/constants.py | 2 + 5 files changed, 172 insertions(+), 3 deletions(-) diff --git a/project/game/tests.py b/project/game/tests.py index ecef9913..4534b4c3 100644 --- a/project/game/tests.py +++ b/project/game/tests.py @@ -191,7 +191,7 @@ def test_call_riichi(self): self.assertEqual(clients[3].player.in_riichi, True) def test_play_round_and_win_by_tsumo(self): - game.game_manager.shuffle_seed = lambda: 0.49336094054688884 + game.game_manager.shuffle_seed = lambda: 0.6633511249314604 clients = [Client() for _ in range(0, 4)] manager = GameManager(clients) @@ -252,7 +252,7 @@ def test_play_round_and_open_yakuhai_hand(self): result = manager.play_round() - self.assertEqual(len(result['players_with_open_hands']), 1) + self.assertEqual(len(result['players_with_open_hands']), 2) def test_scores_calculations_after_retake(self): clients = [Client() for _ in range(0, 4)] diff --git a/project/mahjong/ai/discard.py b/project/mahjong/ai/discard.py index 0a857598..c116dd72 100644 --- a/project/mahjong/ai/discard.py +++ b/project/mahjong/ai/discard.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- +from mahjong.constants import AKA_DORA_LIST from mahjong.tile import TilesConverter +from mahjong.utils import simplify, is_honor, plus_dora class DiscardOption(object): @@ -11,6 +13,8 @@ class DiscardOption(object): waiting = None # how much tiles will improve our hand tiles_count = None + # calculated tile value, for sorting + value = None def __init__(self, player, tile_to_discard, waiting, tiles_count): """ @@ -24,8 +28,48 @@ def __init__(self, player, tile_to_discard, waiting, tiles_count): self.waiting = waiting self.tiles_count = tiles_count + self._calculate_value() + def find_tile_in_hand(self, closed_hand): """ Find and return 136 tile in closed player hand """ + + # special case, to keep aka dora in hand + if self.tile_to_discard in [4, 13, 22]: + aka_closed_hand = closed_hand[:] + while True: + tile = TilesConverter.find_34_tile_in_136_array(self.tile_to_discard, aka_closed_hand) + # we have only aka dora in the hand + if not tile: + break + + # we found aka in the hand, + # let's try to search another five tile + # to keep aka dora + if tile in AKA_DORA_LIST: + aka_closed_hand.remove(tile) + else: + return tile + return TilesConverter.find_34_tile_in_136_array(self.tile_to_discard, closed_hand) + + def _calculate_value(self): + # base is 100 for ability to mark tiles as not needed (like set value to 50) + value = 100 + + if is_honor(self.tile_to_discard): + if self.tile_to_discard in self.player.ai.valued_honors: + count_of_winds = [x for x in self.player.ai.valued_honors if x == self.tile_to_discard] + # for west-west, east-east we had to double tile value + value += 20 * len(count_of_winds) + else: + # suits + suit_tile_grades = [10, 20, 30, 40, 50, 40, 30, 20, 10] + simplified_tile = simplify(self.tile_to_discard) + value += suit_tile_grades[simplified_tile] + + count_of_dora = plus_dora(self.tile_to_discard * 4, self.player.table.dora_indicators) + value += 50 * count_of_dora + + self.value = value diff --git a/project/mahjong/ai/main.py b/project/mahjong/ai/main.py index d2d53d12..dc3f1db6 100644 --- a/project/mahjong/ai/main.py +++ b/project/mahjong/ai/main.py @@ -159,7 +159,10 @@ def determine_strategy(self): return self.current_strategy and True or False def chose_tile_to_discard(self, results, closed_hand): - results = sorted(results, key=lambda x: x.tiles_count, reverse=True) + # - is important for x.tiles_count + # in that case we will discard tile that will give for us more tiles + # to complete a hand + results = sorted(results, key=lambda x: (-x.tiles_count, x.value)) return results[0].find_tile_in_hand(closed_hand) @property diff --git a/project/mahjong/ai/tests/tests_discards.py b/project/mahjong/ai/tests/tests_discards.py index 56d98254..8cc014c1 100644 --- a/project/mahjong/ai/tests/tests_discards.py +++ b/project/mahjong/ai/tests/tests_discards.py @@ -1,8 +1,10 @@ # -*- coding: utf-8 -*- import unittest +from mahjong.ai.discard import DiscardOption from mahjong.ai.main import MainAI from mahjong.ai.shanten import Shanten +from mahjong.constants import EAST, SOUTH, WEST, NORTH, HAKU, HATSU, CHUN, FIVE_RED_SOU from mahjong.player import Player from mahjong.table import Table from utils.tests import TestMixin @@ -78,3 +80,121 @@ def test_discard_tile(self): player.draw_tile(self._string_to_136_tile(sou='8')) discarded_tile = player.discard_tile() self.assertEqual(discarded_tile, Shanten.AGARI_STATE) + + def test_calculate_suit_tiles_value(self): + table = Table() + player = Player(0, 0, table) + + # 0 - 8 man + # 9 - 17 pin + # 18 - 26 sou + results = [ + [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], + [6, 130], [15, 130], [24, 130], + [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) + self.assertEqual(option.value, value) + + def test_calculate_honor_tiles_value(self): + table = Table() + player = Player(0, 0, table) + player.dealer_seat = 3 + + # valuable honor, wind of the round + option = DiscardOption(player, EAST, [], 0) + self.assertEqual(option.value, 120) + + # valuable honor, wind of the player + option = DiscardOption(player, SOUTH, [], 0) + self.assertEqual(option.value, 120) + + # not valuable wind + option = DiscardOption(player, WEST, [], 0) + self.assertEqual(option.value, 100) + + # not valuable wind + option = DiscardOption(player, NORTH, [], 0) + self.assertEqual(option.value, 100) + + # valuable dragon + option = DiscardOption(player, HAKU, [], 0) + self.assertEqual(option.value, 120) + + # valuable dragon + option = DiscardOption(player, HATSU, [], 0) + self.assertEqual(option.value, 120) + + # valuable dragon + option = DiscardOption(player, CHUN, [], 0) + self.assertEqual(option.value, 120) + + player.dealer_seat = 0 + + # double wind + option = DiscardOption(player, EAST, [], 0) + self.assertEqual(option.value, 140) + + def test_calculate_suit_tiles_value_and_dora(self): + table = Table() + table.dora_indicators = [self._string_to_136_tile(sou='9')] + player = Player(0, 0, table) + + tile = self._string_to_34_tile(sou='1') + option = DiscardOption(player, tile, [], 0) + self.assertEqual(option.value, 160) + + # 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) + self.assertEqual(option.value, 210) + + def test_discard_not_valuable_honor_first(self): + table = Table() + player = Player(0, 0, table) + + tiles = self._string_to_136_array(sou='123456', pin='123456', man='9', honors='2') + player.init_hand(tiles) + + discarded_tile = player.discard_tile() + self.assertEqual(self._to_string([discarded_tile]), '2z') + + def test_slide_set_to_keep_dora_in_hand(self): + table = Table() + table.dora_indicators = [self._string_to_136_tile(pin='1')] + player = Player(0, 0, table) + + tiles = self._string_to_136_array(sou='123456', pin='34578', man='99') + tile = self._string_to_136_tile(pin='2') + player.init_hand(tiles) + player.draw_tile(tile) + + # 2p is a dora, we had to keep it + discarded_tile = player.discard_tile() + self.assertEqual(self._to_string([discarded_tile]), '5p') + + def test_keep_aka_dora_in_hand(self): + table = Table() + table.dora_indicators = [self._string_to_136_tile(pin='1')] + player = Player(0, 0, table) + + tiles = self._string_to_136_array(sou='12346', pin='34578', man='99') + # five sou, we can't get it from string (because from string it is always aka dora) + tiles += [89] + player.init_hand(tiles) + player.draw_tile(FIVE_RED_SOU) + + # we had to keep red five and discard just 5s + discarded_tile = player.discard_tile() + self.assertNotEqual(discarded_tile, FIVE_RED_SOU) diff --git a/project/mahjong/constants.py b/project/mahjong/constants.py index 01c59fdb..bb5793db 100644 --- a/project/mahjong/constants.py +++ b/project/mahjong/constants.py @@ -16,6 +16,8 @@ FIVE_RED_PIN = 52 FIVE_RED_SOU = 88 +AKA_DORA_LIST = [FIVE_RED_MAN, FIVE_RED_PIN, FIVE_RED_SOU] + DISPLAY_WINDS = { EAST: 'East', SOUTH: 'South', From 7c5ae882121b738b4bf5fdbb978a0295c52bf3c3 Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Sun, 12 Feb 2017 17:47:51 +0800 Subject: [PATCH 44/80] Set is_tempai flag after meld calling --- project/game/game_manager.py | 7 +++++-- project/game/tests.py | 6 +++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/project/game/game_manager.py b/project/game/game_manager.py index 58871f4c..f992aa04 100644 --- a/project/game/game_manager.py +++ b/project/game/game_manager.py @@ -249,8 +249,8 @@ def play_round(self): # pon is more important than chi possible_melds = sorted(possible_melds, key=lambda x: x['meld'].type == Meld.PON) tile_to_discard = possible_melds[0]['discarded_tile'] - # if tile_to_discard: meld = possible_melds[0]['meld'] + shanten = possible_melds[0]['shanten'] # clear ippatsu after called meld for client_item in self.clients: @@ -275,7 +275,10 @@ def play_round(self): current_client.add_called_meld(meld) current_client.player.tiles.append(tile) - current_client.player.ai.previous_shanten = possible_melds[0]['shanten'] + current_client.player.ai.previous_shanten = shanten + + if shanten == 0: + current_client.player.in_tempai = True self.replay.open_meld(meld) diff --git a/project/game/tests.py b/project/game/tests.py index 4534b4c3..7973f8c5 100644 --- a/project/game/tests.py +++ b/project/game/tests.py @@ -16,7 +16,7 @@ def setUp(self): logger.disabled = False # def test_debug(self): - # game.game_manager.shuffle_seed = lambda: 0.4141604442053938 + # game.game_manager.shuffle_seed = lambda: 0.7843281802290311 # # clients = [Client(use_previous_ai_version=False) for _ in range(0, 4)] # # clients = [Client(use_previous_ai_version=True) for _ in range(0, 3)] @@ -24,8 +24,8 @@ def setUp(self): # manager = GameManager(clients) # manager.replay.init_game() # manager.init_game() - # manager.set_dealer(2) - # manager._unique_dealers = 3 + # manager.set_dealer(1) + # manager._unique_dealers = 2 # manager.init_round() # # result = manager.play_round() From 8a784f5d89d916b50b0a400c263f80cc232b5974 Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Wed, 15 Feb 2017 20:16:39 +0800 Subject: [PATCH 45/80] Count real remaining tiles count --- project/game/game_manager.py | 9 ++-- project/game/tests.py | 2 +- project/mahjong/ai/main.py | 7 +-- project/mahjong/ai/tests/tests_ai.py | 66 +++++++++++++++++++++++++++ project/mahjong/client.py | 20 -------- project/mahjong/table.py | 45 +++++++++++++++++- project/mahjong/tests/tests_client.py | 7 ++- project/tenhou/client.py | 2 +- project/tenhou/decoder.py | 7 +++ 9 files changed, 131 insertions(+), 34 deletions(-) diff --git a/project/game/game_manager.py b/project/game/game_manager.py index f992aa04..6a48fcd7 100644 --- a/project/game/game_manager.py +++ b/project/game/game_manager.py @@ -273,7 +273,10 @@ def play_round(self): hand_string += ' [{}]'.format(', '.join(melds)) logger.info(hand_string) - current_client.add_called_meld(meld) + # we need to notify each client about discard + for _client in self.clients: + _client.table.add_called_meld(meld, current_client.seat - _client.seat) + current_client.player.tiles.append(tile) current_client.player.ai.previous_shanten = shanten @@ -318,7 +321,7 @@ def check_clients_possible_ron(self, current_client, tile): continue # let's store other players discards - other_client.enemy_discard(tile, other_client.seat - current_client.seat) + other_client.table.enemy_discard(tile, other_client.seat - current_client.seat) # TODO support multiple ron if self.can_call_ron(other_client, tile): @@ -425,7 +428,7 @@ def call_riichi(self, client): who_called_riichi = client.seat for client in self.clients: - client.enemy_riichi(who_called_riichi - client.seat) + client.enemy_riichi(client.seat - who_called_riichi) logger.info('Riichi: {0} -1,000'.format(self.clients[who_called_riichi].player.name)) logger.info('With hand: {}'.format( diff --git a/project/game/tests.py b/project/game/tests.py index 7973f8c5..227012e1 100644 --- a/project/game/tests.py +++ b/project/game/tests.py @@ -252,7 +252,7 @@ def test_play_round_and_open_yakuhai_hand(self): result = manager.play_round() - self.assertEqual(len(result['players_with_open_hands']), 2) + self.assertEqual(len(result['players_with_open_hands']), 1) def test_scores_calculations_after_retake(self): clients = [Client() for _ in range(0, 4)] diff --git a/project/mahjong/ai/main.py b/project/mahjong/ai/main.py index dc3f1db6..f0e1e90a 100644 --- a/project/mahjong/ai/main.py +++ b/project/mahjong/ai/main.py @@ -114,10 +114,11 @@ def calculate_outs(self, tiles, closed_hand, is_open_hand=False): return results, shanten - def count_tiles(self, raw_data, tiles): + def count_tiles(self, waiting, tiles): n = 0 - for i in range(0, len(raw_data)): - n += 4 - tiles[raw_data[i]] + for item in waiting: + n += 4 - tiles[item] + n -= self.player.table.revealed_tiles[item] return n def try_to_call_meld(self, tile, is_kamicha_discard): diff --git a/project/mahjong/ai/tests/tests_ai.py b/project/mahjong/ai/tests/tests_ai.py index 9cb008e0..d29d9812 100644 --- a/project/mahjong/ai/tests/tests_ai.py +++ b/project/mahjong/ai/tests/tests_ai.py @@ -119,3 +119,69 @@ def test_chose_strategy_and_reset_strategy(self): tile = self._string_to_136_tile(sou='8') player.draw_tile(tile) self.assertEqual(player.ai.current_strategy.type, BaseStrategy.TANYAO) + + def test_remaining_tiles_and_enemy_discard(self): + table = Table() + player = Player(0, 0, table) + + tiles = self._string_to_136_array(man='123456789', sou='167', honors='77') + player.init_hand(tiles) + + results, shanten = player.ai.calculate_outs(tiles, tiles) + self.assertEqual(results[0].tiles_count, 8) + + player.table.enemy_discard(self._string_to_136_tile(sou='5'), 1) + + results, shanten = player.ai.calculate_outs(tiles, tiles) + self.assertEqual(results[0].tiles_count, 7) + + player.table.enemy_discard(self._string_to_136_tile(sou='5'), 2) + player.table.enemy_discard(self._string_to_136_tile(sou='8'), 3) + + results, shanten = player.ai.calculate_outs(tiles, tiles) + self.assertEqual(results[0].tiles_count, 5) + + def test_remaining_tiles_and_opened_meld(self): + table = Table() + player = Player(0, 0, table) + + tiles = self._string_to_136_array(man='123456789', sou='167', honors='77') + player.init_hand(tiles) + + results, shanten = player.ai.calculate_outs(tiles, tiles) + self.assertEqual(results[0].tiles_count, 8) + + # was discard and set was opened + tile = self._string_to_136_tile(sou='8') + player.table.enemy_discard(tile, 3) + meld = self._make_meld(Meld.PON, self._string_to_136_array(sou='888')) + meld.called_tile = tile + player.table.add_called_meld(meld, 3) + + results, shanten = player.ai.calculate_outs(tiles, tiles) + self.assertEqual(results[0].tiles_count, 5) + + # was discard and set was opened + tile = self._string_to_136_tile(sou='3') + player.table.enemy_discard(tile, 2) + meld = self._make_meld(Meld.PON, self._string_to_136_array(sou='345')) + meld.called_tile = tile + player.table.add_called_meld(meld, 2) + + results, shanten = player.ai.calculate_outs(tiles, tiles) + self.assertEqual(results[0].tiles_count, 4) + + def test_remaining_tiles_and_dora_indicators(self): + table = Table() + player = Player(0, 0, table) + + tiles = self._string_to_136_array(man='123456789', sou='167', honors='77') + player.init_hand(tiles) + + results, shanten = player.ai.calculate_outs(tiles, tiles) + self.assertEqual(results[0].tiles_count, 8) + + table.add_dora_indicator(self._string_to_136_tile(sou='8')) + + results, shanten = player.ai.calculate_outs(tiles, tiles) + self.assertEqual(results[0].tiles_count, 7) diff --git a/project/mahjong/client.py b/project/mahjong/client.py index 20dafdc2..c631737f 100644 --- a/project/mahjong/client.py +++ b/project/mahjong/client.py @@ -34,25 +34,5 @@ def draw_tile(self, tile): def discard_tile(self, tile=None): return self.player.discard_tile(tile) - def add_called_meld(self, meld): - # when opponent called meld it is means - # that he discards tile from hand, not from wall - self.table.count_of_remaining_tiles += 1 - - return self.player.add_called_meld(meld) - - def enemy_discard(self, tile, player_seat): - """ - :param player_seat: - :param tile: 136 format tile - :return: - """ - self.table.get_player(player_seat).add_discarded_tile(tile) - self.table.count_of_remaining_tiles -= 1 - - for player in self.table.players: - if player.in_riichi: - player.safe_tiles.append(tile) - def enemy_riichi(self, player_seat): self.table.get_player(player_seat).in_riichi = True diff --git a/project/mahjong/table.py b/project/mahjong/table.py index 33fdc2c0..b94eef3d 100644 --- a/project/mahjong/table.py +++ b/project/mahjong/table.py @@ -17,9 +17,13 @@ class Table(object): count_of_remaining_tiles = 0 count_of_players = 4 + # array of tiles in 34 format + revealed_tiles = [] + def __init__(self, use_previous_ai_version=False): self.dora_indicators = [] self._init_players(use_previous_ai_version) + self.revealed_tiles = [0] * 34 def __str__(self): return 'Round: {0}, Honba: {1}, Dora Indicators: {2}'.format(self.round_number, @@ -36,6 +40,8 @@ def init_round(self, round_number, count_of_honba_sticks, count_of_riichi_sticks self.dora_indicators = [] self.add_dora_indicator(dora_indicator) + self.revealed_tiles = [0] * 34 + # erase players state for player in self.players: player.erase_state() @@ -51,11 +57,26 @@ def init_round(self, round_number, count_of_honba_sticks, count_of_riichi_sticks def init_main_player_hand(self, tiles): self.get_main_player().init_hand(tiles) - def add_open_set(self, meld): - self.get_player(meld.who).add_called_meld(meld) + def add_called_meld(self, meld, player_seat): + # when opponent called meld it is means + # that he discards tile from hand, not from wall + self.count_of_remaining_tiles += 1 + + self.get_player(player_seat).add_called_meld(meld) + + tiles = meld.tiles[:] + # called tile was already added to revealed array + # because of discard + # for closed kan we will not have called_tile + if meld.called_tile: + tiles.remove(meld.called_tile) + + for tile in tiles: + self._add_revealed_tile(tile) def add_dora_indicator(self, tile): self.dora_indicators.append(tile) + self._add_revealed_tile(tile) def is_dora(self, tile): return plus_dora(tile, self.dora_indicators) or is_aka_dora(tile) @@ -89,6 +110,22 @@ def get_main_player(self) -> Player: def get_players_sorted_by_scores(self): return sorted(self.players, key=lambda x: x.scores, reverse=True) + def enemy_discard(self, tile, player_seat): + """ + :param player_seat: + :param tile: 136 format tile + :return: + """ + self.get_player(player_seat).add_discarded_tile(tile) + self.count_of_remaining_tiles -= 1 + + for player in self.players: + if player.in_riichi: + player.safe_tiles.append(tile) + + # cache already revealed tiles + self._add_revealed_tile(tile) + def _init_players(self, use_previous_ai_version=False): self.players = [] @@ -109,3 +146,7 @@ def round_wind(self): return WEST else: return NORTH + + def _add_revealed_tile(self, tile): + tile //= 4 + self.revealed_tiles[tile] += 1 diff --git a/project/mahjong/tests/tests_client.py b/project/mahjong/tests/tests_client.py index 63f975a4..f9e80be3 100644 --- a/project/mahjong/tests/tests_client.py +++ b/project/mahjong/tests/tests_client.py @@ -39,8 +39,7 @@ def test_call_meld(self): self.assertEqual(client.table.count_of_remaining_tiles, 70) meld = Meld() - - client.add_called_meld(meld) + client.table.add_called_meld(meld, 0) self.assertEqual(len(client.player.melds), 1) self.assertEqual(client.table.count_of_remaining_tiles, 71) @@ -51,7 +50,7 @@ def test_enemy_discard(self): self.assertEqual(client.table.count_of_remaining_tiles, 70) - client.enemy_discard(10, 1) + client.table.enemy_discard(10, 1) self.assertEqual(len(client.table.get_player(1).discards), 1) self.assertEqual(client.table.count_of_remaining_tiles, 69) @@ -61,7 +60,7 @@ def test_enemy_discard_and_safe_tiles(self): client.table.init_round(0, 0, 0, 0, 0, [0, 0, 0, 0]) client.table.players[0].in_riichi = True - client.enemy_discard(10, 1) + client.table.enemy_discard(10, 1) self.assertEqual(len(client.table.players[0].safe_tiles), 1) diff --git a/project/tenhou/client.py b/project/tenhou/client.py index 3023e757..3acb8da3 100644 --- a/project/tenhou/client.py +++ b/project/tenhou/client.py @@ -236,7 +236,7 @@ def start_game(self): else: player_seat = 3 - self.enemy_discard(tile, player_seat) + self.table.enemy_discard(tile, player_seat) if 'owari' in message: values = self.decoder.parse_final_scores_and_uma(message) diff --git a/project/tenhou/decoder.py b/project/tenhou/decoder.py index 09d4400b..03d1a25d 100644 --- a/project/tenhou/decoder.py +++ b/project/tenhou/decoder.py @@ -155,26 +155,33 @@ def parse_chi(self, data, meld): t0, t1, t2 = (data >> 3) & 0x3, (data >> 5) & 0x3, (data >> 7) & 0x3 base_and_called = data >> 10 base = base_and_called // 3 + called = base_and_called % 3 base = (base // 7) * 9 + base % 7 meld.tiles = [t0 + 4 * (base + 0), t1 + 4 * (base + 1), t2 + 4 * (base + 2)] + meld.called_tile = meld.tiles[called] def parse_pon(self, data, meld): t4 = (data >> 5) & 0x3 t0, t1, t2 = ((1, 2, 3), (0, 2, 3), (0, 1, 3), (0, 1, 2))[t4] base_and_called = data >> 9 base = base_and_called // 3 + called = base_and_called % 3 if data & 0x8: meld.type = Meld.PON meld.tiles = [t0 + 4 * base, t1 + 4 * base, t2 + 4 * base] else: meld.type = Meld.CHAKAN meld.tiles = [t0 + 4 * base, t1 + 4 * base, t2 + 4 * base, t4 + 4 * base] + meld.called_tile = meld.tiles[called] def parse_kan(self, data, meld): base_and_called = data >> 8 base = base_and_called // 4 meld.type = Meld.KAN meld.tiles = [4 * base, 1 + 4 * base, 2 + 4 * base, 3 + 4 * base] + if meld.from_who: + called = base_and_called % 4 + meld.called_tile = meld.tiles[called] def parse_nuki(self, data, meld): meld.type = Meld.NUKI From 527bb522596e1f00a18fbdee55563c277f5ee741 Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Thu, 16 Feb 2017 13:31:38 +0800 Subject: [PATCH 46/80] Fix an issue with wrong players positions in local games --- project/game/game_manager.py | 16 ++++++++++------ project/game/tests.py | 8 ++++---- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/project/game/game_manager.py b/project/game/game_manager.py index 6a48fcd7..da386735 100644 --- a/project/game/game_manager.py +++ b/project/game/game_manager.py @@ -102,7 +102,7 @@ def init_round(self): # each client think that he is a player with position = 0 # so, we need to move dealer position for each client # and shift scores array - client_dealer = self.dealer - x + client_dealer = self._enemy_position(self.dealer, x) player_scores = deque([i.player.scores / 100 for i in self.clients]) player_scores.rotate(x * -1) @@ -273,9 +273,9 @@ def play_round(self): hand_string += ' [{}]'.format(', '.join(melds)) logger.info(hand_string) - # we need to notify each client about discard + # we need to notify each client about called meld for _client in self.clients: - _client.table.add_called_meld(meld, current_client.seat - _client.seat) + _client.table.add_called_meld(meld, self._enemy_position(current_client.seat, _client.seat)) current_client.player.tiles.append(tile) current_client.player.ai.previous_shanten = shanten @@ -321,7 +321,7 @@ def check_clients_possible_ron(self, current_client, tile): continue # let's store other players discards - other_client.table.enemy_discard(tile, other_client.seat - current_client.seat) + other_client.table.enemy_discard(tile, self._enemy_position(current_client.seat, other_client.seat)) # TODO support multiple ron if self.can_call_ron(other_client, tile): @@ -428,7 +428,7 @@ def call_riichi(self, client): who_called_riichi = client.seat for client in self.clients: - client.enemy_riichi(client.seat - who_called_riichi) + client.enemy_riichi(self._enemy_position(who_called_riichi, client.seat)) logger.info('Riichi: {0} -1,000'.format(self.clients[who_called_riichi].player.name)) logger.info('With hand: {}'.format( @@ -445,7 +445,7 @@ def set_dealer(self, dealer): # each client think that he is a player with position = 0 # so, we need to move dealer position for each client # and shift scores array - client.player.dealer_seat = dealer - x + client.player.dealer_seat = self._enemy_position(self.dealer, x) # first move should be dealer's move self.current_client_seat = dealer @@ -686,3 +686,7 @@ def _move_position(self, current_position): if current_position > 3: current_position = 0 return current_position + + def _enemy_position(self, who, from_who): + positions = [0, 1, 2, 3] + return positions[who - from_who] diff --git a/project/game/tests.py b/project/game/tests.py index 227012e1..d50820ee 100644 --- a/project/game/tests.py +++ b/project/game/tests.py @@ -16,7 +16,7 @@ def setUp(self): logger.disabled = False # def test_debug(self): - # game.game_manager.shuffle_seed = lambda: 0.7843281802290311 + # game.game_manager.shuffle_seed = lambda: 0.46500720723007105 # # clients = [Client(use_previous_ai_version=False) for _ in range(0, 4)] # # clients = [Client(use_previous_ai_version=True) for _ in range(0, 3)] @@ -24,8 +24,8 @@ def setUp(self): # manager = GameManager(clients) # manager.replay.init_game() # manager.init_game() - # manager.set_dealer(1) - # manager._unique_dealers = 2 + # manager.set_dealer(2) + # manager._unique_dealers = 3 # manager.init_round() # # result = manager.play_round() @@ -191,7 +191,7 @@ def test_call_riichi(self): self.assertEqual(clients[3].player.in_riichi, True) def test_play_round_and_win_by_tsumo(self): - game.game_manager.shuffle_seed = lambda: 0.6633511249314604 + game.game_manager.shuffle_seed = lambda: 0.17868292506833006 clients = [Client() for _ in range(0, 4)] manager = GameManager(clients) From 3da086f15d5466bf091c80a5b4cb1be7a41372bf Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Thu, 16 Feb 2017 13:56:13 +0800 Subject: [PATCH 47/80] Fix an issue with opened chitoitsu like hand --- project/mahjong/ai/main.py | 2 +- project/mahjong/ai/strategies/main.py | 4 ++++ project/mahjong/ai/tests/tests_strategies.py | 15 +++++++++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/project/mahjong/ai/main.py b/project/mahjong/ai/main.py index f0e1e90a..770e1c67 100644 --- a/project/mahjong/ai/main.py +++ b/project/mahjong/ai/main.py @@ -129,7 +129,7 @@ def try_to_call_meld(self, tile, is_kamicha_discard): def determine_strategy(self): # for already opened hand we don't need to give up on selected strategy - if self.player.is_open_hand: + if self.player.is_open_hand and self.current_strategy: return False old_strategy = self.current_strategy diff --git a/project/mahjong/ai/strategies/main.py b/project/mahjong/ai/strategies/main.py index ae225889..93fd6446 100644 --- a/project/mahjong/ai/strategies/main.py +++ b/project/mahjong/ai/strategies/main.py @@ -35,8 +35,12 @@ def should_activate_strategy(self): For now default rule for all strategies: don't open hand with 5+ pairs :return: boolean """ + if self.player.is_open_hand: + return True + 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 count_of_pairs < 5 def is_tile_suitable(self, tile): diff --git a/project/mahjong/ai/tests/tests_strategies.py b/project/mahjong/ai/tests/tests_strategies.py index 97b6dbe6..a18dcd43 100644 --- a/project/mahjong/ai/tests/tests_strategies.py +++ b/project/mahjong/ai/tests/tests_strategies.py @@ -360,3 +360,18 @@ def test_open_hand_and_discard_tiles_logic(self): # 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 = Table() + player = Player(0, 0, table) + + meld = self._make_meld(Meld.PON, self._string_to_136_array(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) From 25dbc817e6396672ed4277e51a698dc0b9023299 Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Thu, 16 Feb 2017 16:30:00 +0800 Subject: [PATCH 48/80] Add owari results to the tenhou log --- project/game/replays/tenhou.py | 35 ++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/project/game/replays/tenhou.py b/project/game/replays/tenhou.py index 0569e611..d604f8c0 100644 --- a/project/game/replays/tenhou.py +++ b/project/game/replays/tenhou.py @@ -25,6 +25,9 @@ def init_game(self): self.tags.append('') def end_game(self): + self.tags[-1] = self.tags[-1].replace('/>', '') + self.tags[-1] += 'owari="{}" />'.format(self._calculate_final_scores_and_uma()) + self.tags.append('') with open(os.path.join(self.replays_directory, self.replay_name), 'w') as f: @@ -247,3 +250,35 @@ def _from_who_offset(self, who, from_who): if result < 0: result += 4 return result + + def _calculate_final_scores_and_uma(self): + data = [] + for client in self.clients: + data.append({ + 'position': None, + 'seat': client.seat, + 'uma': 0, + 'scores': client.player.scores + }) + + data = sorted(data, key=lambda x: (-x['scores'], x['seat'])) + for x in range(0, len(data)): + data[x]['position'] = x + 1 + + uma_list = [20000, 10000, -10000, -20000] + for item in data: + x = item['scores'] - 30000 + uma_list[item['position'] - 1] + + # 10000 oka bonus for the first place + if item['position'] == 1: + x += 10000 + + item['uma'] = round(x / 1000) + item['scores'] = round(item['scores'] / 100) + + data = sorted(data, key=lambda x: x['seat']) + results = [] + for item in data: + results.append('{},{}'.format(item['scores'], item['uma'])) + + return ','.join(results) From d1c5ba5c52ebc6f69e101b22e4a45c2fc973e1c2 Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Thu, 16 Feb 2017 19:02:17 +0800 Subject: [PATCH 49/80] Generate more random wall --- project/game/game_manager.py | 36 +++++++++++++++++++++++++----------- project/game/tests.py | 7 ++++--- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/project/game/game_manager.py b/project/game/game_manager.py index da386735..f1e14f6e 100644 --- a/project/game/game_manager.py +++ b/project/game/game_manager.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- import logging from collections import deque -from random import randint, shuffle, random +from random import randint, shuffle, random, seed from game.logger import set_up_logging from game.replays.tenhou import TenhouReplay as Replay @@ -63,6 +63,9 @@ def init_game(self): Beginning of the game. Clients random placement and dealer selection. """ + + logger.info('Seed: {}'.format(shuffle_seed())) + shuffle(self.clients, shuffle_seed) for i in range(0, len(self.clients)): self.clients[i].seat = i @@ -81,17 +84,10 @@ def init_round(self): Generate players hands, dead wall and dora indicators """ - # each round should have personal seed - global seed_value - seed_value = random() - self.players_with_open_hands = [] self.dora_indicators = [] - self.tiles = [i for i in range(0, 136)] - - # need to change random function in future - shuffle(self.tiles, shuffle_seed) + self.tiles = self._generate_wall() self.dead_wall = self._cut_tiles(14) self.dora_indicators.append(self.dead_wall[8]) @@ -131,7 +127,7 @@ def init_round(self): client.player.tiles = sorted(client.player.tiles) client.init_hand(client.player.tiles) - logger.info('Seed: {}'.format(shuffle_seed())) + logger.info('Round number: {}'.format(self.round_number)) logger.info('Dealer: {}, {}'.format(self.dealer, self.clients[self.dealer].player.name)) logger.info('Wind: {}. Riichi sticks: {}. Honba sticks: {}'.format( self._unique_dealers, @@ -460,7 +456,7 @@ def process_the_end_of_the_round(self, tiles, win_tile, winner, loser, is_tsumo) raise ValueError('Wrong tiles count: {}'.format(len(tiles))) if winner: - logger.info('{0}: {1} + {2}'.format( + logger.info('{}: {} + {}'.format( is_tsumo and 'Tsumo' or 'Ron', TilesConverter.to_one_line_string(tiles), TilesConverter.to_one_line_string([win_tile])), @@ -690,3 +686,21 @@ def _move_position(self, current_position): def _enemy_position(self, who, from_who): positions = [0, 1, 2, 3] return positions[who - from_who] + + def _generate_wall(self): + seed(shuffle_seed() + self.round_number) + + wall = [i for i in range(0, 136)] + rand_seeds = [randint(0, 135) for i in range(0, 136)] + + # for better wall shuffling we had to do it manually + # shuffle() didn't make wall to be really random + for x in range(0, 136): + src = x + dst = rand_seeds[x] + + swap = wall[x] + wall[src] = wall[dst] + wall[dst] = swap + + return wall diff --git a/project/game/tests.py b/project/game/tests.py index d50820ee..93b270ae 100644 --- a/project/game/tests.py +++ b/project/game/tests.py @@ -16,7 +16,7 @@ def setUp(self): logger.disabled = False # def test_debug(self): - # game.game_manager.shuffle_seed = lambda: 0.46500720723007105 + # game.game_manager.shuffle_seed = lambda: 0.5805503012183432 # # clients = [Client(use_previous_ai_version=False) for _ in range(0, 4)] # # clients = [Client(use_previous_ai_version=True) for _ in range(0, 3)] @@ -24,8 +24,9 @@ def setUp(self): # manager = GameManager(clients) # manager.replay.init_game() # manager.init_game() - # manager.set_dealer(2) - # manager._unique_dealers = 3 + # manager.set_dealer(0) + # manager._unique_dealers = 1 + # manager.round_number = 0 # manager.init_round() # # result = manager.play_round() From 764d903ee56cb6f603ba5fbb20009e7baa2ec4f9 Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Thu, 16 Feb 2017 23:37:26 +0800 Subject: [PATCH 50/80] Improve yakuhai strategy --- project/mahjong/ai/strategies/main.py | 10 +++++- project/mahjong/ai/strategies/yakuhai.py | 24 ++++++++++++++ project/mahjong/ai/tests/tests_strategies.py | 35 ++++++++++++++++++++ 3 files changed, 68 insertions(+), 1 deletion(-) diff --git a/project/mahjong/ai/strategies/main.py b/project/mahjong/ai/strategies/main.py index 93fd6446..ac54cc23 100644 --- a/project/mahjong/ai/strategies/main.py +++ b/project/mahjong/ai/strategies/main.py @@ -119,7 +119,7 @@ def try_to_call_meld(self, tile, is_kamicha_discard): # tile will decrease the count of shanten in hand # so let's call opened set with it - if shanten < self.player.ai.previous_shanten: + if shanten < self.player.ai.previous_shanten or self.meld_had_to_be_called(tile): closed_hand_34 = TilesConverter.to_34_array(closed_hand + [tile]) combinations = [] @@ -214,6 +214,14 @@ def try_to_call_meld(self, tile, is_kamicha_discard): return None, None, None + 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, closed_hand_34, first_limit, second_limit, completed_hand): """ For now best meld will be the meld with higher count of remaining sets in the hand diff --git a/project/mahjong/ai/strategies/yakuhai.py b/project/mahjong/ai/strategies/yakuhai.py index 71a12371..0299f264 100644 --- a/project/mahjong/ai/strategies/yakuhai.py +++ b/project/mahjong/ai/strategies/yakuhai.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- from mahjong.ai.strategies.main import BaseStrategy +from mahjong.meld import Meld from mahjong.tile import TilesConverter @@ -44,3 +45,26 @@ def determine_what_to_discard(self, closed_hand, outs_results, shanten, for_open outs_results, shanten, for_open_hand) + + 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.ai.valued_honors if tiles_34[x] == 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 meld.type == Meld.PON and meld.tiles[0] // 4 in self.player.ai.valued_honors: + return False + + # open hand for valued pon + for valued_pair in valued_pairs: + if valued_pair == tile: + return True + + return False + diff --git a/project/mahjong/ai/tests/tests_strategies.py b/project/mahjong/ai/tests/tests_strategies.py index a18dcd43..6ee439e4 100644 --- a/project/mahjong/ai/tests/tests_strategies.py +++ b/project/mahjong/ai/tests/tests_strategies.py @@ -121,6 +121,41 @@ def test_force_yakuhai_pair_waiting_for_tempai_hand(self): self.assertEqual(meld.type, Meld.CHI) self.assertEqual(self._to_string(meld.tiles), '678m') + def test_call_yakuhai_pair_and_special_conditions(self): + table = Table() + player = Player(0, 0, table) + + tiles = self._string_to_136_array(man='56', sou='1235', pin='12888', honors='11') + player.init_hand(tiles) + + meld = self._make_meld(Meld.PON, self._string_to_136_array(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, tile_to_discard, shanten = player.try_to_call_meld(tile, True) + self.assertNotEqual(meld, None) + + table = Table() + player = Player(0, 0, table) + + tiles = self._string_to_136_array(man='56', sou='1235', pin='12', honors='11777') + player.init_hand(tiles) + + meld = self._make_meld(Meld.PON, self._string_to_136_array(honors='777')) + player.add_called_meld(meld) + # to update previous_shanten attribute + player.draw_tile(self._string_to_136_tile(honors='3')) + player.discard_tile() + + # we don't need to open hand with already opened yakuhai set + tile = self._string_to_136_tile(honors='1') + meld, tile_to_discard, shanten = player.try_to_call_meld(tile, True) + self.assertEqual(meld, None) + class HonitsuStrategyTestCase(unittest.TestCase, TestMixin): From 3531c0b3c60d7157de26cacb0b257a5fadd3267d Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Fri, 17 Feb 2017 16:36:06 +0800 Subject: [PATCH 51/80] Shuffle wall two times just in case --- project/game/game_manager.py | 26 ++++++++------ project/game/tests.py | 69 +++++++++++------------------------- 2 files changed, 36 insertions(+), 59 deletions(-) diff --git a/project/game/game_manager.py b/project/game/game_manager.py index f1e14f6e..a00524da 100644 --- a/project/game/game_manager.py +++ b/project/game/game_manager.py @@ -690,17 +690,23 @@ def _enemy_position(self, who, from_who): def _generate_wall(self): seed(shuffle_seed() + self.round_number) - wall = [i for i in range(0, 136)] - rand_seeds = [randint(0, 135) for i in range(0, 136)] + def shuffle_wall(rand_seeds): + # for better wall shuffling we had to do it manually + # shuffle() didn't make wall to be really random + for x in range(0, 136): + src = x + dst = rand_seeds[x] + + swap = wall[x] + wall[src] = wall[dst] + wall[dst] = swap - # for better wall shuffling we had to do it manually - # shuffle() didn't make wall to be really random - for x in range(0, 136): - src = x - dst = rand_seeds[x] + wall = [i for i in range(0, 136)] + rand_one = [randint(0, 135) for i in range(0, 136)] + rand_two = [randint(0, 135) for i in range(0, 136)] - swap = wall[x] - wall[src] = wall[dst] - wall[dst] = swap + # let's shuffle wall two times just in case + shuffle_wall(rand_one) + shuffle_wall(rand_two) return wall diff --git a/project/game/tests.py b/project/game/tests.py index 93b270ae..8d195127 100644 --- a/project/game/tests.py +++ b/project/game/tests.py @@ -192,68 +192,75 @@ def test_call_riichi(self): self.assertEqual(clients[3].player.in_riichi, True) def test_play_round_and_win_by_tsumo(self): - game.game_manager.shuffle_seed = lambda: 0.17868292506833006 + game.game_manager.shuffle_seed = lambda: 0.8689851662263914 clients = [Client() for _ in range(0, 4)] manager = GameManager(clients) manager.init_game() - manager.set_dealer(0) manager.init_round() + manager.set_dealer(2) + manager._unique_dealers = 3 + manager.round_number = 3 result = manager.play_round() - self.assertEqual(manager.round_number, 1) + self.assertEqual(manager.round_number, 4) self.assertEqual(result['is_tsumo'], True) self.assertEqual(result['is_game_end'], False) self.assertNotEqual(result['winner'], None) self.assertEqual(result['loser'], None) def test_play_round_and_win_by_ron(self): - game.game_manager.shuffle_seed = lambda: 0.33 + game.game_manager.shuffle_seed = lambda: 0.8689851662263914 clients = [Client() for _ in range(0, 4)] manager = GameManager(clients) manager.init_game() - manager.set_dealer(3) manager.init_round() + manager.set_dealer(3) + manager._unique_dealers = 4 + manager.round_number = 5 result = manager.play_round() - self.assertEqual(manager.round_number, 1) + self.assertEqual(manager.round_number, 6) self.assertEqual(result['is_tsumo'], False) self.assertEqual(result['is_game_end'], False) self.assertNotEqual(result['winner'], None) self.assertNotEqual(result['loser'], None) def test_play_round_with_retake(self): - game.game_manager.shuffle_seed = lambda: 0.3939281197763548 + game.game_manager.shuffle_seed = lambda: 0.1096086064947076 clients = [Client() for _ in range(0, 4)] manager = GameManager(clients) manager.init_game() - manager.set_dealer(0) + manager.set_dealer(2) + manager._unique_dealers = 3 + manager.round_number = 2 manager.init_round() result = manager.play_round() - self.assertEqual(manager.round_number, 1) + self.assertEqual(manager.round_number, 3) self.assertEqual(result['is_tsumo'], False) self.assertEqual(result['is_game_end'], False) self.assertEqual(result['winner'], None) self.assertEqual(result['loser'], None) - def test_play_round_and_open_yakuhai_hand(self): - game.game_manager.shuffle_seed = lambda: 0.457500580104948 + def test_play_round_and_open_hand(self): + game.game_manager.shuffle_seed = lambda: 0.8689851662263914 clients = [Client() for _ in range(0, 4)] manager = GameManager(clients) manager.init_game() - manager.set_dealer(3) manager.init_round() + manager.set_dealer(0) + manager.round_number = 0 result = manager.play_round() - self.assertEqual(len(result['players_with_open_hands']), 1) + self.assertEqual(len(result['players_with_open_hands']), 4) def test_scores_calculations_after_retake(self): clients = [Client() for _ in range(0, 4)] @@ -508,39 +515,3 @@ def test_is_game_end_by_eight_winds(self): result = manager.process_the_end_of_the_round(list(range(0, 13)), 0, clients[0], None, True) self.assertEqual(result['is_game_end'], True) - - def test_ron_with_not_correct_hand(self): - """ - With open for yakuhai strategy we can have situation like this - 234567m67s66z + 8s + [444z] - We have open hand and we don't have yaku in the hand - In that case we can't call ron. - Round should be ended without exceptions - """ - game.game_manager.shuffle_seed = lambda: 0.5082102963203375 - - clients = [Client() for _ in range(0, 4)] - manager = GameManager(clients) - manager.init_game() - manager.set_dealer(1) - manager.init_round() - - manager.play_round() - - def test_tsumo_with_not_correct_hand(self): - """ - With open for yakuhai strategy we can have situation like this - 234567m67s66z + 8s + [444z] - We have open hand and we don't have yaku in the hand - In that case we can't call tsumo. - Round should be ended without exceptions - """ - game.game_manager.shuffle_seed = lambda: 0.26483054978923926 - - clients = [Client() for _ in range(0, 4)] - manager = GameManager(clients) - manager.init_game() - manager.set_dealer(1) - manager.init_round() - - manager.play_round() From 9b360e64088e1b0061bb24f46cd5fa2a10387865 Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Fri, 17 Feb 2017 23:32:45 +0800 Subject: [PATCH 52/80] Improve yakuhai strategy --- project/game/game_manager.py | 2 +- project/mahjong/ai/main.py | 3 ++- project/mahjong/ai/strategies/main.py | 5 +++-- project/mahjong/ai/strategies/yakuhai.py | 10 +++++++--- project/mahjong/ai/tests/tests_strategies.py | 7 +++++++ 5 files changed, 20 insertions(+), 7 deletions(-) diff --git a/project/game/game_manager.py b/project/game/game_manager.py index a00524da..b557e0e7 100644 --- a/project/game/game_manager.py +++ b/project/game/game_manager.py @@ -428,7 +428,7 @@ def call_riichi(self, client): logger.info('Riichi: {0} -1,000'.format(self.clients[who_called_riichi].player.name)) logger.info('With hand: {}'.format( - TilesConverter.to_one_line_string(client.player.closed_hand) + TilesConverter.to_one_line_string(self.clients[who_called_riichi].player.closed_hand) )) def set_dealer(self, dealer): diff --git a/project/mahjong/ai/main.py b/project/mahjong/ai/main.py index 770e1c67..7b014add 100644 --- a/project/mahjong/ai/main.py +++ b/project/mahjong/ai/main.py @@ -64,7 +64,8 @@ def discard_tile(self): results = self.current_strategy.determine_what_to_discard(self.player.closed_hand, results, shanten, - False) + False, + None) return self.chose_tile_to_discard(results, self.player.closed_hand) diff --git a/project/mahjong/ai/strategies/main.py b/project/mahjong/ai/strategies/main.py index ac54cc23..ce570386 100644 --- a/project/mahjong/ai/strategies/main.py +++ b/project/mahjong/ai/strategies/main.py @@ -51,12 +51,13 @@ def is_tile_suitable(self, tile): """ raise NotImplemented() - def determine_what_to_discard(self, closed_hand, outs_results, shanten, for_open_hand): + def determine_what_to_discard(self, closed_hand, outs_results, shanten, for_open_hand, tile_for_open_hand): """ :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 :return: array of DiscardOption """ @@ -201,7 +202,7 @@ def try_to_call_meld(self, tile, is_kamicha_discard): meld.type = meld_type meld.tiles = sorted(tiles) - results = self.determine_what_to_discard(closed_hand, outs_results, shanten, True) + results = self.determine_what_to_discard(closed_hand, outs_results, shanten, True, tile) # we don't have tiles to discard after hand opening # so, we don't need to open hand if not results: diff --git a/project/mahjong/ai/strategies/yakuhai.py b/project/mahjong/ai/strategies/yakuhai.py index 0299f264..6b3550d2 100644 --- a/project/mahjong/ai/strategies/yakuhai.py +++ b/project/mahjong/ai/strategies/yakuhai.py @@ -27,12 +27,15 @@ def is_tile_suitable(self, tile): """ return True - def determine_what_to_discard(self, closed_hand, outs_results, shanten, for_open_hand): + def determine_what_to_discard(self, closed_hand, outs_results, shanten, for_open_hand, tile_for_open_hand): + 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.ai.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: + 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 = [] @@ -44,7 +47,8 @@ def determine_what_to_discard(self, closed_hand, outs_results, shanten, for_open return super(YakuhaiStrategy, self).determine_what_to_discard(closed_hand, outs_results, shanten, - for_open_hand) + for_open_hand, + tile_for_open_hand) def meld_had_to_be_called(self, tile): # for closed hand we don't need to open hand with special conditions diff --git a/project/mahjong/ai/tests/tests_strategies.py b/project/mahjong/ai/tests/tests_strategies.py index 6ee439e4..c9cb2cd2 100644 --- a/project/mahjong/ai/tests/tests_strategies.py +++ b/project/mahjong/ai/tests/tests_strategies.py @@ -121,6 +121,13 @@ def test_force_yakuhai_pair_waiting_for_tempai_hand(self): self.assertEqual(meld.type, Meld.CHI) self.assertEqual(self._to_string(meld.tiles), '678m') + # we can open hand in that case + tiles = self._string_to_136_array(man='44556', sou='366789', honors='77') + tile = self._string_to_136_tile(honors='7') + player.init_hand(tiles) + meld, tile_to_discard, shanten = player.try_to_call_meld(tile, True) + self.assertEqual(self._to_string(meld.tiles), '777z') + def test_call_yakuhai_pair_and_special_conditions(self): table = Table() player = Player(0, 0, table) From 43069afe47fd62265110852b5171b3a510e0478f Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Sat, 18 Feb 2017 08:37:41 +0800 Subject: [PATCH 53/80] Improve the way to work with tempai and no yaku --- project/game/tests.py | 8 +++---- project/mahjong/ai/main.py | 10 ++++++++- project/mahjong/ai/tests/tests_strategies.py | 23 ++++++++++++++++++++ 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/project/game/tests.py b/project/game/tests.py index 8d195127..0ee78f31 100644 --- a/project/game/tests.py +++ b/project/game/tests.py @@ -16,7 +16,7 @@ def setUp(self): logger.disabled = False # def test_debug(self): - # game.game_manager.shuffle_seed = lambda: 0.5805503012183432 + # game.game_manager.shuffle_seed = lambda: 0.09764471694361732 # # clients = [Client(use_previous_ai_version=False) for _ in range(0, 4)] # # clients = [Client(use_previous_ai_version=True) for _ in range(0, 3)] @@ -24,9 +24,9 @@ def setUp(self): # manager = GameManager(clients) # manager.replay.init_game() # manager.init_game() - # manager.set_dealer(0) - # manager._unique_dealers = 1 - # manager.round_number = 0 + # manager.set_dealer(2) + # manager._unique_dealers = 3 + # manager.round_number = 2 # manager.init_round() # # result = manager.play_round() diff --git a/project/mahjong/ai/main.py b/project/mahjong/ai/main.py index 7b014add..ae0df80c 100644 --- a/project/mahjong/ai/main.py +++ b/project/mahjong/ai/main.py @@ -52,7 +52,15 @@ def discard_tile(self): # we are win! if shanten == Shanten.AGARI_STATE: - return Shanten.AGARI_STATE + # we draw a tile that complete our four sets and pair + # but we can't win with it, because we don't have a yaku + # so let's discard it + if (self.current_strategy and + not self.current_strategy.is_tile_suitable(self.player.last_draw) and + self.player.is_open_hand): + return self.player.last_draw + else: + return Shanten.AGARI_STATE # we are in agari state, but we can't win because we don't have yaku # in that case let's do tsumogiri diff --git a/project/mahjong/ai/tests/tests_strategies.py b/project/mahjong/ai/tests/tests_strategies.py index c9cb2cd2..a5fae525 100644 --- a/project/mahjong/ai/tests/tests_strategies.py +++ b/project/mahjong/ai/tests/tests_strategies.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- import unittest +from mahjong.ai.shanten import Shanten from mahjong.ai.strategies.honitsu import HonitsuStrategy from mahjong.ai.strategies.main import BaseStrategy from mahjong.ai.strategies.tanyao import TanyaoStrategy @@ -417,3 +418,25 @@ def test_dont_count_pairs_in_already_opened_hand(self): 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 = Table() + player = Player(0, 0, table) + + tiles = self._string_to_136_array(man='34577', sou='23', pin='233445') + player.init_hand(tiles) + player.draw_tile(self._string_to_136_tile(sou='1')) + player.ai.current_strategy = TanyaoStrategy(BaseStrategy.TANYAO, player) + # print(player.ai.current_strategy) + + discard = player.discard_tile() + # hand was closed and we have won! + self.assertEqual(discard, Shanten.AGARI_STATE) + + meld = self._make_meld(Meld.CHI, self._string_to_136_array(pin='234')) + player.add_called_meld(meld) + + discard = player.discard_tile() + # but for already open hand we cant do tsumo + # because we don't have a yaku here + self.assertEqual(self._to_string([discard]), '1s') From b213c0686cefaa182ed02c00c5e6e810260b39f3 Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Sun, 19 Feb 2017 14:12:12 +0800 Subject: [PATCH 54/80] Don't activate yakuhai strategy if there is not enough tiles in the wall --- project/mahjong/ai/strategies/yakuhai.py | 11 +++++++++-- project/mahjong/ai/tests/tests_strategies.py | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/project/mahjong/ai/strategies/yakuhai.py b/project/mahjong/ai/strategies/yakuhai.py index 6b3550d2..80627128 100644 --- a/project/mahjong/ai/strategies/yakuhai.py +++ b/project/mahjong/ai/strategies/yakuhai.py @@ -16,8 +16,15 @@ def should_activate_strategy(self): return False tiles_34 = TilesConverter.to_34_array(self.player.tiles) - has_valued_pairs = any([tiles_34[x] >= 2 for x in self.player.ai.valued_honors]) - return has_valued_pairs + valued_pairs = [x for x in self.player.ai.valued_honors if tiles_34[x] >= 2] + + for pair in valued_pairs: + # we have valued pair in the hand and there is enough tiles + # in the wall + if tiles_34[pair] + self.player.table.revealed_tiles[pair] < 4: + return True + + return False def is_tile_suitable(self, tile): """ diff --git a/project/mahjong/ai/tests/tests_strategies.py b/project/mahjong/ai/tests/tests_strategies.py index a5fae525..170311b0 100644 --- a/project/mahjong/ai/tests/tests_strategies.py +++ b/project/mahjong/ai/tests/tests_strategies.py @@ -37,6 +37,22 @@ def test_should_activate_strategy(self): 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 = Player(0, 0, table) + 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.enemy_discard(self._string_to_136_tile(honors='4'), 3) + table.enemy_discard(self._string_to_136_tile(honors='4'), 3) + + # 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 = Player(0, 0, table) From 9bfb842988491042a634dc5b06d1b8c74927ac42 Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Sun, 19 Feb 2017 20:55:58 +0800 Subject: [PATCH 55/80] Integrate calling melds and tenhou --- project/bots_battle.py | 8 +- project/game/game_manager.py | 11 +-- project/mahjong/ai/strategies/main.py | 2 +- project/mahjong/player.py | 13 +++ project/tenhou/client.py | 125 ++++++++++++++++++-------- project/utils/logger.py | 5 ++ 6 files changed, 110 insertions(+), 54 deletions(-) diff --git a/project/bots_battle.py b/project/bots_battle.py index d95b870e..09f0b3ed 100644 --- a/project/bots_battle.py +++ b/project/bots_battle.py @@ -10,7 +10,7 @@ from game.game_manager import GameManager from mahjong.client import Client -TOTAL_HANCHANS = 1 +TOTAL_HANCHANS = 100 def main(): @@ -20,9 +20,9 @@ def main(): # let's load three bots with old logic # and one copy with new logic - # clients = [Client(use_previous_ai_version=True) for _ in range(0, 3)] - # clients += [Client(use_previous_ai_version=False)] - clients = [Client(use_previous_ai_version=False) for _ in range(0, 4)] + clients = [Client(use_previous_ai_version=True) for _ in range(0, 3)] + clients += [Client(use_previous_ai_version=False)] + # clients = [Client(use_previous_ai_version=False) for _ in range(0, 4)] manager = GameManager(clients) total_results = {} diff --git a/project/game/game_manager.py b/project/game/game_manager.py index b557e0e7..83f4376a 100644 --- a/project/game/game_manager.py +++ b/project/game/game_manager.py @@ -258,16 +258,7 @@ def play_round(self): self.players_with_open_hands.append(self.current_client_seat) logger.info('Called meld: {} by {}'.format(meld, current_client.player.name)) - hand_string = 'With hand: {} + {}'.format( - TilesConverter.to_one_line_string(current_client.player.closed_hand), - TilesConverter.to_one_line_string([tile]) - ) - if current_client.player.is_open_hand: - melds = [] - for item in current_client.player.melds: - melds.append('{}'.format(TilesConverter.to_one_line_string(item.tiles))) - hand_string += ' [{}]'.format(', '.join(melds)) - logger.info(hand_string) + logger.info('With hand: {}'.format(current_client.player.format_hand_for_print(tile))) # we need to notify each client about called meld for _client in self.clients: diff --git a/project/mahjong/ai/strategies/main.py b/project/mahjong/ai/strategies/main.py index ce570386..21f13472 100644 --- a/project/mahjong/ai/strategies/main.py +++ b/project/mahjong/ai/strategies/main.py @@ -87,7 +87,7 @@ def try_to_call_meld(self, tile, is_kamicha_discard): 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 enemy_seat: 1, 2, 3 + :param is_kamicha_discard: boolean :return: meld and tile to discard after called open set, and new shanten count """ if self.player.in_riichi: diff --git a/project/mahjong/player.py b/project/mahjong/player.py index 24b77c6e..02a798b7 100644 --- a/project/mahjong/player.py +++ b/project/mahjong/player.py @@ -5,6 +5,7 @@ import copy from mahjong.constants import EAST, SOUTH, WEST, NORTH +from mahjong.tile import TilesConverter from utils.settings_handler import settings from mahjong.ai.shanten import Shanten @@ -187,3 +188,15 @@ def meld_tiles(self): meld[1] //= 4 meld[2] //= 4 return melds + + 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))) + hand_string += ' [{}]'.format(', '.join(melds)) + return hand_string diff --git a/project/tenhou/client.py b/project/tenhou/client.py index 3acb8da3..7485f71d 100644 --- a/project/tenhou/client.py +++ b/project/tenhou/client.py @@ -31,7 +31,7 @@ def __init__(self, socket_object): self.socket = socket_object def authenticate(self): - self._send_message(''.format(quote(settings.USER_ID))) + self._send_message(''.format(quote(settings.USER_ID))) auth_message = self._read_message() auth_string = self.decoder.parse_auth_string(auth_message) @@ -40,7 +40,7 @@ def authenticate(self): auth_token = self.decoder.generate_auth_token(auth_string) - self._send_message(''.format(auth_token)) + self._send_message(''.format(auth_token)) self._send_message(self._pxr_tag()) # sometimes tenhou send an empty tag after authentication (in tournament mode) @@ -67,19 +67,19 @@ def start_game(self): if settings.LOBBY != '0': if settings.IS_TOURNAMENT: - logger.info('Go to the tournament lobby: {0}'.format(settings.LOBBY)) - self._send_message(''.format(settings.LOBBY)) + logger.info('Go to the tournament lobby: {}'.format(settings.LOBBY)) + self._send_message(''.format(settings.LOBBY)) sleep(2) self._send_message('') else: - logger.info('Go to the lobby: {0}'.format(settings.LOBBY)) - self._send_message(''.format(quote('/lobby {0}'.format(settings.LOBBY)))) + logger.info('Go to the lobby: {}'.format(settings.LOBBY)) + self._send_message(''.format(quote('/lobby {}'.format(settings.LOBBY)))) sleep(2) - game_type = '{0},{1}'.format(settings.LOBBY, settings.GAME_TYPE) + game_type = '{},{}'.format(settings.LOBBY, settings.GAME_TYPE) if not settings.IS_TOURNAMENT: - self._send_message(''.format(game_type)) + self._send_message(''.format(game_type)) logger.info('Looking for the game...') start_time = datetime.datetime.now() @@ -93,7 +93,7 @@ def start_game(self): if ''.format(game_type)) + self._send_message(''.format(game_type)) if '') @@ -102,7 +102,7 @@ def start_game(self): if '') else: # let's call riichi and after this discard tile if main_player.can_call_riichi(): - self._send_message(''.format(tile)) + self._send_message(''.format(tile)) sleep(2) main_player.in_riichi = True # tenhou format: - self._send_message(''.format(tile)) + self._send_message(''.format(tile)) - logger.info('Remaining tiles: {0}'.format(self.table.count_of_remaining_tiles)) + logger.info('Remaining tiles: {}'.format(self.table.count_of_remaining_tiles)) # new dora indicator after kan if '') - # t="7" - suggest to open kan - open_sets = ['t="1"', 't="2"', 't="3"', 't="4"', 't="5"', 't="7"'] - if any(i in message for i in open_sets): + # for now I'm not sure about what sets was suggested to call with this numbers + # will find it out later + not_allowed_open_sets = ['t="2"', 't="3"', 't="5"', 't="7"'] + if any(i in message for i in not_allowed_open_sets): sleep(1) self._send_message('') - # set call + # set was called if ''.format(tile_to_discard)) + self.player.discard_tile(tile_to_discard) - # other player upgraded pon to kan, and it is our winning tile - if meld.type == Meld.CHAKAN and 't="8"' in message: - # actually I don't know what exactly client response should be - # let's try usual ron response - self._send_message('') + self.player.tiles.append(meld_tile) + self.player.ai.previous_shanten = shanten + + self.table.add_called_meld(meld, meld.who) + + win_suggestions = ['t="8"', 't="9"', 't="13"'] + # we win by other player's discard + if any(i in message for i in win_suggestions): + sleep(1) + self._send_message('') # other players discards: ') - tile = self.decoder.parse_tile(message) if ''.format( + meld_type, + tiles[0], + tiles[1] + )) + else: + sleep(1) + self._send_message('') + if 'owari' in message: values = self.decoder.parse_final_scores_and_uma(message) self.table.set_players_scores(values['scores'], values['uma']) @@ -245,7 +292,7 @@ def start_game(self): if ' Date: Sun, 19 Feb 2017 22:42:09 +0800 Subject: [PATCH 56/80] Update python path --- bin/run.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bin/run.sh b/bin/run.sh index 28bf24e3..42287e1d 100644 --- a/bin/run.sh +++ b/bin/run.sh @@ -4,12 +4,12 @@ # and will search the run process # if there is no process, it will run it -# */5 * * * * bash /home/bot/app/bin/run.sh +# */5 * * * * bash /root/bot/bin/run.sh PID=`ps -eaf | grep project/main.py | grep -v grep | awk '{print $2}'` if [[ "" = "$PID" ]]; then - /home/bot/env/bin/python /home/bot/app/project/main.py + /root/bot/env/bin/python /root/bot/project/main.py else WORKED_SECONDS=`ps -p "$PID" -o etimes=` # if process run > 60 minutes, probably it hang and we need to kill it From 538962d814a749b43680cb32d4bb91396c3f9935 Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Mon, 20 Feb 2017 09:09:05 +0800 Subject: [PATCH 57/80] Don't use strategy specific choices for calling riichi --- project/mahjong/ai/main.py | 4 +++- project/mahjong/ai/tests/tests_strategies.py | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/project/mahjong/ai/main.py b/project/mahjong/ai/main.py index ae0df80c..8c187a01 100644 --- a/project/mahjong/ai/main.py +++ b/project/mahjong/ai/main.py @@ -67,8 +67,10 @@ def discard_tile(self): if not results: return self.player.last_draw + we_can_call_riichi = shanten == 0 and self.player.can_call_riichi() # current strategy can affect on our discard options - if self.current_strategy: + # and don't use strategy specific choices for calling riichi + if self.current_strategy and not we_can_call_riichi: results = self.current_strategy.determine_what_to_discard(self.player.closed_hand, results, shanten, diff --git a/project/mahjong/ai/tests/tests_strategies.py b/project/mahjong/ai/tests/tests_strategies.py index 170311b0..e7c0bb9f 100644 --- a/project/mahjong/ai/tests/tests_strategies.py +++ b/project/mahjong/ai/tests/tests_strategies.py @@ -253,6 +253,22 @@ def test_open_hand_and_discard_tiles_logic(self): # 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 = Player(0, 0, table) + player.scores = 25000 + table.count_of_remaining_tiles = 100 + + tiles = self._string_to_136_array(man='3335689', pin='456', honors='155') + player.init_hand(tiles) + + player.draw_tile(self._string_to_136_tile(man='4')) + 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') + class TanyaoStrategyTestCase(unittest.TestCase, TestMixin): From 65fd5164b1fc405718d5ce942a2362ebd042c647 Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Mon, 20 Feb 2017 09:15:50 +0800 Subject: [PATCH 58/80] Change python path --- bin/run.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bin/run.sh b/bin/run.sh index 42287e1d..b929b2bb 100644 --- a/bin/run.sh +++ b/bin/run.sh @@ -4,12 +4,12 @@ # and will search the run process # if there is no process, it will run it -# */5 * * * * bash /root/bot/bin/run.sh +# */5 * * * * bash /var/www/bot/bin/run.sh PID=`ps -eaf | grep project/main.py | grep -v grep | awk '{print $2}'` if [[ "" = "$PID" ]]; then - /root/bot/env/bin/python /root/bot/project/main.py + /var/www/bot/env/bin/python /var/www/bot/project/main.py else WORKED_SECONDS=`ps -p "$PID" -o etimes=` # if process run > 60 minutes, probably it hang and we need to kill it From 073c66e21c133840ff5ca5b4a89191faeec464d4 Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Mon, 20 Feb 2017 09:44:27 +0800 Subject: [PATCH 59/80] Add debug information --- project/tenhou/client.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/project/tenhou/client.py b/project/tenhou/client.py index 7485f71d..1de0fa02 100644 --- a/project/tenhou/client.py +++ b/project/tenhou/client.py @@ -26,6 +26,8 @@ class TenhouClient(Client): decoder = TenhouDecoder() + _count_of_empty_messages = 0 + def __init__(self, socket_object): super(TenhouClient, self).__init__() self.socket = socket_object @@ -90,7 +92,6 @@ def start_game(self): messages = self._get_multiple_messages() for message in messages: - if ''.format(game_type)) @@ -142,8 +143,13 @@ def start_game(self): messages = self._get_multiple_messages() - for message in messages: + if not messages: + self._count_of_empty_messages += 1 + else: + # we had set to zero counter + self._count_of_empty_messages = 0 + for message in messages: if ' Date: Mon, 20 Feb 2017 10:00:30 +0800 Subject: [PATCH 60/80] Add another win suggestion --- project/tenhou/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/tenhou/client.py b/project/tenhou/client.py index 1de0fa02..f558d76e 100644 --- a/project/tenhou/client.py +++ b/project/tenhou/client.py @@ -241,7 +241,7 @@ def start_game(self): self.table.add_called_meld(meld, meld.who) - win_suggestions = ['t="8"', 't="9"', 't="13"'] + win_suggestions = ['t="8"', 't="9"', 't="12"', 't="13"'] # we win by other player's discard if any(i in message for i in win_suggestions): sleep(1) From 2d0ba4ac4220fc9bc0b003da394e18a6c35fe339 Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Tue, 21 Feb 2017 16:33:16 +0800 Subject: [PATCH 61/80] Add basic logs compression support --- .gitignore | 1 + project/analytics/__init__.py | 1 + project/analytics/logs_compressor.py | 123 +++++++++++++++++++++++ project/analytics/tags.py | 144 +++++++++++++++++++++++++++ project/analyze.py | 10 ++ 5 files changed, 279 insertions(+) create mode 100644 project/analytics/__init__.py create mode 100644 project/analytics/logs_compressor.py create mode 100644 project/analytics/tags.py create mode 100644 project/analyze.py diff --git a/.gitignore b/.gitignore index 6f2638d6..8772ac6b 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ temp *.log project/game/data/* +project/analytics/data/* # temporary files experiments \ No newline at end of file diff --git a/project/analytics/__init__.py b/project/analytics/__init__.py new file mode 100644 index 00000000..40a96afc --- /dev/null +++ b/project/analytics/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/project/analytics/logs_compressor.py b/project/analytics/logs_compressor.py new file mode 100644 index 00000000..da1f9be8 --- /dev/null +++ b/project/analytics/logs_compressor.py @@ -0,0 +1,123 @@ +# -*- coding: utf-8 -*- +import os + +import re +from bs4 import BeautifulSoup + +from analytics.tags import * + +data_directory = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'data') + + +class TenhouLogCompressor(object): + log_name = '' + tags = [] + + def compress(self, log_name): + content = self._get_log_content(log_name) + + soup = BeautifulSoup(content, 'html.parser') + elements = soup.find_all() + + skip_tags = ['mjloggm', 'shuffle', 'un', 'taikyoku', 'bye', 'ryuukyoku'] + for tag in elements: + if tag.name in skip_tags: + continue + + match_discard = re.match(r"^[defgtuvw]+\d.*", tag.name) + if match_discard: + self.tags.append(self.parse_draw_and_discard(tag)) + + if tag.name == 'go': + self.tags.append(self.parse_game_type(tag)) + + if tag.name == 'n': + self.tags.append(self.parse_meld(tag)) + + if tag.name == 'reach': + self.tags.append(self.parse_riichi(tag)) + + if tag.name == 'dora': + self.tags.append(self.parse_dora(tag)) + + if tag.name == 'init': + self.tags.append(self.parse_init_round(tag)) + + if tag.name == 'agari': + self.tags.append(self.parse_agari_round(tag)) + + self._save_results() + + def parse_draw_and_discard(self, tag): + tile = re.findall(r'\d+', tag.name)[0] + tag_name = re.findall(r'^[defgtuvw]+', tag.name)[0] + return DiscardAndDrawTag(tag_name, tile) + + def parse_game_type(self, tag): + return GameTypeTag(tag.attrs['type']) + + def parse_meld(self, tag): + return MeldTag(tag.attrs['who'], tag.attrs['m']) + + def parse_riichi(self, tag): + return RiichiTag(tag.attrs['who'], tag.attrs['step']) + + def parse_dora(self, tag): + return DoraTag(tag.attrs['hai']) + + def parse_init_round(self, tag): + seed = tag.attrs['seed'].split(',') + count_of_honba_sticks = seed[1] + count_of_riichi_sticks = seed[2] + dora_indicator = seed[5] + + return InitTag( + tag.attrs['hai0'], + tag.attrs['hai1'], + tag.attrs['hai2'], + tag.attrs['hai3'], + tag.attrs['oya'], + count_of_honba_sticks, + count_of_riichi_sticks, + dora_indicator, + ) + + def parse_agari_round(self, tag): + temp = tag.attrs['ten'].split(',') + fu = temp[0] + win_scores = temp[1] + + yaku_list = '' + if 'yaku' in tag.attrs: + yaku_list = tag.attrs['yaku'] + + if 'yakuman' in tag.attrs: + yakuman_list = tag.attrs['yakuman'].split(',') + yaku_list = ','.join(['{},13'.format(x) for x in yakuman_list]) + + ura_dora = 'dorahaiura' in tag.attrs and tag.attrs['dorahaiura'] or '' + melds = 'm' in tag.attrs and tag.attrs['m'] or '' + + return AgariTag( + tag.attrs['who'], + tag.attrs['fromwho'], + ura_dora, + tag.attrs['hai'], + melds, + win_scores, + tag.attrs['machi'], + yaku_list, + fu + ) + + def _save_results(self): + log_name = os.path.join(data_directory, 'results', self.log_name.split('/')[-1]) + with open(log_name, 'w') as f: + f.write('{}'.format(TAGS_DELIMITER).join([str(tag) for tag in self.tags])) + + def _get_log_content(self, log_name): + self.log_name = log_name + + log_name = os.path.join(data_directory, log_name) + with open(log_name, 'r') as f: + return f.read() diff --git a/project/analytics/tags.py b/project/analytics/tags.py new file mode 100644 index 00000000..7ad89d67 --- /dev/null +++ b/project/analytics/tags.py @@ -0,0 +1,144 @@ +# -*- coding: utf-8 -*- + +# d e f g t u v w +GAME_TYPE = 'z' +MELD_TAG = 'm' +RIICHI_TAG = 'r' +DORA_TAG = 'a' +INIT_TAG = 'i' +AGARI_TAG = 'o' + +DELIMITER = ';' +TAGS_DELIMITER = '\n' + + +class BaseTag(object): + tag_name = '' + + def _values(self): + raise NotImplemented() + + def __str__(self): + return '{}'.format(DELIMITER).join([str(x) for x in self._values()]) + + +class DiscardAndDrawTag(BaseTag): + discard = 0 + + # d e f g t u v w tag names + def __init__(self, tag_name, discard): + self.tag_name = tag_name + self.discard = discard + + def _values(self): + return [self.tag_name, self.discard] + + +class GameTypeTag(BaseTag): + tag_name = GAME_TYPE + + game_type = 0 + + def __init__(self, game_type): + self.game_type = game_type + + def _values(self): + return [self.tag_name, self.game_type] + + +class MeldTag(BaseTag): + tag_name = MELD_TAG + + meld_string = '' + who = 0 + + def __init__(self, who, meld): + self.who = who + self.meld_string = meld + + def _values(self): + return [self.tag_name, self.who, self.meld_string] + + +class RiichiTag(BaseTag): + tag_name = RIICHI_TAG + + who = 0 + step = 0 + + def __init__(self, who, step): + self.who = who + self.step = step + + def _values(self): + return [self.tag_name, self.who, self.step] + + +class DoraTag(BaseTag): + tag_name = DORA_TAG + + tile = 0 + + def __init__(self, tile): + self.tile = tile + + def _values(self): + return [self.tag_name, self.tile] + + +class InitTag(BaseTag): + tag_name = INIT_TAG + + first_hand = '' + second_hand = '' + third_hand = '' + fourth_hand = '' + dealer = '' + count_of_honba_sticks = '' + count_of_riichi_sticks = '' + dora_indicator = '' + + def __init__(self, first_hand, second_hand, third_hand, fourth_hand, dealer, count_of_honba_sticks, + count_of_riichi_sticks, dora_indicator): + self.first_hand = first_hand + self.second_hand = second_hand + self.third_hand = third_hand + self.fourth_hand = fourth_hand + self.dealer = dealer + self.count_of_honba_sticks = count_of_honba_sticks + self.count_of_riichi_sticks = count_of_riichi_sticks + self.dora_indicator = dora_indicator + + def _values(self): + return [self.tag_name, self.first_hand, self.second_hand, self.third_hand, + self.fourth_hand, self.dealer, self.count_of_honba_sticks, self.count_of_riichi_sticks, + self.dora_indicator] + + +class AgariTag(BaseTag): + tag_name = AGARI_TAG + + who = 0 + from_who = 0 + ura_dora = '' + closed_hand = '' + open_melds = '' + win_tile = '' + win_scores = '' + yaku_list = '' + fu = '' + + def __init__(self, who, from_who, ura_dora, closed_hand, open_melds, win_scores, win_tile, yaku_list, fu): + self.who = who + self.from_who = from_who + self.ura_dora = ura_dora + self.closed_hand = closed_hand + self.open_melds = open_melds + self.win_scores = win_scores + self.win_tile = win_tile + self.yaku_list = yaku_list + self.fu = fu + + def _values(self): + return [self.tag_name, self.who, self.from_who, self.ura_dora, self.closed_hand, + self.open_melds, self.win_scores, self.win_tile, self.yaku_list, self.fu] diff --git a/project/analyze.py b/project/analyze.py new file mode 100644 index 00000000..29e17405 --- /dev/null +++ b/project/analyze.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +from analytics.logs_compressor import TenhouLogCompressor + + +def main(): + compressor = TenhouLogCompressor() + compressor.compress('samples/test.log') + +if __name__ == '__main__': + main() From 8895f1e7fedcfdb4fc7977a9c873a8dd8a929e80 Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Tue, 21 Feb 2017 16:37:29 +0800 Subject: [PATCH 62/80] Handle issues with tenhou protocol and empty messages --- project/tenhou/client.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/project/tenhou/client.py b/project/tenhou/client.py index f558d76e..664a576b 100644 --- a/project/tenhou/client.py +++ b/project/tenhou/client.py @@ -298,8 +298,10 @@ def start_game(self): if ' 10: + logger.error('Tenhou send empty messages to us. Probably we did something wrong with protocol') + self.end_game(False) + return logger.info('Final results: {}'.format(self.table.get_players_sorted_by_scores())) @@ -314,7 +316,7 @@ def start_game(self): result = self.statistics.send_statistics() logger.info('Statistics sent: {}'.format(result)) - def end_game(self): + def end_game(self, success=True): self.game_is_continue = False self._send_message('') @@ -324,7 +326,10 @@ def end_game(self): self.socket.shutdown(socket.SHUT_RDWR) self.socket.close() - logger.info('End of the game') + if success: + logger.info('End of the game') + else: + logger.error('Game was ended without of success') def _send_message(self, message): # tenhou requires an empty byte in the end of each sending message From 888122e5fb1a191508bd2ed83d1124a4ad35412c Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Tue, 21 Feb 2017 16:52:49 +0800 Subject: [PATCH 63/80] Fix an issue with agari hand without yaku --- project/mahjong/ai/main.py | 29 +++++++++++++------- project/mahjong/ai/tests/tests_strategies.py | 16 +++++++++++ 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/project/mahjong/ai/main.py b/project/mahjong/ai/main.py index 8c187a01..61911ed6 100644 --- a/project/mahjong/ai/main.py +++ b/project/mahjong/ai/main.py @@ -11,7 +11,7 @@ from mahjong.ai.strategies.tanyao import TanyaoStrategy from mahjong.ai.strategies.yakuhai import YakuhaiStrategy from mahjong.constants import HAKU, CHUN, HATSU -from mahjong.hand import HandDivider +from mahjong.hand import HandDivider, FinishedHand from mahjong.tile import TilesConverter logger = logging.getLogger('ai') @@ -52,15 +52,24 @@ def discard_tile(self): # we are win! if shanten == Shanten.AGARI_STATE: - # we draw a tile that complete our four sets and pair - # but we can't win with it, because we don't have a yaku - # so let's discard it - if (self.current_strategy and - not self.current_strategy.is_tile_suitable(self.player.last_draw) and - self.player.is_open_hand): - return self.player.last_draw - else: - return Shanten.AGARI_STATE + # special conditions for open hands + if self.player.is_open_hand: + # sometimes we can draw a tile that gave us agari, + # but didn't give us a yaku + # in that case we had to do last draw discard + finished_hand = FinishedHand() + result = finished_hand.estimate_hand_value(tiles=self.player.tiles, + win_tile=self.player.last_draw, + is_tsumo=True, + is_riichi=False, + is_dealer=self.player.is_dealer, + open_sets=self.player.meld_tiles, + player_wind=self.player.player_wind, + round_wind=self.player.table.round_wind) + if result['error'] is not None: + return self.player.last_draw + + return Shanten.AGARI_STATE # we are in agari state, but we can't win because we don't have yaku # in that case let's do tsumogiri diff --git a/project/mahjong/ai/tests/tests_strategies.py b/project/mahjong/ai/tests/tests_strategies.py index e7c0bb9f..ef72c1e1 100644 --- a/project/mahjong/ai/tests/tests_strategies.py +++ b/project/mahjong/ai/tests/tests_strategies.py @@ -180,6 +180,22 @@ def test_call_yakuhai_pair_and_special_conditions(self): meld, tile_to_discard, shanten = player.try_to_call_meld(tile, True) self.assertEqual(meld, None) + def test_tempai_without_yaku(self): + table = Table() + player = Player(0, 0, table) + + # 456m12355p22z + 5p [678s] + 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, self._string_to_136_array(sou='678')) + player.add_called_meld(meld) + + discard = player.discard_tile() + self.assertEqual(self._to_string([discard]), '5p') + class HonitsuStrategyTestCase(unittest.TestCase, TestMixin): From 2e2928b4742d3ab585dae34c658d530644ea5797 Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Tue, 21 Feb 2017 17:39:13 +0800 Subject: [PATCH 64/80] Fix an issue with wrong chi calling --- project/mahjong/ai/strategies/main.py | 3 ++- project/mahjong/ai/tests/tests_ai.py | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/project/mahjong/ai/strategies/main.py b/project/mahjong/ai/strategies/main.py index 21f13472..e66f63b6 100644 --- a/project/mahjong/ai/strategies/main.py +++ b/project/mahjong/ai/strategies/main.py @@ -147,7 +147,8 @@ def try_to_call_meld(self, tile, is_kamicha_discard): # we can check only tiles around +-2 discarded tile first_limit = discarded_tile - 2 if first_limit < first_index: - first_limit = 0 + first_limit = first_index + second_limit = discarded_tile + 2 if second_limit > second_index: second_limit = second_index diff --git a/project/mahjong/ai/tests/tests_ai.py b/project/mahjong/ai/tests/tests_ai.py index d29d9812..21e57203 100644 --- a/project/mahjong/ai/tests/tests_ai.py +++ b/project/mahjong/ai/tests/tests_ai.py @@ -185,3 +185,19 @@ def test_remaining_tiles_and_dora_indicators(self): results, shanten = player.ai.calculate_outs(tiles, tiles) self.assertEqual(results[0].tiles_count, 7) + + def test_using_tiles_of_different_suit_for_chi(self): + """ + It was a bug related to it, when bot wanted to call 9p12s chi :( + """ + table = Table() + player = Player(0, 0, table) + + # 16m2679p1348s111z + tiles = [0, 21, 41, 56, 61, 70, 74, 80, 84, 102, 108, 110, 111] + player.init_hand(tiles) + + # 2s + tile = 77 + meld, tile_to_discard, shanten = player.try_to_call_meld(tile, True) + self.assertIsNotNone(meld) From 75f8381fe220eccb68aabf13f44bc4bd080f53a3 Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Tue, 21 Feb 2017 18:03:30 +0800 Subject: [PATCH 65/80] Fix a bug with discarding 0 tile --- project/mahjong/player.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/project/mahjong/player.py b/project/mahjong/player.py index 02a798b7..e6c18867 100644 --- a/project/mahjong/player.py +++ b/project/mahjong/player.py @@ -125,7 +125,11 @@ def discard_tile(self, tile=None): :param tile: 136 tiles format :return: """ - tile_to_discard = tile or self.ai.discard_tile() + # we can't use if tile, because of 0 tile + if tile is not None: + tile_to_discard = tile + else: + tile_to_discard = self.ai.discard_tile() if tile_to_discard != Shanten.AGARI_STATE: self.add_discarded_tile(tile_to_discard) From f5031bb9a2002349ef9eede8207c7828453ac874 Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Fri, 24 Feb 2017 10:20:43 +0800 Subject: [PATCH 66/80] Update game rules decoding --- project/settings.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/project/settings.py b/project/settings.py index e0378e0d..fb144d89 100644 --- a/project/settings.py +++ b/project/settings.py @@ -19,7 +19,7 @@ """ 0 - 1 - online, 0 - bots - 1 - aka forbidden + 1 - has aka 2 - kuitan forbidden 3 - hanchan 4 - 3man @@ -30,8 +30,9 @@ Combine them as: 76543210 - 00001001 = 9 = hanchan ari-ari - 00000001 = 1 = tonpu-sen ari-ari + 00001001 = 9 = kyu, hanchan ari-ari + 00000001 = 1 = kyu, tonpusen ari-ari + 10001001 = 137 = dan, hanchan ari-ari """ GAME_TYPE = '1' From 8ab1d23a5439a0fc68ce72f35f4b9d59453fcd36 Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Fri, 24 Feb 2017 21:07:46 +0800 Subject: [PATCH 67/80] Add a some code to process phoenix logs --- project/analytics/cases/__init__.py | 1 + project/analytics/cases/count_of_games.py | 47 ++++++ project/analytics/cases/honitsu_hands.py | 33 ++++ project/analytics/cases/main.py | 54 ++++++ project/analytics/download_game_ids.py | 187 +++++++++++++++++++++ project/analytics/download_logs_content.py | 115 +++++++++++++ project/analytics/logs_compressor.py | 21 ++- project/analytics/tags.py | 56 +++++- project/analyze.py | 10 -- project/process.py | 10 ++ 10 files changed, 520 insertions(+), 14 deletions(-) create mode 100644 project/analytics/cases/__init__.py create mode 100644 project/analytics/cases/count_of_games.py create mode 100644 project/analytics/cases/honitsu_hands.py create mode 100644 project/analytics/cases/main.py create mode 100644 project/analytics/download_game_ids.py create mode 100644 project/analytics/download_logs_content.py delete mode 100644 project/analyze.py create mode 100644 project/process.py diff --git a/project/analytics/cases/__init__.py b/project/analytics/cases/__init__.py new file mode 100644 index 00000000..40a96afc --- /dev/null +++ b/project/analytics/cases/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/project/analytics/cases/count_of_games.py b/project/analytics/cases/count_of_games.py new file mode 100644 index 00000000..b3493b4f --- /dev/null +++ b/project/analytics/cases/count_of_games.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +import sqlite3 +from datetime import datetime +from analytics.cases.main import ProcessDataCase + + +class CountOfGames(ProcessDataCase): + + def process(self): + years = range(2009, datetime.now().year + 1) + + connection = sqlite3.connect(self.db_file) + + with connection: + cursor = connection.cursor() + + tonpusen_games_array = [] + hanchan_games_array = [] + + for year in years: + total_games_sql = 'SELECT count(*) from logs where year = {};'.format(year) + hanchan_games_sql = 'SELECT count(*) from logs where year = {} and is_tonpusen = 0;'.format(year) + tonpusen_games_sql = 'SELECT count(*) from logs where year = {} and is_tonpusen = 1;'.format(year) + + cursor.execute(total_games_sql) + data = cursor.fetchone() + total_games = data and data[0] or 0 + + cursor.execute(hanchan_games_sql) + data = cursor.fetchone() + hanchan_games = data and data[0] or 0 + + cursor.execute(tonpusen_games_sql) + data = cursor.fetchone() + tonpusen_games = data and data[0] or 0 + + hanchan_percentage = total_games and (hanchan_games / total_games) * 100 or 0 + tonpusen_percentage = total_games and (tonpusen_games / total_games) * 100 or 0 + + print(year) + print('Total games: {}'.format(total_games)) + print('Hanchan games: {}, {:.2f}%'.format(hanchan_games, hanchan_percentage)) + print('Tonpusen games: {}, {:.2f}%'.format(tonpusen_games, tonpusen_percentage)) + print() + + tonpusen_games_array.append(tonpusen_percentage) + hanchan_games_array.append(hanchan_percentage) diff --git a/project/analytics/cases/honitsu_hands.py b/project/analytics/cases/honitsu_hands.py new file mode 100644 index 00000000..06fda622 --- /dev/null +++ b/project/analytics/cases/honitsu_hands.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +from analytics.cases.main import ProcessDataCase +from analytics.tags import AGARI_TAG +from mahjong.tile import TilesConverter + + +class HonitsuHands(ProcessDataCase): + HONITSU_ID = 34 + + def process(self): + self.load_all_records() + filtered = self.filter_hands() + for item in filtered: + meld_tiles = [] + if item.parsed_melds: + for meld in item.parsed_melds: + tiles = meld.tiles + if meld.type == meld.KAN or meld.type == meld.CHAKAN: + tiles = tiles[:3] + meld_tiles.extend(tiles) + + closed_hand_tiles = [int(x) for x in item.closed_hand.split(',')] + tiles = closed_hand_tiles + meld_tiles + print(TilesConverter.to_one_line_string(tiles)) + + def filter_hands(self): + filtered = [] + for hanchan in self.hanchans: + for tag in hanchan.tags: + if tag.tag_name == AGARI_TAG: + if self.HONITSU_ID in tag.parsed_yaku: + filtered.append(tag) + return filtered diff --git a/project/analytics/cases/main.py b/project/analytics/cases/main.py new file mode 100644 index 00000000..e7a4042f --- /dev/null +++ b/project/analytics/cases/main.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +import os + +import sqlite3 + +from ..tags import TAGS_DELIMITER, decode_tag + + +class Hanchan(object): + log_id = None + is_tonpusen = False + year = None + content = None + tags = None + + def __init__(self, log_id, is_tonpusen, year, content): + self.log_id = log_id + self.is_tonpusen = is_tonpusen, + self.year = year, + self.content = content + + self._decode_tags() + + def _decode_tags(self): + self.tags = [] + temp = self.content.split(TAGS_DELIMITER) + for item in temp: + self.tags.append(decode_tag(item)) + + +class ProcessDataCase(object): + db_file = '' + hanchans = [] + + def __init__(self): + current_directory = os.path.dirname(os.path.realpath(__file__)) + self.db_file = os.path.join(current_directory, '..', 'data.db') + + self.hanchans = [] + + def process(self): + raise NotImplemented() + + def load_all_records(self): + connection = sqlite3.connect(self.db_file) + + with connection: + cursor = connection.cursor() + + cursor.execute('SELECT log_id, is_tonpusen, year, log FROM logs WHERE is_processed = 1 and was_error = 0;') + data = cursor.fetchall() + + for item in data: + self.hanchans.append(Hanchan(item[0], item[1] == 1, item[2], item[3])) diff --git a/project/analytics/download_game_ids.py b/project/analytics/download_game_ids.py new file mode 100644 index 00000000..b9ff7731 --- /dev/null +++ b/project/analytics/download_game_ids.py @@ -0,0 +1,187 @@ +# -*- coding: utf-8 -*- +""" +Script to download latest phoenix games and store their ids in the database +We can run it once a day or so, to get new data +""" +import shutil +from datetime import datetime +import calendar +import gzip +import os + +import sqlite3 +from distutils.dir_util import mkpath + +import requests + +current_directory = os.path.dirname(os.path.realpath(__file__)) +logs_directory = os.path.join(current_directory, 'data', 'logs') +db_file = os.path.join(current_directory, 'data.db') + +if not os.path.exists(logs_directory): + mkpath(logs_directory) + + +def main(): + # for the initial set up + # set_up_database() + + download_game_ids() + + results = process_local_files() + if results: + add_logs_to_database(results) + + +def process_local_files(): + """ + Function to process scc*.html files that can be obtained + from the annual archives with logs or from latest phoenix games + """ + print('Preparing the list of games') + + results = [] + for file_name in os.listdir(logs_directory): + if 'scc' not in file_name: + continue + + # after 2013 tenhou produced compressed logs + if '.gz' in file_name: + with gzip.open(os.path.join(logs_directory, file_name), 'r') as f: + for line in f: + line = str(line, 'utf-8') + _process_log_line(line, results) + else: + with open(os.path.join(logs_directory, file_name)) as f: + for line in f: + _process_log_line(line, results) + + print('Found {} games'.format(len(results))) + + shutil.rmtree(logs_directory) + + return results + + +def download_game_ids(): + """ + Download latest phoenix games from tenhou + """ + connection = sqlite3.connect(db_file) + + last_name = '' + with connection: + cursor = connection.cursor() + cursor.execute('SELECT * FROM last_downloads ORDER BY date DESC LIMIT 1;') + data = cursor.fetchone() + if data: + last_name = data[0] + + download_url = 'http://tenhou.net/sc/raw/dat/' + url = 'http://tenhou.net/sc/raw/list.cgi' + + response = requests.get(url) + response = response.text.replace('list(', '').replace(');', '') + response = response.split(',\r\n') + + records_was_added = False + for archive_name in response: + if 'scc' in archive_name: + archive_name = archive_name.split("',")[0].replace("{file:'", '') + + file_name = archive_name + if '/' in file_name: + file_name = file_name.split('/')[1] + + if file_name > last_name: + last_name = file_name + records_was_added = True + + archive_path = os.path.join(logs_directory, file_name) + if not os.path.exists(archive_path): + print('Downloading... {}'.format(archive_name)) + + url = '{}{}'.format(download_url, archive_name) + page = requests.get(url) + with open(archive_path, 'wb') as f: + f.write(page.content) + + if records_was_added: + unix_time = calendar.timegm(datetime.utcnow().utctimetuple()) + with connection: + cursor = connection.cursor() + cursor.execute('INSERT INTO last_downloads VALUES ("{}", {});'.format(last_name, unix_time)) + + +def _process_log_line(line, results): + line = line.strip() + # sometimes there is empty lines in the file + if not line: + return None + + result = line.split('|') + game_type = result[2].strip() + + # we don't need hirosima replays for now + if game_type.startswith('三'): + return None + + # example: 牌譜 + game_id = result[3].split('log=')[1].split('"')[0] + + # example: 四鳳東喰赤 + is_tonpusen = game_type[2] == '東' + year = int(game_id[:4]) + + results.append([game_id, year, is_tonpusen]) + + +def set_up_database(): + """ + Init logs table and add basic indices + :return: + """ + if os.path.exists(db_file): + print('Remove old database') + os.remove(db_file) + + connection = sqlite3.connect(db_file) + + print('Set up new database') + with connection: + cursor = connection.cursor() + cursor.execute(""" + CREATE TABLE logs(log_id text primary key, + year int, + is_tonpusen int, + is_processed int, + was_error int, + log text); + """) + cursor.execute("CREATE INDEX is_tonpusen_index ON logs (is_tonpusen);") + cursor.execute("CREATE INDEX is_processed_index ON logs (is_processed);") + cursor.execute("CREATE INDEX was_error_index ON logs (was_error);") + + cursor.execute(""" + CREATE TABLE last_downloads(name text, + date int); + """) + + +def add_logs_to_database(results): + """ + Store logs to the sqllite3 database + """ + print('Inserting new values to the database') + connection = sqlite3.connect(db_file) + with connection: + cursor = connection.cursor() + + for item in results: + cursor.execute('INSERT INTO logs VALUES ("{}", {}, {}, 0, 0, "");'.format(item[0], + item[1], + item[2] and 1 or 0)) + + +if __name__ == '__main__': + main() diff --git a/project/analytics/download_logs_content.py b/project/analytics/download_logs_content.py new file mode 100644 index 00000000..1ecd8cc6 --- /dev/null +++ b/project/analytics/download_logs_content.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +""" +Script will load log ids from the database and will download log content. +Log content will be stores in the DB (compressed version) and in the file. +""" +import os +import sqlite3 +from distutils.dir_util import mkpath + +import requests + +# need to find out better way to do relative imports +# noinspection PyUnresolvedReferences +from logs_compressor import TenhouLogCompressorNoFile + +current_directory = os.path.dirname(os.path.realpath(__file__)) +data_directory = os.path.join(current_directory, 'data') +db_file = os.path.join(current_directory, 'data.db') + + +def main(): + should_continue = True + while should_continue: + try: + limit = 50 + print('Load {} records'.format(limit)) + results = load_not_processed_logs(limit) + if not results: + should_continue = False + + for log_id in results: + print('Process {}'.format(log_id)) + download_log_content(log_id) + except KeyboardInterrupt: + should_continue = False + + +def download_log_content(log_id): + """ + We will download log content and will store it in the file, + also we will store compressed version in the database + """ + url = 'http://e.mjv.jp/0/log/?{0}'.format(log_id) + + log_folder = prepare_log_folder(log_id) + + content = '' + was_error = False + try: + response = requests.get(url) + content = response.text + if 'mjlog' not in content: + was_error = True + except Exception as e: + was_error = True + + connection = sqlite3.connect(db_file) + + with connection: + cursor = connection.cursor() + + # store original content to the file + # and compressed version to the database + if not was_error: + original_content = content + log_path = os.path.join(log_folder, log_id + '.mjlog') + with open(log_path, 'w') as f: + f.write(original_content) + + try: + compressor = TenhouLogCompressorNoFile(original_content) + content = compressor.compress(log_id) + except Exception as e: + os.remove(log_path) + was_error = True + content = '' + + sql = 'UPDATE logs SET is_processed = {}, was_error = {}, log = "{}" WHERE log_id = "{}";'.format( + 1, + was_error and 1 or 0, + content, + log_id + ) + cursor.execute(sql) + + +def load_not_processed_logs(limit): + connection = sqlite3.connect(db_file) + + with connection: + cursor = connection.cursor() + cursor.execute('SELECT log_id FROM logs where is_processed = 0 and was_error = 0 LIMIT {};'.format(limit)) + data = cursor.fetchall() + results = [x[0] for x in data] + + return results + + +def prepare_log_folder(log_id): + """ + To not store million files in the one folder. We will separate them + based on log has. + This log 2017022109gm-00a9-0000-897ed93b will be converted to the + /8/9/7/e/ folder + """ + temp = log_id.split('-') + game_hash = list(temp[-1][:4]) + + path = os.path.join(data_directory, *game_hash) + mkpath(path) + + return path + +if __name__ == '__main__': + main() diff --git a/project/analytics/logs_compressor.py b/project/analytics/logs_compressor.py index da1f9be8..f4de3b4b 100644 --- a/project/analytics/logs_compressor.py +++ b/project/analytics/logs_compressor.py @@ -4,7 +4,9 @@ import re from bs4 import BeautifulSoup -from analytics.tags import * +# need to find out better way to do relative imports +# noinspection PyUnresolvedReferences +from tags import GameTypeTag, DiscardAndDrawTag, AgariTag, DoraTag, MeldTag, InitTag, RiichiTag, TAGS_DELIMITER data_directory = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'data') @@ -46,7 +48,7 @@ def compress(self, log_name): if tag.name == 'agari': self.tags.append(self.parse_agari_round(tag)) - self._save_results() + return self._save_results() def parse_draw_and_discard(self, tag): tile = re.findall(r'\d+', tag.name)[0] @@ -114,6 +116,7 @@ def _save_results(self): log_name = os.path.join(data_directory, 'results', self.log_name.split('/')[-1]) with open(log_name, 'w') as f: f.write('{}'.format(TAGS_DELIMITER).join([str(tag) for tag in self.tags])) + return True def _get_log_content(self, log_name): self.log_name = log_name @@ -121,3 +124,17 @@ def _get_log_content(self, log_name): log_name = os.path.join(data_directory, log_name) with open(log_name, 'r') as f: return f.read() + + +class TenhouLogCompressorNoFile(TenhouLogCompressor): + content = '' + + def __init__(self, content): + self.content = content + self.tags = [] + + def _get_log_content(self, log_name): + return self.content + + def _save_results(self): + return '{}'.format(TAGS_DELIMITER).join([str(tag) for tag in self.tags]) diff --git a/project/analytics/tags.py b/project/analytics/tags.py index 7ad89d67..2fd72c23 100644 --- a/project/analytics/tags.py +++ b/project/analytics/tags.py @@ -1,6 +1,14 @@ # -*- coding: utf-8 -*- +# ================== +# dirty fix of siblings imports +from sys import path +from os.path import dirname as directory + +path.append(directory(path[0])) +__package__ = "tenhou" +# ===================== +from tenhou.decoder import TenhouDecoder -# d e f g t u v w GAME_TYPE = 'z' MELD_TAG = 'm' RIICHI_TAG = 'r' @@ -9,7 +17,33 @@ AGARI_TAG = 'o' DELIMITER = ';' -TAGS_DELIMITER = '\n' +TAGS_DELIMITER = '&' + +decoder = TenhouDecoder() + + +def decode_tag(raw_tag): + values = raw_tag.split(DELIMITER) + + if values[0] == GAME_TYPE: + return GameTypeTag(*values[1:]) + + if values[0] == MELD_TAG: + return MeldTag(*values[1:]) + + if values[0] == RIICHI_TAG: + return RiichiTag(*values[1:]) + + if values[0] == DORA_TAG: + return DoraTag(*values[1:]) + + if values[0] == INIT_TAG: + return InitTag(*values[1:]) + + if values[0] == AGARI_TAG: + return AgariTag(*values[1:]) + + return DiscardAndDrawTag(*values) class BaseTag(object): @@ -128,6 +162,9 @@ class AgariTag(BaseTag): yaku_list = '' fu = '' + parsed_yaku = None + parsed_melds = None + def __init__(self, who, from_who, ura_dora, closed_hand, open_melds, win_scores, win_tile, yaku_list, fu): self.who = who self.from_who = from_who @@ -139,6 +176,21 @@ def __init__(self, who, from_who, ura_dora, closed_hand, open_melds, win_scores, self.yaku_list = yaku_list self.fu = fu + self._parse_yaku() + self._parse_melds() + def _values(self): return [self.tag_name, self.who, self.from_who, self.ura_dora, self.closed_hand, self.open_melds, self.win_scores, self.win_tile, self.yaku_list, self.fu] + + def _parse_yaku(self): + data = self.yaku_list.split(',') + self.parsed_yaku = [int(x) for x in data[::2]] + + def _parse_melds(self): + self.parsed_melds = [] + if self.open_melds: + open_melds = self.open_melds.split(',') + for meld in open_melds: + message = ''.format(self.who, meld) + self.parsed_melds.append(decoder.parse_meld(message)) diff --git a/project/analyze.py b/project/analyze.py deleted file mode 100644 index 29e17405..00000000 --- a/project/analyze.py +++ /dev/null @@ -1,10 +0,0 @@ -# -*- coding: utf-8 -*- -from analytics.logs_compressor import TenhouLogCompressor - - -def main(): - compressor = TenhouLogCompressor() - compressor.compress('samples/test.log') - -if __name__ == '__main__': - main() diff --git a/project/process.py b/project/process.py new file mode 100644 index 00000000..e89a1aef --- /dev/null +++ b/project/process.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +from analytics.cases.honitsu_hands import HonitsuHands + + +def main(): + analyzer = HonitsuHands() + analyzer.process() + +if __name__ == '__main__': + main() From 1c7ab5199364a591510be2ac92537caaeade0bca Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Fri, 24 Feb 2017 21:29:32 +0800 Subject: [PATCH 68/80] Add debug information --- project/analytics/debug.py | 26 ++++++++++++++++++++++ project/analytics/download_logs_content.py | 2 ++ 2 files changed, 28 insertions(+) create mode 100644 project/analytics/debug.py diff --git a/project/analytics/debug.py b/project/analytics/debug.py new file mode 100644 index 00000000..25ca443f --- /dev/null +++ b/project/analytics/debug.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +import os + +import sqlite3 + +current_directory = os.path.dirname(os.path.realpath(__file__)) +db_file = os.path.join(current_directory, 'data.db') + + +def main(): + connection = sqlite3.connect(db_file) + + with connection: + cursor = connection.cursor() + + cursor.execute('SELECT COUNT(*) from logs where is_processed = 1;') + processed = cursor.fetchone()[0] + + cursor.execute('SELECT COUNT(*) from logs where was_error = 1;') + with_errors = cursor.fetchone()[0] + + print('Processed: {}'.format(processed)) + print('With errors: {}'.format(with_errors)) + +if __name__ == '__main__': + main() diff --git a/project/analytics/download_logs_content.py b/project/analytics/download_logs_content.py index 1ecd8cc6..aca1e67e 100644 --- a/project/analytics/download_logs_content.py +++ b/project/analytics/download_logs_content.py @@ -83,6 +83,8 @@ def download_log_content(log_id): ) cursor.execute(sql) + print('Was errors: {}'.format(was_error)) + def load_not_processed_logs(limit): connection = sqlite3.connect(db_file) From 446c83b996d57f43cb8b353cf9da58fbb39f3d44 Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Sun, 26 Feb 2017 10:54:55 +0800 Subject: [PATCH 69/80] Update the way to work with compressed logs --- project/analytics/debug.py | 10 +- project/analytics/download_game_ids.py | 21 ++- project/analytics/download_logs_content.py | 63 ++----- project/analytics/logs_compressor.py | 140 --------------- project/analytics/tags.py | 196 --------------------- 5 files changed, 34 insertions(+), 396 deletions(-) delete mode 100644 project/analytics/logs_compressor.py delete mode 100644 project/analytics/tags.py diff --git a/project/analytics/debug.py b/project/analytics/debug.py index 25ca443f..5c802175 100644 --- a/project/analytics/debug.py +++ b/project/analytics/debug.py @@ -3,8 +3,10 @@ import sqlite3 -current_directory = os.path.dirname(os.path.realpath(__file__)) -db_file = os.path.join(current_directory, 'data.db') +YEAR = '2016' + +db_folder = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'db') +db_file = os.path.join(db_folder, '{}.db'.format(YEAR)) def main(): @@ -13,12 +15,16 @@ def main(): with connection: cursor = connection.cursor() + cursor.execute('SELECT COUNT(*) from logs;') + total = cursor.fetchone()[0] + cursor.execute('SELECT COUNT(*) from logs where is_processed = 1;') processed = cursor.fetchone()[0] cursor.execute('SELECT COUNT(*) from logs where was_error = 1;') with_errors = cursor.fetchone()[0] + print('Total: {}'.format(total)) print('Processed: {}'.format(processed)) print('With errors: {}'.format(with_errors)) diff --git a/project/analytics/download_game_ids.py b/project/analytics/download_game_ids.py index b9ff7731..3c25a70c 100644 --- a/project/analytics/download_game_ids.py +++ b/project/analytics/download_game_ids.py @@ -14,13 +14,19 @@ import requests +YEAR = '2017' + current_directory = os.path.dirname(os.path.realpath(__file__)) logs_directory = os.path.join(current_directory, 'data', 'logs') -db_file = os.path.join(current_directory, 'data.db') +db_folder = os.path.join(current_directory, 'db') +db_file = os.path.join(db_folder, '{}.db'.format(YEAR)) if not os.path.exists(logs_directory): mkpath(logs_directory) +if not os.path.exists(db_folder): + mkpath(db_folder) + def main(): # for the initial set up @@ -110,7 +116,7 @@ def download_game_ids(): unix_time = calendar.timegm(datetime.utcnow().utctimetuple()) with connection: cursor = connection.cursor() - cursor.execute('INSERT INTO last_downloads VALUES ("{}", {});'.format(last_name, unix_time)) + cursor.execute('INSERT INTO last_downloads VALUES (?, ?);', [last_name, unix_time]) def _process_log_line(line, results): @@ -131,9 +137,8 @@ def _process_log_line(line, results): # example: 四鳳東喰赤 is_tonpusen = game_type[2] == '東' - year = int(game_id[:4]) - results.append([game_id, year, is_tonpusen]) + results.append([game_id, is_tonpusen]) def set_up_database(): @@ -152,11 +157,10 @@ def set_up_database(): cursor = connection.cursor() cursor.execute(""" CREATE TABLE logs(log_id text primary key, - year int, is_tonpusen int, is_processed int, was_error int, - log text); + log_content text); """) cursor.execute("CREATE INDEX is_tonpusen_index ON logs (is_tonpusen);") cursor.execute("CREATE INDEX is_processed_index ON logs (is_processed);") @@ -178,9 +182,8 @@ def add_logs_to_database(results): cursor = connection.cursor() for item in results: - cursor.execute('INSERT INTO logs VALUES ("{}", {}, {}, 0, 0, "");'.format(item[0], - item[1], - item[2] and 1 or 0)) + cursor.execute('INSERT INTO logs VALUES (?, ?, 0, 0, "");', [item[0], + item[1] and 1 or 0]) if __name__ == '__main__': diff --git a/project/analytics/download_logs_content.py b/project/analytics/download_logs_content.py index aca1e67e..35049046 100644 --- a/project/analytics/download_logs_content.py +++ b/project/analytics/download_logs_content.py @@ -1,21 +1,18 @@ # -*- coding: utf-8 -*- """ -Script will load log ids from the database and will download log content. -Log content will be stores in the DB (compressed version) and in the file. +Script will load log ids from the database and will download log content """ +import bz2 import os import sqlite3 from distutils.dir_util import mkpath import requests -# need to find out better way to do relative imports -# noinspection PyUnresolvedReferences -from logs_compressor import TenhouLogCompressorNoFile +YEAR = '2016' -current_directory = os.path.dirname(os.path.realpath(__file__)) -data_directory = os.path.join(current_directory, 'data') -db_file = os.path.join(current_directory, 'data.db') +db_folder = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'db') +db_file = os.path.join(db_folder, '{}.db'.format(YEAR)) def main(): @@ -42,14 +39,12 @@ def download_log_content(log_id): """ url = 'http://e.mjv.jp/0/log/?{0}'.format(log_id) - log_folder = prepare_log_folder(log_id) - - content = '' + binary_content = None was_error = False try: response = requests.get(url) - content = response.text - if 'mjlog' not in content: + binary_content = response.content + if 'mjlog' not in response.text: was_error = True except Exception as e: was_error = True @@ -59,29 +54,15 @@ def download_log_content(log_id): with connection: cursor = connection.cursor() - # store original content to the file - # and compressed version to the database + compressed_content = '' if not was_error: - original_content = content - log_path = os.path.join(log_folder, log_id + '.mjlog') - with open(log_path, 'w') as f: - f.write(original_content) - try: - compressor = TenhouLogCompressorNoFile(original_content) - content = compressor.compress(log_id) - except Exception as e: - os.remove(log_path) + compressed_content = bz2.compress(binary_content) + except: was_error = True - content = '' - sql = 'UPDATE logs SET is_processed = {}, was_error = {}, log = "{}" WHERE log_id = "{}";'.format( - 1, - was_error and 1 or 0, - content, - log_id - ) - cursor.execute(sql) + cursor.execute('UPDATE logs SET is_processed = ?, was_error = ?, log_content = ? WHERE log_id = ?;', + [1, was_error and 1 or 0, compressed_content, log_id]) print('Was errors: {}'.format(was_error)) @@ -91,27 +72,11 @@ def load_not_processed_logs(limit): with connection: cursor = connection.cursor() - cursor.execute('SELECT log_id FROM logs where is_processed = 0 and was_error = 0 LIMIT {};'.format(limit)) + cursor.execute('SELECT log_id FROM logs where is_processed = 0 and was_error = 0 LIMIT ?;', [limit]) data = cursor.fetchall() results = [x[0] for x in data] return results - -def prepare_log_folder(log_id): - """ - To not store million files in the one folder. We will separate them - based on log has. - This log 2017022109gm-00a9-0000-897ed93b will be converted to the - /8/9/7/e/ folder - """ - temp = log_id.split('-') - game_hash = list(temp[-1][:4]) - - path = os.path.join(data_directory, *game_hash) - mkpath(path) - - return path - if __name__ == '__main__': main() diff --git a/project/analytics/logs_compressor.py b/project/analytics/logs_compressor.py deleted file mode 100644 index f4de3b4b..00000000 --- a/project/analytics/logs_compressor.py +++ /dev/null @@ -1,140 +0,0 @@ -# -*- coding: utf-8 -*- -import os - -import re -from bs4 import BeautifulSoup - -# need to find out better way to do relative imports -# noinspection PyUnresolvedReferences -from tags import GameTypeTag, DiscardAndDrawTag, AgariTag, DoraTag, MeldTag, InitTag, RiichiTag, TAGS_DELIMITER - -data_directory = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'data') - - -class TenhouLogCompressor(object): - log_name = '' - tags = [] - - def compress(self, log_name): - content = self._get_log_content(log_name) - - soup = BeautifulSoup(content, 'html.parser') - elements = soup.find_all() - - skip_tags = ['mjloggm', 'shuffle', 'un', 'taikyoku', 'bye', 'ryuukyoku'] - for tag in elements: - if tag.name in skip_tags: - continue - - match_discard = re.match(r"^[defgtuvw]+\d.*", tag.name) - if match_discard: - self.tags.append(self.parse_draw_and_discard(tag)) - - if tag.name == 'go': - self.tags.append(self.parse_game_type(tag)) - - if tag.name == 'n': - self.tags.append(self.parse_meld(tag)) - - if tag.name == 'reach': - self.tags.append(self.parse_riichi(tag)) - - if tag.name == 'dora': - self.tags.append(self.parse_dora(tag)) - - if tag.name == 'init': - self.tags.append(self.parse_init_round(tag)) - - if tag.name == 'agari': - self.tags.append(self.parse_agari_round(tag)) - - return self._save_results() - - def parse_draw_and_discard(self, tag): - tile = re.findall(r'\d+', tag.name)[0] - tag_name = re.findall(r'^[defgtuvw]+', tag.name)[0] - return DiscardAndDrawTag(tag_name, tile) - - def parse_game_type(self, tag): - return GameTypeTag(tag.attrs['type']) - - def parse_meld(self, tag): - return MeldTag(tag.attrs['who'], tag.attrs['m']) - - def parse_riichi(self, tag): - return RiichiTag(tag.attrs['who'], tag.attrs['step']) - - def parse_dora(self, tag): - return DoraTag(tag.attrs['hai']) - - def parse_init_round(self, tag): - seed = tag.attrs['seed'].split(',') - count_of_honba_sticks = seed[1] - count_of_riichi_sticks = seed[2] - dora_indicator = seed[5] - - return InitTag( - tag.attrs['hai0'], - tag.attrs['hai1'], - tag.attrs['hai2'], - tag.attrs['hai3'], - tag.attrs['oya'], - count_of_honba_sticks, - count_of_riichi_sticks, - dora_indicator, - ) - - def parse_agari_round(self, tag): - temp = tag.attrs['ten'].split(',') - fu = temp[0] - win_scores = temp[1] - - yaku_list = '' - if 'yaku' in tag.attrs: - yaku_list = tag.attrs['yaku'] - - if 'yakuman' in tag.attrs: - yakuman_list = tag.attrs['yakuman'].split(',') - yaku_list = ','.join(['{},13'.format(x) for x in yakuman_list]) - - ura_dora = 'dorahaiura' in tag.attrs and tag.attrs['dorahaiura'] or '' - melds = 'm' in tag.attrs and tag.attrs['m'] or '' - - return AgariTag( - tag.attrs['who'], - tag.attrs['fromwho'], - ura_dora, - tag.attrs['hai'], - melds, - win_scores, - tag.attrs['machi'], - yaku_list, - fu - ) - - def _save_results(self): - log_name = os.path.join(data_directory, 'results', self.log_name.split('/')[-1]) - with open(log_name, 'w') as f: - f.write('{}'.format(TAGS_DELIMITER).join([str(tag) for tag in self.tags])) - return True - - def _get_log_content(self, log_name): - self.log_name = log_name - - log_name = os.path.join(data_directory, log_name) - with open(log_name, 'r') as f: - return f.read() - - -class TenhouLogCompressorNoFile(TenhouLogCompressor): - content = '' - - def __init__(self, content): - self.content = content - self.tags = [] - - def _get_log_content(self, log_name): - return self.content - - def _save_results(self): - return '{}'.format(TAGS_DELIMITER).join([str(tag) for tag in self.tags]) diff --git a/project/analytics/tags.py b/project/analytics/tags.py deleted file mode 100644 index 2fd72c23..00000000 --- a/project/analytics/tags.py +++ /dev/null @@ -1,196 +0,0 @@ -# -*- coding: utf-8 -*- -# ================== -# dirty fix of siblings imports -from sys import path -from os.path import dirname as directory - -path.append(directory(path[0])) -__package__ = "tenhou" -# ===================== -from tenhou.decoder import TenhouDecoder - -GAME_TYPE = 'z' -MELD_TAG = 'm' -RIICHI_TAG = 'r' -DORA_TAG = 'a' -INIT_TAG = 'i' -AGARI_TAG = 'o' - -DELIMITER = ';' -TAGS_DELIMITER = '&' - -decoder = TenhouDecoder() - - -def decode_tag(raw_tag): - values = raw_tag.split(DELIMITER) - - if values[0] == GAME_TYPE: - return GameTypeTag(*values[1:]) - - if values[0] == MELD_TAG: - return MeldTag(*values[1:]) - - if values[0] == RIICHI_TAG: - return RiichiTag(*values[1:]) - - if values[0] == DORA_TAG: - return DoraTag(*values[1:]) - - if values[0] == INIT_TAG: - return InitTag(*values[1:]) - - if values[0] == AGARI_TAG: - return AgariTag(*values[1:]) - - return DiscardAndDrawTag(*values) - - -class BaseTag(object): - tag_name = '' - - def _values(self): - raise NotImplemented() - - def __str__(self): - return '{}'.format(DELIMITER).join([str(x) for x in self._values()]) - - -class DiscardAndDrawTag(BaseTag): - discard = 0 - - # d e f g t u v w tag names - def __init__(self, tag_name, discard): - self.tag_name = tag_name - self.discard = discard - - def _values(self): - return [self.tag_name, self.discard] - - -class GameTypeTag(BaseTag): - tag_name = GAME_TYPE - - game_type = 0 - - def __init__(self, game_type): - self.game_type = game_type - - def _values(self): - return [self.tag_name, self.game_type] - - -class MeldTag(BaseTag): - tag_name = MELD_TAG - - meld_string = '' - who = 0 - - def __init__(self, who, meld): - self.who = who - self.meld_string = meld - - def _values(self): - return [self.tag_name, self.who, self.meld_string] - - -class RiichiTag(BaseTag): - tag_name = RIICHI_TAG - - who = 0 - step = 0 - - def __init__(self, who, step): - self.who = who - self.step = step - - def _values(self): - return [self.tag_name, self.who, self.step] - - -class DoraTag(BaseTag): - tag_name = DORA_TAG - - tile = 0 - - def __init__(self, tile): - self.tile = tile - - def _values(self): - return [self.tag_name, self.tile] - - -class InitTag(BaseTag): - tag_name = INIT_TAG - - first_hand = '' - second_hand = '' - third_hand = '' - fourth_hand = '' - dealer = '' - count_of_honba_sticks = '' - count_of_riichi_sticks = '' - dora_indicator = '' - - def __init__(self, first_hand, second_hand, third_hand, fourth_hand, dealer, count_of_honba_sticks, - count_of_riichi_sticks, dora_indicator): - self.first_hand = first_hand - self.second_hand = second_hand - self.third_hand = third_hand - self.fourth_hand = fourth_hand - self.dealer = dealer - self.count_of_honba_sticks = count_of_honba_sticks - self.count_of_riichi_sticks = count_of_riichi_sticks - self.dora_indicator = dora_indicator - - def _values(self): - return [self.tag_name, self.first_hand, self.second_hand, self.third_hand, - self.fourth_hand, self.dealer, self.count_of_honba_sticks, self.count_of_riichi_sticks, - self.dora_indicator] - - -class AgariTag(BaseTag): - tag_name = AGARI_TAG - - who = 0 - from_who = 0 - ura_dora = '' - closed_hand = '' - open_melds = '' - win_tile = '' - win_scores = '' - yaku_list = '' - fu = '' - - parsed_yaku = None - parsed_melds = None - - def __init__(self, who, from_who, ura_dora, closed_hand, open_melds, win_scores, win_tile, yaku_list, fu): - self.who = who - self.from_who = from_who - self.ura_dora = ura_dora - self.closed_hand = closed_hand - self.open_melds = open_melds - self.win_scores = win_scores - self.win_tile = win_tile - self.yaku_list = yaku_list - self.fu = fu - - self._parse_yaku() - self._parse_melds() - - def _values(self): - return [self.tag_name, self.who, self.from_who, self.ura_dora, self.closed_hand, - self.open_melds, self.win_scores, self.win_tile, self.yaku_list, self.fu] - - def _parse_yaku(self): - data = self.yaku_list.split(',') - self.parsed_yaku = [int(x) for x in data[::2]] - - def _parse_melds(self): - self.parsed_melds = [] - if self.open_melds: - open_melds = self.open_melds.split(',') - for meld in open_melds: - message = ''.format(self.who, meld) - self.parsed_melds.append(decoder.parse_meld(message)) From 7af40cd0eaa33e8b129c4b64d001dc8d306b1a16 Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Thu, 2 Mar 2017 23:54:34 +0800 Subject: [PATCH 70/80] Change year for logs downloading --- project/analytics/download_logs_content.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/analytics/download_logs_content.py b/project/analytics/download_logs_content.py index 35049046..a730abd4 100644 --- a/project/analytics/download_logs_content.py +++ b/project/analytics/download_logs_content.py @@ -9,7 +9,7 @@ import requests -YEAR = '2016' +YEAR = '2017' db_folder = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'db') db_file = os.path.join(db_folder, '{}.db'.format(YEAR)) From c086044264752dfaf2da605c1772904f4eeeb526 Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Fri, 3 Mar 2017 00:08:25 +0800 Subject: [PATCH 71/80] Update analytics scripts --- project/analytics/debug.py | 18 ++++++++++++++---- project/analytics/download_game_ids.py | 17 ++++++++++++++--- project/analytics/download_logs_content.py | 16 ++++++++++++---- 3 files changed, 40 insertions(+), 11 deletions(-) diff --git a/project/analytics/debug.py b/project/analytics/debug.py index 5c802175..63dd6c9f 100644 --- a/project/analytics/debug.py +++ b/project/analytics/debug.py @@ -1,15 +1,15 @@ # -*- coding: utf-8 -*- import os - import sqlite3 - -YEAR = '2016' +import sys db_folder = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'db') -db_file = os.path.join(db_folder, '{}.db'.format(YEAR)) +db_file = '' def main(): + parse_command_line_arguments() + connection = sqlite3.connect(db_file) with connection: @@ -28,5 +28,15 @@ def main(): print('Processed: {}'.format(processed)) print('With errors: {}'.format(with_errors)) + +def parse_command_line_arguments(): + if len(sys.argv) > 1: + year = sys.argv[1] + else: + year = '2017' + + global db_file + db_file = os.path.join(db_folder, '{}.db'.format(year)) + if __name__ == '__main__': main() diff --git a/project/analytics/download_game_ids.py b/project/analytics/download_game_ids.py index 3c25a70c..8cd94424 100644 --- a/project/analytics/download_game_ids.py +++ b/project/analytics/download_game_ids.py @@ -13,13 +13,12 @@ from distutils.dir_util import mkpath import requests - -YEAR = '2017' +import sys current_directory = os.path.dirname(os.path.realpath(__file__)) logs_directory = os.path.join(current_directory, 'data', 'logs') db_folder = os.path.join(current_directory, 'db') -db_file = os.path.join(db_folder, '{}.db'.format(YEAR)) +db_file = '' if not os.path.exists(logs_directory): mkpath(logs_directory) @@ -29,6 +28,8 @@ def main(): + parse_command_line_arguments() + # for the initial set up # set_up_database() @@ -186,5 +187,15 @@ def add_logs_to_database(results): item[1] and 1 or 0]) +def parse_command_line_arguments(): + if len(sys.argv) > 1: + year = sys.argv[1] + else: + year = '2017' + + global db_file + db_file = os.path.join(db_folder, '{}.db'.format(year)) + + if __name__ == '__main__': main() diff --git a/project/analytics/download_logs_content.py b/project/analytics/download_logs_content.py index a730abd4..ed93c28c 100644 --- a/project/analytics/download_logs_content.py +++ b/project/analytics/download_logs_content.py @@ -5,14 +5,12 @@ import bz2 import os import sqlite3 -from distutils.dir_util import mkpath import requests - -YEAR = '2017' +import sys db_folder = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'db') -db_file = os.path.join(db_folder, '{}.db'.format(YEAR)) +db_file = '' def main(): @@ -78,5 +76,15 @@ def load_not_processed_logs(limit): return results + +def parse_command_line_arguments(): + if len(sys.argv) > 1: + year = sys.argv[1] + else: + year = '2017' + + global db_file + db_file = os.path.join(db_folder, '{}.db'.format(year)) + if __name__ == '__main__': main() From 2c593edc972f547abf976625c0aab55d7b126a8d Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Fri, 3 Mar 2017 00:10:09 +0800 Subject: [PATCH 72/80] Add missing function call --- project/analytics/download_logs_content.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/project/analytics/download_logs_content.py b/project/analytics/download_logs_content.py index ed93c28c..d331a9ed 100644 --- a/project/analytics/download_logs_content.py +++ b/project/analytics/download_logs_content.py @@ -14,6 +14,8 @@ def main(): + parse_command_line_arguments() + should_continue = True while should_continue: try: From 1217bc8fed203953d02a041b9dc4d00c7059373d Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Fri, 3 Mar 2017 10:31:14 +0800 Subject: [PATCH 73/80] Update analytics process --- project/analytics/cases/count_of_games.py | 48 +++++------- project/analytics/cases/honitsu_hands.py | 56 ++++++++------ project/analytics/cases/main.py | 93 ++++++++++++++++++----- project/process.py | 52 ++++++++++++- 4 files changed, 174 insertions(+), 75 deletions(-) diff --git a/project/analytics/cases/count_of_games.py b/project/analytics/cases/count_of_games.py index b3493b4f..284cc1ad 100644 --- a/project/analytics/cases/count_of_games.py +++ b/project/analytics/cases/count_of_games.py @@ -1,47 +1,39 @@ # -*- coding: utf-8 -*- import sqlite3 from datetime import datetime + +import logging + from analytics.cases.main import ProcessDataCase +logger = logging.getLogger('process') + class CountOfGames(ProcessDataCase): def process(self): - years = range(2009, datetime.now().year + 1) - connection = sqlite3.connect(self.db_file) with connection: cursor = connection.cursor() - tonpusen_games_array = [] - hanchan_games_array = [] - - for year in years: - total_games_sql = 'SELECT count(*) from logs where year = {};'.format(year) - hanchan_games_sql = 'SELECT count(*) from logs where year = {} and is_tonpusen = 0;'.format(year) - tonpusen_games_sql = 'SELECT count(*) from logs where year = {} and is_tonpusen = 1;'.format(year) - - cursor.execute(total_games_sql) - data = cursor.fetchone() - total_games = data and data[0] or 0 + total_games_sql = 'SELECT count(*) from logs' + hanchan_games_sql = 'SELECT count(*) from logs where is_tonpusen = 0;' - cursor.execute(hanchan_games_sql) - data = cursor.fetchone() - hanchan_games = data and data[0] or 0 + cursor.execute(total_games_sql) + data = cursor.fetchone() + total_games = data and data[0] or 0 - cursor.execute(tonpusen_games_sql) - data = cursor.fetchone() - tonpusen_games = data and data[0] or 0 + cursor.execute(hanchan_games_sql) + data = cursor.fetchone() + hanchan_games = data and data[0] or 0 - hanchan_percentage = total_games and (hanchan_games / total_games) * 100 or 0 - tonpusen_percentage = total_games and (tonpusen_games / total_games) * 100 or 0 + tonpusen_games = total_games - hanchan_games - print(year) - print('Total games: {}'.format(total_games)) - print('Hanchan games: {}, {:.2f}%'.format(hanchan_games, hanchan_percentage)) - print('Tonpusen games: {}, {:.2f}%'.format(tonpusen_games, tonpusen_percentage)) - print() + hanchan_percentage = total_games and (hanchan_games / total_games) * 100 or 0 + tonpusen_percentage = total_games and (tonpusen_games / total_games) * 100 or 0 - tonpusen_games_array.append(tonpusen_percentage) - hanchan_games_array.append(hanchan_percentage) + logger.info('Total games: {}'.format(total_games)) + logger.info('Hanchan games: {}, {:.2f}%'.format(hanchan_games, hanchan_percentage)) + logger.info('Tonpusen games: {}, {:.2f}%'.format(tonpusen_games, tonpusen_percentage)) + logger.info('') diff --git a/project/analytics/cases/honitsu_hands.py b/project/analytics/cases/honitsu_hands.py index 06fda622..4917fbf2 100644 --- a/project/analytics/cases/honitsu_hands.py +++ b/project/analytics/cases/honitsu_hands.py @@ -1,33 +1,41 @@ # -*- coding: utf-8 -*- +import re + +import logging + from analytics.cases.main import ProcessDataCase -from analytics.tags import AGARI_TAG -from mahjong.tile import TilesConverter + +logger = logging.getLogger('process') class HonitsuHands(ProcessDataCase): - HONITSU_ID = 34 + HONITSU_ID = '34' def process(self): self.load_all_records() - filtered = self.filter_hands() - for item in filtered: - meld_tiles = [] - if item.parsed_melds: - for meld in item.parsed_melds: - tiles = meld.tiles - if meld.type == meld.KAN or meld.type == meld.CHAKAN: - tiles = tiles[:3] - meld_tiles.extend(tiles) - - closed_hand_tiles = [int(x) for x in item.closed_hand.split(',')] - tiles = closed_hand_tiles + meld_tiles - print(TilesConverter.to_one_line_string(tiles)) - - def filter_hands(self): - filtered = [] + + filtered_rounds = self.filter_rounds() + logger.info('Found {} honitsu hands'.format(len(filtered_rounds))) + + def filter_rounds(self): + """ + Find all rounds that were ended with honitsu hand + """ + filtered_rounds = [] + + total_rounds = [] for hanchan in self.hanchans: - for tag in hanchan.tags: - if tag.tag_name == AGARI_TAG: - if self.HONITSU_ID in tag.parsed_yaku: - filtered.append(tag) - return filtered + total_rounds.extend(hanchan.rounds) + + find = re.compile(r'yaku=\"(.+?)\"') + for round_item in total_rounds: + for tag in round_item: + if 'AGARI' in tag and 'yaku=' in tag: + yaku_temp = find.findall(tag)[0].split(',') + # start at the beginning at take every second item (even) + yaku_list = yaku_temp[::2] + + if self.HONITSU_ID in yaku_list: + filtered_rounds.append(round_item) + + return filtered_rounds diff --git a/project/analytics/cases/main.py b/project/analytics/cases/main.py index e7a4042f..f13cbd67 100644 --- a/project/analytics/cases/main.py +++ b/project/analytics/cases/main.py @@ -1,40 +1,78 @@ # -*- coding: utf-8 -*- -import os - +import bz2 import sqlite3 +import logging + +import re -from ..tags import TAGS_DELIMITER, decode_tag +logger = logging.getLogger('process') class Hanchan(object): log_id = None is_tonpusen = False - year = None content = None - tags = None + rounds = [] - def __init__(self, log_id, is_tonpusen, year, content): + def __init__(self, log_id, is_tonpusen, compressed_content): self.log_id = log_id self.is_tonpusen = is_tonpusen, - self.year = year, - self.content = content - - self._decode_tags() - - def _decode_tags(self): - self.tags = [] - temp = self.content.split(TAGS_DELIMITER) - for item in temp: - self.tags.append(decode_tag(item)) + self.content = bz2.decompress(compressed_content) + self.rounds = [] + + self._parse_rounds() + + def _parse_rounds(self): + # we had to parse it manually, to save resources + tag_start = 0 + tag = None + game_round = [] + for x in range(0, len(self.content)): + if self.content[x] == '>': + tag = self.content[tag_start:x+1] + tag_start = x + 1 + + # not useful tags + if tag and ('mjloggm' in tag or 'TAIKYOKU' in tag): + tag = None + + # new round was started + if tag and 'INIT' in tag: + self.rounds.append(game_round) + game_round = [] + + # the end of the game + if tag and 'owari' in tag: + self.rounds.append(game_round) + + if tag: + # to save some memory we can remove not needed information from logs + if 'INIT' in tag: + # we dont need seed information + find = re.compile(r'shuffle="[^"]*"') + tag = find.sub('', tag) + + if 'sc' in tag: + # and we don't need points deltas + find = re.compile(r'sc="[^"]*" ') + tag = find.sub('', tag) + + # add processed tag to the round + game_round.append(tag) + tag = None + + # first element is player names, ranks and etc. + # we shouldn't consider it as game round + # and for now let's not save it + self.rounds = self.rounds[1:] class ProcessDataCase(object): db_file = '' hanchans = [] - def __init__(self): - current_directory = os.path.dirname(os.path.realpath(__file__)) - self.db_file = os.path.join(current_directory, '..', 'data.db') + def __init__(self, db_file): + self.db_file = db_file self.hanchans = [] @@ -42,13 +80,26 @@ def process(self): raise NotImplemented() def load_all_records(self): + limit = 60000 + logger.info('Loading data...') + connection = sqlite3.connect(self.db_file) with connection: cursor = connection.cursor() - cursor.execute('SELECT log_id, is_tonpusen, year, log FROM logs WHERE is_processed = 1 and was_error = 0;') + cursor.execute("""SELECT log_id, is_tonpusen, log_content FROM logs + WHERE is_processed = 1 and was_error = 0 LIMIT ?;""", [limit]) data = cursor.fetchall() + logger.info('Found {} records'.format(len(data))) + + logger.info('Unzipping and processing games data...') for item in data: - self.hanchans.append(Hanchan(item[0], item[1] == 1, item[2], item[3])) + self.hanchans.append(Hanchan(item[0], item[1] == 1, item[2])) + + total_rounds = 0 + for hanchan in self.hanchans: + total_rounds += len(hanchan.rounds) + + logger.info('Found {} rounds'.format(total_rounds)) diff --git a/project/process.py b/project/process.py index e89a1aef..910cc1c3 100644 --- a/project/process.py +++ b/project/process.py @@ -1,10 +1,58 @@ # -*- coding: utf-8 -*- +""" +Calculate various statistics on phoenix replays +""" +import os +import sys + +import logging + +import datetime + +# from analytics.cases.count_of_games import CountOfGames +# ANALYTICS_CLASS = CountOfGames from analytics.cases.honitsu_hands import HonitsuHands +ANALYTICS_CLASS = HonitsuHands + +logger = logging.getLogger('process') def main(): - analyzer = HonitsuHands() - analyzer.process() + set_up_logging() + + if len(sys.argv) == 1: + logger.error('Cant find db files. Set db files folder as command line argument') + return + + db_folder = sys.argv[1] + for db_file in os.listdir(db_folder): + logger.info(db_file) + case = ANALYTICS_CLASS(os.path.join(db_folder, db_file)) + case.process() + break + + +def set_up_logging(): + logs_directory = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'logs') + if not os.path.exists(logs_directory): + os.mkdir(logs_directory) + + logger = logging.getLogger('process') + logger.setLevel(logging.DEBUG) + + ch = logging.StreamHandler() + ch.setLevel(logging.DEBUG) + + file_name = datetime.datetime.now().strftime('process-%Y-%m-%d %H_%M_%S') + '.log' + fh = logging.FileHandler(os.path.join(logs_directory, file_name)) + fh.setLevel(logging.DEBUG) + + formatter = logging.Formatter('%(asctime)s %(message)s', datefmt='%H:%M:%S') + ch.setFormatter(formatter) + fh.setFormatter(formatter) + + logger.addHandler(ch) + logger.addHandler(fh) if __name__ == '__main__': main() From ab8aa02b1b79ec841451cf2c1b8c26a4e0003081 Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Fri, 3 Mar 2017 10:57:17 +0800 Subject: [PATCH 74/80] Discard dead honor waits first --- project/mahjong/ai/main.py | 6 ++++++ project/mahjong/ai/tests/tests_strategies.py | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/project/mahjong/ai/main.py b/project/mahjong/ai/main.py index 61911ed6..ade17701 100644 --- a/project/mahjong/ai/main.py +++ b/project/mahjong/ai/main.py @@ -13,6 +13,7 @@ from mahjong.constants import HAKU, CHUN, HATSU from mahjong.hand import HandDivider, FinishedHand from mahjong.tile import TilesConverter +from mahjong.utils import is_honor logger = logging.getLogger('ai') @@ -86,6 +87,11 @@ def discard_tile(self): False, None) + # we had to discard dead waits first (last honor tile) + for result in results: + if is_honor(result.tile_to_discard) and self.table.revealed_tiles[result.tile_to_discard] == 3: + result.tiles_count = 2000 + return self.chose_tile_to_discard(results, self.player.closed_hand) def calculate_outs(self, tiles, closed_hand, is_open_hand=False): diff --git a/project/mahjong/ai/tests/tests_strategies.py b/project/mahjong/ai/tests/tests_strategies.py index ef72c1e1..08147efb 100644 --- a/project/mahjong/ai/tests/tests_strategies.py +++ b/project/mahjong/ai/tests/tests_strategies.py @@ -285,6 +285,25 @@ def test_riichi_and_tiles_from_another_suit_in_the_hand(self): # we already in tempai self.assertEqual(self._to_string([tile_to_discard]), '1z') + def test_discard_not_needed_winds(self): + table = Table() + player = Player(0, 0, table) + 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.enemy_discard(self._string_to_136_tile(honors='3'), 1) + table.enemy_discard(self._string_to_136_tile(honors='3'), 1) + table.enemy_discard(self._string_to_136_tile(honors='3'), 1) + + 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') + class TanyaoStrategyTestCase(unittest.TestCase, TestMixin): From d2ec1ebdf549b9d87ab2796fb53bc80c40a39dfb Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Fri, 3 Mar 2017 16:15:36 +0800 Subject: [PATCH 75/80] Improve the way to build a hand --- project/game/tests.py | 13 +++++++------ project/mahjong/ai/strategies/main.py | 4 ++-- project/mahjong/ai/tests/tests_strategies.py | 14 ++++++++++++++ 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/project/game/tests.py b/project/game/tests.py index 0ee78f31..bb83b81f 100644 --- a/project/game/tests.py +++ b/project/game/tests.py @@ -230,19 +230,19 @@ def test_play_round_and_win_by_ron(self): self.assertNotEqual(result['loser'], None) def test_play_round_with_retake(self): - game.game_manager.shuffle_seed = lambda: 0.1096086064947076 + game.game_manager.shuffle_seed = lambda: 0.4257050015767606 clients = [Client() for _ in range(0, 4)] manager = GameManager(clients) manager.init_game() - manager.set_dealer(2) - manager._unique_dealers = 3 - manager.round_number = 2 + manager.set_dealer(0) + manager._unique_dealers = 0 + manager.round_number = 0 manager.init_round() result = manager.play_round() - self.assertEqual(manager.round_number, 3) + self.assertEqual(manager.round_number, 1) self.assertEqual(result['is_tsumo'], False) self.assertEqual(result['is_game_end'], False) self.assertEqual(result['winner'], None) @@ -260,7 +260,7 @@ def test_play_round_and_open_hand(self): result = manager.play_round() - self.assertEqual(len(result['players_with_open_hands']), 4) + self.assertEqual(len(result['players_with_open_hands']), 3) def test_scores_calculations_after_retake(self): clients = [Client() for _ in range(0, 4)] @@ -359,6 +359,7 @@ def test_win_by_ron_and_scores_calculation(self): manager.init_game() manager.init_round() manager.set_dealer(0) + manager.dora_indicators = [0] winner = clients[0] winner.player.discards = [1, 2] diff --git a/project/mahjong/ai/strategies/main.py b/project/mahjong/ai/strategies/main.py index e66f63b6..c73fadee 100644 --- a/project/mahjong/ai/strategies/main.py +++ b/project/mahjong/ai/strategies/main.py @@ -71,8 +71,8 @@ def determine_what_to_discard(self, closed_hand, outs_results, shanten, for_open for j in outs_results: if j.tile_to_discard == i: item_was_found = True - j.tiles_count = 1000 - j.waiting = [] + if j.tiles_count < 1000: + j.tiles_count += 1000 if not item_was_found: outs_results.append(DiscardOption(player=self.player, diff --git a/project/mahjong/ai/tests/tests_strategies.py b/project/mahjong/ai/tests/tests_strategies.py index 08147efb..c185a5d9 100644 --- a/project/mahjong/ai/tests/tests_strategies.py +++ b/project/mahjong/ai/tests/tests_strategies.py @@ -304,6 +304,20 @@ def test_discard_not_needed_winds(self): # 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 = Player(0, 0, table) + 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() + + # west was discarded three times, we don't need it + self.assertEqual(self._to_string([tile_to_discard]), '5s') + class TanyaoStrategyTestCase(unittest.TestCase, TestMixin): From 608ef9ec2b12f12d4d8c28ae82b72eb0e3ce29f4 Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Fri, 3 Mar 2017 17:43:49 +0800 Subject: [PATCH 76/80] Calculate correct remaining tiles after calling chankan --- project/mahjong/table.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/project/mahjong/table.py b/project/mahjong/table.py index b94eef3d..4c131e70 100644 --- a/project/mahjong/table.py +++ b/project/mahjong/table.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- from mahjong.constants import EAST, SOUTH, WEST, NORTH +from mahjong.meld import Meld from mahjong.player import Player from mahjong.utils import plus_dora, is_aka_dora @@ -71,6 +72,10 @@ def add_called_meld(self, meld, player_seat): if meld.called_tile: tiles.remove(meld.called_tile) + # for chankan we already added 3 tiles + if meld.type == Meld.CHAKAN: + tiles = tiles[0] + for tile in tiles: self._add_revealed_tile(tile) From 592f5b21056fb068e35bfba6c396f01856b26466 Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Fri, 3 Mar 2017 20:57:44 +0800 Subject: [PATCH 77/80] Chose correct waiting for the tanyao tempai --- project/mahjong/ai/strategies/tanyao.py | 26 +++++++++++- project/mahjong/ai/tests/tests_strategies.py | 42 +++++++++++++++++++- 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/project/mahjong/ai/strategies/tanyao.py b/project/mahjong/ai/strategies/tanyao.py index a512c156..b027e378 100644 --- a/project/mahjong/ai/strategies/tanyao.py +++ b/project/mahjong/ai/strategies/tanyao.py @@ -2,7 +2,6 @@ from mahjong.ai.strategies.main import BaseStrategy from mahjong.constants import TERMINAL_INDICES, HONOR_INDICES from mahjong.tile import TilesConverter -from mahjong.utils import is_sou, is_pin, is_man, is_honor class TanyaoStrategy(BaseStrategy): @@ -69,6 +68,31 @@ def should_activate_strategy(self): return True + def determine_what_to_discard(self, closed_hand, outs_results, shanten, for_open_hand, tile_for_open_hand): + if tile_for_open_hand: + tile_for_open_hand //= 4 + + if shanten == 0 and self.player.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]) + 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) + def is_tile_suitable(self, tile): """ We can use only simples tiles (2-8) in any suit diff --git a/project/mahjong/ai/tests/tests_strategies.py b/project/mahjong/ai/tests/tests_strategies.py index c185a5d9..835a1bb6 100644 --- a/project/mahjong/ai/tests/tests_strategies.py +++ b/project/mahjong/ai/tests/tests_strategies.py @@ -508,7 +508,6 @@ def test_we_cant_win_with_this_hand(self): player.init_hand(tiles) player.draw_tile(self._string_to_136_tile(sou='1')) player.ai.current_strategy = TanyaoStrategy(BaseStrategy.TANYAO, player) - # print(player.ai.current_strategy) discard = player.discard_tile() # hand was closed and we have won! @@ -521,3 +520,44 @@ def test_we_cant_win_with_this_hand(self): # but for already open hand we cant do tsumo # because we don't have a yaku here self.assertEqual(self._to_string([discard]), '1s') + + def test_choose_correct_waiting(self): + table = Table() + player = Player(0, 0, table) + + 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 = Table() + player = Player(0, 0, table) + + meld = self._make_meld(Meld.CHI, self._string_to_136_array(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() + player = Player(0, 0, table) + + meld = self._make_meld(Meld.CHI, self._string_to_136_array(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') From 87229f4b1549a02ec65cec6aef3455d303b060b2 Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Sat, 4 Mar 2017 10:09:01 +0800 Subject: [PATCH 78/80] Don't go for honitsu with ryanmen on another suit --- project/game/tests.py | 2 +- project/mahjong/ai/strategies/honitsu.py | 37 +++++++++++++++++++- project/mahjong/ai/tests/tests_strategies.py | 12 ++++++- 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/project/game/tests.py b/project/game/tests.py index bb83b81f..adc4f21c 100644 --- a/project/game/tests.py +++ b/project/game/tests.py @@ -260,7 +260,7 @@ def test_play_round_and_open_hand(self): result = manager.play_round() - self.assertEqual(len(result['players_with_open_hands']), 3) + self.assertEqual(len(result['players_with_open_hands']), 2) def test_scores_calculations_after_retake(self): clients = [Client() for _ in range(0, 4)] diff --git a/project/mahjong/ai/strategies/honitsu.py b/project/mahjong/ai/strategies/honitsu.py index 69600c7b..e3942002 100644 --- a/project/mahjong/ai/strategies/honitsu.py +++ b/project/mahjong/ai/strategies/honitsu.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- from mahjong.ai.strategies.main import BaseStrategy from mahjong.tile import TilesConverter -from mahjong.utils import is_sou, is_pin, is_man, is_honor +from mahjong.utils import is_sou, is_pin, is_man, is_honor, simplify class HonitsuStrategy(BaseStrategy): @@ -46,6 +46,14 @@ def should_activate_strategy(self): if tiles[x] >= 2: count_of_pairs += 1 + suits.remove(suit) + count_of_ryanmens = self._find_ryanmen_waits(tiles, suits[0]['function']) + count_of_ryanmens += self._find_ryanmen_waits(tiles, suits[1]['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: + 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 @@ -63,3 +71,30 @@ def is_tile_suitable(self, tile): """ tile //= 4 return self.chosen_suit(tile) or is_honor(tile) + + def _find_ryanmen_waits(self, tiles, suit): + suit_tiles = [] + for x in range(0, 34): + tile = tiles[x] + if not tile: + continue + + if suit(x): + suit_tiles.append(x) + + count_of_ryanmen_waits = 0 + simple_tiles = [simplify(x) for x in suit_tiles] + for x in range(0, len(simple_tiles)): + tile = simple_tiles[x] + # we cant build ryanmen with 1 and 9 + if tile == 1 or tile == 9: + continue + + # bordered tile + if x + 1 == len(simple_tiles): + continue + + if tile + 1 == simple_tiles[x + 1]: + count_of_ryanmen_waits += 1 + + return count_of_ryanmen_waits diff --git a/project/mahjong/ai/tests/tests_strategies.py b/project/mahjong/ai/tests/tests_strategies.py index 835a1bb6..83c3b49b 100644 --- a/project/mahjong/ai/tests/tests_strategies.py +++ b/project/mahjong/ai/tests/tests_strategies.py @@ -248,7 +248,7 @@ def test_open_hand_and_discard_tiles_logic(self): table = Table() player = Player(0, 0, table) - tiles = self._string_to_136_array(sou='112235589', man='23', honors='22') + 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, @@ -318,6 +318,16 @@ def test_discard_not_effective_tiles_first(self): # west was discarded three times, we don't need it self.assertEqual(self._to_string([tile_to_discard]), '5s') + def test_dont_go_for_honitsu_with_ryanmen_in_other_suit(self): + table = Table() + player = Player(0, 0, table) + 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): From 58d9c820b2cf1035c4fcd74618d923406f665cec Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Sat, 4 Mar 2017 19:07:38 +0800 Subject: [PATCH 79/80] Don't keep honor tiles in hand with small number of shanten --- project/mahjong/ai/discard.py | 11 ++++++++--- project/mahjong/ai/main.py | 4 ++++ project/mahjong/ai/tests/tests_discards.py | 11 +++++++++++ 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/project/mahjong/ai/discard.py b/project/mahjong/ai/discard.py index c116dd72..c7b8e9c4 100644 --- a/project/mahjong/ai/discard.py +++ b/project/mahjong/ai/discard.py @@ -28,7 +28,7 @@ def __init__(self, player, tile_to_discard, waiting, tiles_count): self.waiting = waiting self.tiles_count = tiles_count - self._calculate_value() + self.calculate_value() def find_tile_in_hand(self, closed_hand): """ @@ -54,15 +54,20 @@ 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): + 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 self.player.ai.previous_shanten <= 2: + honored_value = 0 if is_honor(self.tile_to_discard): if self.tile_to_discard in self.player.ai.valued_honors: count_of_winds = [x for x in self.player.ai.valued_honors if x == self.tile_to_discard] # for west-west, east-east we had to double tile value - value += 20 * len(count_of_winds) + value += honored_value * len(count_of_winds) else: # suits suit_tile_grades = [10, 20, 30, 40, 50, 40, 30, 20, 10] diff --git a/project/mahjong/ai/main.py b/project/mahjong/ai/main.py index ade17701..df104dfb 100644 --- a/project/mahjong/ai/main.py +++ b/project/mahjong/ai/main.py @@ -46,7 +46,11 @@ def discard_tile(self): results, shanten = self.calculate_outs(self.player.tiles, self.player.closed_hand, self.player.is_open_hand) + # we had to update tiles value + # because it is related with shanten number self.previous_shanten = shanten + for result in results: + result.calculate_value() if shanten == 0: self.player.in_tempai = True diff --git a/project/mahjong/ai/tests/tests_discards.py b/project/mahjong/ai/tests/tests_discards.py index 8cc014c1..e6a23ac1 100644 --- a/project/mahjong/ai/tests/tests_discards.py +++ b/project/mahjong/ai/tests/tests_discards.py @@ -198,3 +198,14 @@ def test_keep_aka_dora_in_hand(self): # we had to keep red five and discard just 5s discarded_tile = player.discard_tile() self.assertNotEqual(discarded_tile, FIVE_RED_SOU) + + def test_dont_keep_honor_with_small_number_of_shanten(self): + table = Table() + player = Player(0, 0, table) + + tiles = self._string_to_136_array(sou='11445', pin='55699', man='246') + player.init_hand(tiles) + player.draw_tile(self._string_to_136_tile(honors='7')) + + discarded_tile = player.discard_tile() + self.assertEqual(self._to_string([discarded_tile]), '7z') From e20252355a2306cc87d524127be94fd2ae28827a Mon Sep 17 00:00:00 2001 From: Alexey Lisikhin Date: Sat, 4 Mar 2017 20:07:18 +0800 Subject: [PATCH 80/80] Fix flake8 warnings --- project/analytics/cases/count_of_games.py | 1 - project/mahjong/ai/strategies/yakuhai.py | 1 - 2 files changed, 2 deletions(-) diff --git a/project/analytics/cases/count_of_games.py b/project/analytics/cases/count_of_games.py index 284cc1ad..ab4e2e17 100644 --- a/project/analytics/cases/count_of_games.py +++ b/project/analytics/cases/count_of_games.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- import sqlite3 -from datetime import datetime import logging diff --git a/project/mahjong/ai/strategies/yakuhai.py b/project/mahjong/ai/strategies/yakuhai.py index 80627128..5cf84c52 100644 --- a/project/mahjong/ai/strategies/yakuhai.py +++ b/project/mahjong/ai/strategies/yakuhai.py @@ -78,4 +78,3 @@ def meld_had_to_be_called(self, tile): return True return False -