diff --git a/.gitignore b/.gitignore index 5b1a7eef..8772ac6b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ .idea -.vagrant +env *.py[cod] __pycache__ @@ -12,8 +12,10 @@ tests_validate_hand.py loader.py *.db temp +*.log -replays/data/* +project/game/data/* +project/analytics/data/* # temporary files experiments \ No newline at end of file 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 diff --git a/bin/run.sh b/bin/run.sh index 28bf24e3..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 /home/bot/app/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 - /home/bot/env/bin/python /home/bot/app/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 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 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/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..ab4e2e17 --- /dev/null +++ b/project/analytics/cases/count_of_games.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +import sqlite3 + +import logging + +from analytics.cases.main import ProcessDataCase + +logger = logging.getLogger('process') + + +class CountOfGames(ProcessDataCase): + + def process(self): + connection = sqlite3.connect(self.db_file) + + with connection: + cursor = connection.cursor() + + total_games_sql = 'SELECT count(*) from logs' + hanchan_games_sql = 'SELECT count(*) from logs where is_tonpusen = 0;' + + 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 + + tonpusen_games = total_games - hanchan_games + + hanchan_percentage = total_games and (hanchan_games / total_games) * 100 or 0 + tonpusen_percentage = total_games and (tonpusen_games / total_games) * 100 or 0 + + 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 new file mode 100644 index 00000000..4917fbf2 --- /dev/null +++ b/project/analytics/cases/honitsu_hands.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +import re + +import logging + +from analytics.cases.main import ProcessDataCase + +logger = logging.getLogger('process') + + +class HonitsuHands(ProcessDataCase): + HONITSU_ID = '34' + + def process(self): + self.load_all_records() + + 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: + 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 new file mode 100644 index 00000000..f13cbd67 --- /dev/null +++ b/project/analytics/cases/main.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- +import bz2 +import sqlite3 +import logging + +import re + +logger = logging.getLogger('process') + + +class Hanchan(object): + log_id = None + is_tonpusen = False + content = None + rounds = [] + + def __init__(self, log_id, is_tonpusen, compressed_content): + self.log_id = log_id + self.is_tonpusen = is_tonpusen, + 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, db_file): + self.db_file = db_file + + self.hanchans = [] + + 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, 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])) + + total_rounds = 0 + for hanchan in self.hanchans: + total_rounds += len(hanchan.rounds) + + logger.info('Found {} rounds'.format(total_rounds)) diff --git a/project/analytics/debug.py b/project/analytics/debug.py new file mode 100644 index 00000000..63dd6c9f --- /dev/null +++ b/project/analytics/debug.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +import os +import sqlite3 +import sys + +db_folder = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'db') +db_file = '' + + +def main(): + parse_command_line_arguments() + + connection = sqlite3.connect(db_file) + + 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)) + + +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 new file mode 100644 index 00000000..8cd94424 --- /dev/null +++ b/project/analytics/download_game_ids.py @@ -0,0 +1,201 @@ +# -*- 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 +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 = '' + +if not os.path.exists(logs_directory): + mkpath(logs_directory) + +if not os.path.exists(db_folder): + mkpath(db_folder) + + +def main(): + parse_command_line_arguments() + + # 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 (?, ?);', [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] == '東' + + results.append([game_id, 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, + is_tonpusen int, + is_processed int, + was_error int, + 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);") + 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, "");', [item[0], + 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 new file mode 100644 index 00000000..d331a9ed --- /dev/null +++ b/project/analytics/download_logs_content.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8 -*- +""" +Script will load log ids from the database and will download log content +""" +import bz2 +import os +import sqlite3 + +import requests +import sys + +db_folder = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'db') +db_file = '' + + +def main(): + parse_command_line_arguments() + + 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) + + binary_content = None + was_error = False + try: + response = requests.get(url) + binary_content = response.content + if 'mjlog' not in response.text: + was_error = True + except Exception as e: + was_error = True + + connection = sqlite3.connect(db_file) + + with connection: + cursor = connection.cursor() + + compressed_content = '' + if not was_error: + try: + compressed_content = bz2.compress(binary_content) + except: + was_error = True + + 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)) + + +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 ?;', [limit]) + data = cursor.fetchall() + results = [x[0] for x in data] + + 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() diff --git a/project/bots_battle.py b/project/bots_battle.py index effcf332..09f0b3ed 100644 --- a/project/bots_battle.py +++ b/project/bots_battle.py @@ -16,24 +16,26 @@ 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 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 = {} 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 +53,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 +71,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 +80,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 +94,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 81584d06..83f4376a 100644 --- a/project/game/game_manager.py +++ b/project/game/game_manager.py @@ -1,14 +1,18 @@ # -*- 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 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 +from utils.settings_handler import settings + +settings.FIVE_REDS = True # we need to have it # to be able repeat our tests with needed random @@ -25,16 +29,17 @@ 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 = [] 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 @@ -42,6 +47,7 @@ class GameManager(object): _unique_dealers = 0 def __init__(self, clients): + self.tiles = [] self.dead_wall = [] self.dora_indicators = [] @@ -50,18 +56,23 @@ def __init__(self, clients): self.agari = Agari() self.finished_hand = FinishedHand() + self.replay = Replay(self.clients) def init_game(self): """ - Initial of the game. - Clients random placement and dealer selection + 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].position = i + 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 @@ -69,14 +80,14 @@ def init_game(self): self._unique_dealers = 1 def init_round(self): - # each round should have personal seed - global seed_value - seed_value = random() + """ + Generate players hands, dead wall and dora indicators + """ - self.tiles = [i for i in range(0, 136)] + self.players_with_open_hands = [] + self.dora_indicators = [] - # 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]) @@ -87,14 +98,14 @@ 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) 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], @@ -113,74 +124,168 @@ 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())) - logger.info('Dealer: {0}'.format(self.dealer)) - logger.info('Wind: {0}. Riichi sticks: {1}. Honba sticks: {2}'.format( + 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, self.riichi_sticks, self.honba_sticks )) logger.info('Players: {0}'.format(self.players_sorted_by_scores())) + self.replay.init_round(self.dealer, + self._unique_dealers - 1, + self.honba_sticks, + self.riichi_sticks, + self.dora_indicators[0]) + 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] + self.replay.draw(current_client.seat, tile) # 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)) + 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: - result = self.process_the_end_of_the_round(tiles=client.player.tiles, - win_tile=tile, - winner=client, - loser=None, - is_tsumo=True) - return result + tiles.remove(tile) + 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) + + # 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 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 - # after tile discard let's check all other players can they win or not - # at this tile + 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: + return result + + # 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 = [] for other_client in self.clients: # there is no need to check the current client - if other_client == client: + # or check client in riichi + if other_client == current_client or other_client.player.in_riichi: continue - # let's store other players discards - other_client.enemy_discard(other_client.position - client.position, tile) - - # 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) + # 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, shanten = other_client.player.try_to_call_meld(tile, is_kamicha_discard) + + if meld: + meld.from_who = current_client.seat + meld.who = other_client.seat + meld.called_tile = tile + possible_melds.append({ + 'meld': meld, + 'discarded_tile': discarded_tile, + 'shanten': shanten + }) + + 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'] + meld = possible_melds[0]['meld'] + shanten = possible_melds[0]['shanten'] + + # 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() + 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(current_client.player.format_hand_for_print(tile))) + + # we need to notify each client about called meld + for _client in self.clients: + _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 + + if shanten == 0: + current_client.player.in_tempai = True + + 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_to_discard) + 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) + if result: return result - # 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) - - self.current_client = self._move_position(self.current_client) + self.current_client_seat = self._move_position(self.current_client_seat) # retake if not len(self.tiles): @@ -189,6 +294,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.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): + # 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 @@ -199,6 +332,7 @@ def play_game(self, total_results): is_game_end = False self.init_game() + self.replay.init_game() played_rounds = 0 @@ -218,9 +352,14 @@ 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() + self.replay.end_game() logger.info('Final Scores: {0}'.format(self.players_sorted_by_scores())) logger.info('The end of the game') @@ -241,10 +380,26 @@ 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])) + + # 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]), client.player.meld_tiles) return is_ron def call_riichi(self, client): @@ -252,11 +407,20 @@ def call_riichi(self, client): client.player.scores -= 1000 self.riichi_sticks += 1 - who_called_riichi = client.position + if len(client.player.discards) == 1: + 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.position) + client.enemy_riichi(self._enemy_position(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)) + logger.info('With hand: {}'.format( + TilesConverter.to_one_line_string(self.clients[who_called_riichi].player.closed_hand) + )) def set_dealer(self, dealer): self.dealer = dealer @@ -268,18 +432,22 @@ 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 = 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( + logger.info('{}: {} + {}'.format( is_tsumo and 'Tsumo' or 'Ron', TilesConverter.to_one_line_string(tiles), TilesConverter.to_one_line_string([win_tile])), @@ -291,24 +459,82 @@ 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( + ura_dora = [] + # add one more dora for riichi win + 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, + is_riichi=winner.player.in_riichi, + is_dealer=winner.player.is_dealer, + 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, + 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( TilesConverter.to_one_line_string(tiles + [win_tile]), hand_value['error'] )) - hand_value = 1000 + 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']))) + + if loser is not None: + loser_seat = loser.seat + else: + # tsumo + loser_seat = winner.seat + + self.replay.win(winner.seat, + loser_seat, + win_tile, + self.honba_sticks, + self.riichi_sticks, + hand_value['han'], + hand_value['fu'], + hand_value['cost'], + hand_value['hand_yaku'], + self.dora_indicators, + ura_dora) - 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: @@ -320,56 +546,74 @@ 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} +{2:,d}'.format(winner.player.name, calculated_cost, + riichi_bonus + honba_bonus)) + 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 + tempai_users = [] + dealer_was_tempai = False for client in self.clients: if client.player.in_tempai: - tempai_users += 1 + tempai_users.append(client.seat) + + if client.player.is_dealer: + dealer_was_tempai = True - 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: - 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 - 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: self.honba_sticks += 1 else: - client.player.scores -= 3000 / (4 - tempai_users) + 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, # we need to end the game @@ -387,7 +631,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): @@ -407,7 +652,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) -> []: """ @@ -422,9 +667,37 @@ 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: current_position = 0 return 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) + + 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 + + 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)] + + # let's shuffle wall two times just in case + shuffle_wall(rand_one) + shuffle_wall(rand_two) + + return wall diff --git a/project/game/logger.py b/project/game/logger.py index 6ca5a53d..0e2ab3f4 100644 --- a/project/game/logger.py +++ b/project/game/logger.py @@ -1,15 +1,33 @@ # -*- 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) + + logger = logging.getLogger('ai') + logger.setLevel(logging.DEBUG) + logger.addHandler(ch) + logger.addHandler(fh) 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..ec2ea189 --- /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): + 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, meld): + 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..d604f8c0 --- /dev/null +++ b/project/game/replays/tenhou.py @@ -0,0 +1,284 @@ +# -*- coding: utf-8 -*- +import os +import time + +from game.replays.base import Replay +from mahjong.meld import Meld + + +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): + 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: + 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, meld): + self.tags.append(''.format(meld.who, self._encode_meld(meld))) + + 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): + winner = self.clients[who].player + han_key = winner.is_open_hand and 'open' or 'closed' + scores = [] + for client in self.clients: + # 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: + 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))) + + # tsumo + if who == from_who: + if winner.is_dealer: + payment = cost['main'] * 3 + else: + payment = cost['main'] + cost['additional'] * 2 + # ron + 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 winner.closed_hand]), + win_tile, + melds, + fu, + payment, + 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)) + + 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 = [] + + 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.append(self._to_binary_string(t2, 2)) + result.append(self._to_binary_string(t1, 2)) + result.append(self._to_binary_string(t0, 2)) + + # it was a chi + result.append('1') + + 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 = [] + + tiles = sorted(meld.tiles[:]) + base = int(tiles[0] / 4) + + called = tiles.index(meld.called_tile) + base_and_called = base * 3 + 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) + 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 + + def _from_who_offset(self, who, from_who): + result = from_who - 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) diff --git a/project/game/replays/tests.py b/project/game/replays/tests.py new file mode 100644 index 00000000..1e9a24c0 --- /dev/null +++ b/project/game/replays/tests.py @@ -0,0 +1,46 @@ +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): + 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, '3303') + + def test_encode_called_pon(self): + 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, '47658') diff --git a/project/game/tests.py b/project/game/tests.py index f31a1eb2..adc4f21c 100644 --- a/project/game/tests.py +++ b/project/game/tests.py @@ -5,13 +5,33 @@ import game.game_manager 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): +class GameManagerTestCase(unittest.TestCase, TestMixin): def setUp(self): logger = logging.getLogger('game') - logger.disabled = True + logger.disabled = False + + # def test_debug(self): + # 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)] + # # clients += [Client(use_previous_ai_version=False)] + # manager = GameManager(clients) + # manager.replay.init_game() + # manager.init_game() + # manager.set_dealer(2) + # manager._unique_dealers = 3 + # manager.round_number = 2 + # manager.init_round() + # + # result = manager.play_round() + # + # manager.replay.end_game() def test_init_game(self): clients = [Client() for _ in range(0, 4)] @@ -28,7 +48,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) @@ -172,46 +192,52 @@ 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.7662959679647414 + 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(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.01 + game.game_manager.shuffle_seed = lambda: 0.4257050015767606 clients = [Client() for _ in range(0, 4)] manager = GameManager(clients) manager.init_game() - manager.set_dealer(3) + manager.set_dealer(0) + manager._unique_dealers = 0 + manager.round_number = 0 manager.init_round() result = manager.play_round() @@ -222,13 +248,27 @@ def test_play_round_with_retake(self): self.assertEqual(result['winner'], None) self.assertEqual(result['loser'], None) + 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.init_round() + manager.set_dealer(0) + manager.round_number = 0 + + result = manager.play_round() + + self.assertEqual(len(result['players_with_open_hands']), 2) + 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 +276,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 +288,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 +301,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 +315,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 +329,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 @@ -297,29 +337,40 @@ 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 - manager.process_the_end_of_the_round(None, None, None, None, False) - self.assertEqual(manager.honba_sticks, 0) + 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, 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, None, False) - self.assertEqual(manager.honba_sticks, 1) + manager.process_the_end_of_the_round([], None, None, None, False) + self.assertEqual(manager.honba_sticks, 2) 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() manager.init_round() + manager.set_dealer(0) + manager.dora_indicators = [0] winner = clients[0] + winner.player.discards = [1, 2] 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) + # 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 +378,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,25 +392,59 @@ 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) + settings.FIVE_REDS = True + def test_win_by_tsumo_and_scores_calculation(self): clients = [Client() for _ in range(0, 4)] manager = GameManager(clients) manager.init_game() manager.init_round() manager.riichi_sticks = 1 + manager.honba_sticks = 1 + + winner = clients[0] + manager.set_dealer(0) + 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') + manager.process_the_end_of_the_round(tiles, win_tile, winner, None, True) + + # 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 winner = clients[0] - manager.process_the_end_of_the_round(list(range(0, 14)), 0, winner, None, True) + manager.set_dealer(1) + winner.player.in_riichi = False + 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) - 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) + # 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)] @@ -366,28 +453,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 +485,16 @@ def test_is_game_end_by_negative_scores(self): winner = clients[0] loser = clients[1] - loser.player.scores = 500 + manager.dora_indicators = [100] + 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') + # 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) def test_is_game_end_by_eight_winds(self): @@ -416,12 +509,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/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/discard.py b/project/mahjong/ai/discard.py new file mode 100644 index 00000000..c7b8e9c4 --- /dev/null +++ b/project/mahjong/ai/discard.py @@ -0,0 +1,80 @@ +# -*- 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): + 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 + # calculated tile value, for sorting + value = 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 + + 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 + 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 += honored_value * len(count_of_winds) + else: + # suits + suit_tile_grades = [10, 20, 30, 40, 50, 40, 30, 20, 10] + simplified_tile = simplify(self.tile_to_discard) + value += suit_tile_grades[simplified_tile] + + count_of_dora = plus_dora(self.tile_to_discard * 4, self.player.table.dora_indicators) + value += 50 * count_of_dora + + self.value = value diff --git a/project/mahjong/ai/main.py b/project/mahjong/ai/main.py index 71d1ce67..df104dfb 100644 --- a/project/mahjong/ai/main.py +++ b/project/mahjong/ai/main.py @@ -1,17 +1,33 @@ # -*- coding: utf-8 -*- +import logging + 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 +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, FinishedHand from mahjong.tile import TilesConverter +from mahjong.utils import is_honor + +logger = logging.getLogger('ai') class MainAI(BaseAI): - version = '0.0.6' + version = '0.1.0' agari = None shanten = None defence = None + hand_divider = None + previous_shanten = 7 + + current_strategy = None def __init__(self, table, player): super(MainAI, self).__init__(table, player) @@ -19,88 +35,167 @@ def __init__(self, table, player): self.agari = Agari() self.shanten = Shanten() self.defence = Defence(table) + self.hand_divider = HandDivider() + self.previous_shanten = 7 + self.current_strategy = None + + def erase_state(self): + self.current_strategy = None def discard_tile(self): - results, shanten = self.calculate_outs() + 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 # we are win! if shanten == Shanten.AGARI_STATE: - 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 - # 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) - - tile34 = results[0]['discard'] - tile_in_hand = TilesConverter.find_34_tile_in_136_array(tile34, self.player.tiles) - - return tile_in_hand + return Shanten.AGARI_STATE - def calculate_outs(self): - tiles = TilesConverter.to_34_array(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 + + we_can_call_riichi = shanten == 0 and self.player.can_call_riichi() + # current strategy can affect on our discard options + # 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, + 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): + """ + :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, is_open_hand, self.player.meld_tiles) - shanten = self.shanten.calculate_shanten(tiles) # win if shanten == Shanten.AGARI_STATE: return [], shanten - raw_data = {} + results = [] + for i in range(0, 34): - if not tiles[i]: + if not tiles_34[i]: continue - tiles[i] -= 1 + if not closed_tiles_34[i]: + continue + + tiles_34[i] -= 1 - raw_data[i] = [] + waiting = [] 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: - raw_data[i].append(j) - tiles[j] -= 1 - - tiles[i] += 1 + tiles_34[j] += 1 + if self.shanten.calculate_shanten(tiles_34, is_open_hand, self.player.meld_tiles) == shanten - 1: + waiting.append(j) + tiles_34[j] -= 1 - if raw_data[i]: - raw_data[i] = {'tile': i, 'tiles_count': self.count_tiles(raw_data[i], tiles), 'waiting': raw_data[i]} - - results = [] - tiles = TilesConverter.to_34_array(self.player.tiles) - for tile in range(0, len(tiles)): - if tile in raw_data and raw_data[tile] and raw_data[tile]['tiles_count']: - item = raw_data[tile] + tiles_34[i] += 1 - 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 - 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): + if not self.current_strategy: + return None, None, None + + 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 + if self.player.is_open_hand and self.current_strategy: + return False + + old_strategy = self.current_strategy + self.current_strategy = None + + # order is important + strategies = [ + YakuhaiStrategy(BaseStrategy.YAKUHAI, self.player), + HonitsuStrategy(BaseStrategy.HONITSU, self.player), + TanyaoStrategy(BaseStrategy.TANYAO, self.player), + ] + + for strategy in strategies: + 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 + + def chose_tile_to_discard(self, results, closed_hand): + # - 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 + 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..affc1f9e 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,42 @@ class Shanten(object): number_isolated_tiles = 0 min_shanten = 0 - def calculate_shanten(self, tiles): + 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 34 tiles format :return: int """ + # we will modify them later, so we need to use a copy + 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 - self.min_shanten = self._scan_chitoitsu_and_kokushi() + # 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() + + 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() + 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/honitsu.py b/project/mahjong/ai/strategies/honitsu.py new file mode 100644 index 00000000..e3942002 --- /dev/null +++ b/project/mahjong/ai/strategies/honitsu.py @@ -0,0 +1,100 @@ +# -*- 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, simplify + + +class HonitsuStrategy(BaseStrategy): + REQUIRED_TILES = 10 + + 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 + """ + + 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}, + {'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] + count_of_pairs = 0 + for x in range(0, 34): + 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 + if suit['count'] + honor['count'] >= HonitsuStrategy.REQUIRED_TILES: + self.chosen_suit = suit['function'] + return count_of_pairs > 0 + 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 + """ + 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/strategies/main.py b/project/mahjong/ai/strategies/main.py new file mode 100644 index 00000000..c73fadee --- /dev/null +++ b/project/mahjong/ai/strategies/main.py @@ -0,0 +1,264 @@ +# -*- coding: utf-8 -*- +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 + + +class BaseStrategy(object): + YAKUHAI = 0 + 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 + min_shanten = 7 + + 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 + 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 + """ + 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): + """ + Can tile be used for open hand strategy or not + :param tile: in 136 tiles format + :return: boolean + """ + raise NotImplemented() + + 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 + """ + + # 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.tile_to_discard == i: + item_was_found = True + if j.tiles_count < 1000: + j.tiles_count += 1000 + + if not item_was_found: + outs_results.append(DiscardOption(player=self.player, + tile_to_discard=i, + waiting=[], + tiles_count=1000)) + + return outs_results + + 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 is_kamicha_discard: boolean + :return: meld and tile to discard after called open set, and new shanten count + """ + if self.player.in_riichi: + return None, None, None + + closed_hand = self.player.closed_hand[:] + + # we opened all our hand + if len(closed_hand) == 1: + return None, None, None + + # we can't use this tile for our chosen strategy + if not self.is_tile_suitable(tile): + return None, None, None + + discarded_tile = tile // 4 + + new_tiles = self.player.tiles[:] + [tile] + # 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: + return None, None, None + + # we can't improve hand, so we don't need to open it + if not outs_results: + return None, 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 or self.meld_had_to_be_called(tile): + 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): + first_index = 0 + second_index = 8 + elif is_pin(discarded_tile): + first_index = 9 + second_index = 17 + elif is_sou(discarded_tile): + first_index = 18 + second_index = 26 + + if second_index == 0: + # honor tiles + if closed_hand_34[discarded_tile] == 3: + combinations = [[[discarded_tile] * 3]] + else: + # to avoid not necessary calculations + # we can check only tiles around +-2 discarded tile + first_limit = discarded_tile - 2 + if first_limit < first_index: + first_limit = first_index + + second_limit = discarded_tile + 2 + if second_limit > second_index: + second_limit = second_index + + combinations = self.player.ai.hand_divider.find_valid_combinations(closed_hand_34, + first_limit, + second_limit, True) + + if combinations: + combinations = combinations[0] + + possible_melds = [] + for 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) + + # 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, + new_tiles) + 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.type = meld_type + meld.tiles = sorted(tiles) + + 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: + 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 + + 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 + :param possible_melds: + :param closed_hand_34: + :param first_limit: + :param second_limit: + :return: + """ + + if len(possible_melds) == 1: + return possible_melds[0] + + # 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] + + 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 + # open hand always should be true to exclude chitoitsu hands from calculations + 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'] diff --git a/project/mahjong/ai/strategies/tanyao.py b/project/mahjong/ai/strategies/tanyao.py new file mode 100644 index 00000000..b027e378 --- /dev/null +++ b/project/mahjong/ai/strategies/tanyao.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- +from mahjong.ai.strategies.main import BaseStrategy +from mahjong.constants import TERMINAL_INDICES, HONOR_INDICES +from mahjong.tile import TilesConverter + + +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 + """ + + 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 + 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: + count_of_terminal_pon_sets += 1 + + if x in self.not_suitable_tiles and tile == 2: + count_of_terminal_pairs += 1 + + if x in self.player.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: + 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 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 + :param tile: 136 tiles format + :return: True + """ + tile //= 4 + return tile not in self.not_suitable_tiles diff --git a/project/mahjong/ai/strategies/yakuhai.py b/project/mahjong/ai/strategies/yakuhai.py new file mode 100644 index 00000000..5cf84c52 --- /dev/null +++ b/project/mahjong/ai/strategies/yakuhai.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +from mahjong.ai.strategies.main import BaseStrategy +from mahjong.meld import Meld +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 + """ + result = super(YakuhaiStrategy, self).should_activate_strategy() + if not result: + return False + + 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 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): + """ + 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, 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 and tile_for_open_hand not in valued_pairs: + valued_pair = valued_pairs[0] + + results = [] + for item in outs_results: + if valued_pair in item.waiting: + results.append(item) + return results + else: + return super(YakuhaiStrategy, self).determine_what_to_discard(closed_hand, + outs_results, + shanten, + 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 + 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_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_ai.py b/project/mahjong/ai/tests/tests_ai.py index f9d35a1f..21e57203 100644 --- a/project/mahjong/ai/tests/tests_ai.py +++ b/project/mahjong/ai/tests/tests_ai.py @@ -1,8 +1,8 @@ # -*- 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 from mahjong.table import Table from utils.tests import TestMixin @@ -10,117 +10,194 @@ class AITestCase(unittest.TestCase, TestMixin): - def test_outs(self): + def test_set_is_tempai_flag_to_the_player(self): table = Table() player = Player(0, 0, table) - ai = MainAI(table, player) - tiles = self._string_to_136_array(sou='111345677', pin='15', man='56') + 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() - - 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) + player.discard_tile() + self.assertEqual(player.in_tempai, False) - tiles = self._string_to_136_array(sou='111345677', pin='45', man='56') + 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() + player.discard_tile() + self.assertEqual(player.in_tempai, True) - 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) + def test_not_open_hand_in_riichi(self): + table = Table() + player = Player(0, 0, table) - tiles = self._string_to_136_array(sou='11145677', pin='345', man='56') - tile = self._string_to_136_array(man='9')[0] + player.in_riichi = True + + tiles = self._string_to_136_array(sou='12368', pin='2358', honors='4455') + tile = self._string_to_136_tile(honors='5') player.init_hand(tiles) - player.draw_tile(tile) + meld, _, _ = player.try_to_call_meld(tile, False) + 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) - outs, shanten = ai.calculate_outs() + 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) + self.assertNotEqual(meld, None) + self.assertEqual(meld.type, Meld.PON) + self.assertEqual(self._to_string(meld.tiles), '555m') - 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(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) + self.assertNotEqual(meld, None) + self.assertEqual(meld.type, Meld.CHI) + self.assertEqual(self._to_string(meld.tiles), '345m') - tiles = self._string_to_136_array(sou='11145677', pin='345', man='56') - tile = self._string_to_136_array(man='4')[0] + tiles = self._string_to_136_array(man='23557', pin='556788', honors='22') + tile = self._string_to_136_tile(pin='5') player.init_hand(tiles) - player.draw_tile(tile) + 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) - outs, shanten = ai.calculate_outs() + 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 - self.assertEqual(shanten, Shanten.AGARI_STATE) - self.assertEqual(len(outs), 0) + 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_discard_tile(self): + def test_chose_strategy_and_reset_strategy(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(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) - discarded_tile = player.discard_tile() - self.assertEqual(discarded_tile, 36) + tiles = self._string_to_136_array(man='33355788', sou='3479', honors='3') + player.init_hand(tiles) + self.assertEqual(player.ai.current_strategy.type, BaseStrategy.TANYAO) - player.draw_tile(self._string_to_136_array(pin='4')[0]) + # 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) - discarded_tile = player.discard_tile() - self.assertEqual(discarded_tile, 92) + def test_remaining_tiles_and_enemy_discard(self): + table = Table() + player = Player(0, 0, table) - player.draw_tile(self._string_to_136_array(pin='3')[0]) + tiles = self._string_to_136_array(man='123456789', sou='167', honors='77') + player.init_hand(tiles) - discarded_tile = player.discard_tile() - self.assertEqual(discarded_tile, 32) + results, shanten = player.ai.calculate_outs(tiles, tiles) + self.assertEqual(results[0].tiles_count, 8) - player.draw_tile(self._string_to_136_array(man='4')[0]) + player.table.enemy_discard(self._string_to_136_tile(sou='5'), 1) - discarded_tile = player.discard_tile() - self.assertEqual(discarded_tile, Shanten.AGARI_STATE) + results, shanten = player.ai.calculate_outs(tiles, tiles) + self.assertEqual(results[0].tiles_count, 7) - def test_discard_isolated_honor_tiles_first(self): + 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(sou='8', pin='56688', man='11323', honors='36') - tile = self._string_to_136_array(man='9')[0] + tiles = self._string_to_136_array(man='123456789', sou='167', honors='77') player.init_hand(tiles) - player.draw_tile(tile) - discarded_tile = player.discard_tile() - self.assertEqual(discarded_tile, 128) + results, shanten = player.ai.calculate_outs(tiles, tiles) + self.assertEqual(results[0].tiles_count, 8) - player.draw_tile(self._string_to_136_array(man='4')[0]) + # 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) - discarded_tile = player.discard_tile() - self.assertEqual(discarded_tile, 116) + results, shanten = player.ai.calculate_outs(tiles, tiles) + self.assertEqual(results[0].tiles_count, 5) - def test_set_is_tempai_flag_to_the_player(self): + # 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(sou='111345677', pin='45', man='56') - tile = self._string_to_136_array(man='9')[0] + tiles = self._string_to_136_array(man='123456789', sou='167', honors='77') player.init_hand(tiles) - player.draw_tile(tile) - player.discard_tile() - self.assertEqual(player.in_tempai, False) + results, shanten = player.ai.calculate_outs(tiles, tiles) + self.assertEqual(results[0].tiles_count, 8) - tiles = self._string_to_136_array(sou='11145677', pin='345', man='56') - tile = self._string_to_136_array(man='9')[0] + 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) + + 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) - player.draw_tile(tile) - player.discard_tile() - self.assertEqual(player.in_tempai, True) + # 2s + tile = 77 + meld, tile_to_discard, shanten = player.try_to_call_meld(tile, True) + self.assertIsNotNone(meld) diff --git a/project/mahjong/ai/tests/tests_discards.py b/project/mahjong/ai/tests/tests_discards.py new file mode 100644 index 00000000..e6a23ac1 --- /dev/null +++ b/project/mahjong/ai/tests/tests_discards.py @@ -0,0 +1,211 @@ +# -*- 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 + + +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) + + 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) + + 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') diff --git a/project/mahjong/ai/tests/tests_shanten.py b/project/mahjong/ai/tests/tests_shanten.py index d6eebd54..91d2108b 100644 --- a/project/mahjong/ai/tests/tests_shanten.py +++ b/project/mahjong/ai/tests/tests_shanten.py @@ -10,62 +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_34_array(sou='44467778', pin='222567') + open_sets = [] + 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(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/ai/tests/tests_strategies.py b/project/mahjong/ai/tests/tests_strategies.py new file mode 100644 index 00000000..83c3b49b --- /dev/null +++ b/project/mahjong/ai/tests/tests_strategies.py @@ -0,0 +1,573 @@ +# -*- 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 +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) + + # with chitoitsu-like hand we don't need to go for yakuhai + tiles = self._string_to_136_array(sou='1235566', man='8899', honors='66') + player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(), False) + + def test_dont_activate_strategy_if_we_dont_have_enough_tiles_in_the_wall(self): + table = Table() + player = 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) + 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') + tile = self._string_to_136_tile(honors='4') + player.init_hand(tiles) + + # we don't need to open hand with not our wind + meld, _, _ = player.try_to_call_meld(tile, False) + self.assertEqual(meld, None) + + # with dragon pair in hand let's open our hand + tiles = self._string_to_136_array(sou='1689', pin='2358', man='1', honors='4455') + tile = self._string_to_136_tile(honors='4') + player.init_hand(tiles) + meld, _, _ = player.try_to_call_meld(tile, False) + self.assertNotEqual(meld, None) + player.add_called_meld(meld) + player.tiles.append(tile) + + self.assertEqual(meld.type, Meld.PON) + self.assertEqual(self._to_string(meld.tiles), '444z') + self.assertEqual(len(player.closed_hand), 11) + self.assertEqual(len(player.tiles), 14) + player.discard_tile() + + tile = self._string_to_136_tile(honors='5') + meld, _, _ = player.try_to_call_meld(tile, False) + self.assertNotEqual(meld, None) + player.add_called_meld(meld) + player.tiles.append(tile) + + self.assertEqual(meld.type, Meld.PON) + self.assertEqual(self._to_string(meld.tiles), '555z') + self.assertEqual(len(player.closed_hand), 8) + self.assertEqual(len(player.tiles), 14) + player.discard_tile() + + tile = self._string_to_136_tile(sou='7') + # we can call chi only from left player + meld, _, _ = player.try_to_call_meld(tile, False) + self.assertEqual(meld, None) + + meld, _, _ = player.try_to_call_meld(tile, True) + self.assertNotEqual(meld, None) + player.add_called_meld(meld) + player.tiles.append(tile) + + self.assertEqual(meld.type, Meld.CHI) + self.assertEqual(self._to_string(meld.tiles), '678s') + self.assertEqual(len(player.closed_hand), 5) + self.assertEqual(len(player.tiles), 14) + + def test_force_yakuhai_pair_waiting_for_tempai_hand(self): + """ + If hand shanten = 1 don't open hand except the situation where is + we have tempai on yakuhai tile after open set + """ + table = Table() + player = 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, False) + self.assertEqual(meld, None) + + tile = self._string_to_136_tile(man='7') + 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') + + # 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) + + 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) + + 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): + + 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='238', honors='11234') + player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(), True) + + # with hand without pairs we not should go for honitsu, + # because it is far away from tempai + tiles = self._string_to_136_array(sou='12358', man='238', honors='12345') + player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(), False) + + # with chitoitsu-like hand we don't need to go for honitsu + tiles = self._string_to_136_array(pin='77', man='3355677899', sou='11') + player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(), False) + + def test_suitable_tiles(self): + table = Table() + player = Player(0, 0, table) + strategy = HonitsuStrategy(BaseStrategy.HONITSU, player) + + tiles = self._string_to_136_array(sou='12355', man='238', honors='11234') + player.init_hand(tiles) + self.assertEqual(strategy.should_activate_strategy(), True) + + tile = self._string_to_136_tile(man='1') + self.assertEqual(strategy.is_tile_suitable(tile), False) + + tile = self._string_to_136_tile(pin='1') + self.assertEqual(strategy.is_tile_suitable(tile), False) + + tile = self._string_to_136_tile(sou='1') + self.assertEqual(strategy.is_tile_suitable(tile), True) + + tile = self._string_to_136_tile(honors='1') + self.assertEqual(strategy.is_tile_suitable(tile), True) + + def test_open_hand_and_discard_tiles_logic(self): + table = Table() + player = Player(0, 0, table) + + tiles = self._string_to_136_array(sou='112235589', man='24', honors='22') + player.init_hand(tiles) + + # we don't need to call meld even if it improves our hand, + # because we are collecting honitsu + tile = self._string_to_136_tile(man='1') + meld, _, _ = player.try_to_call_meld(tile, False) + self.assertEqual(meld, None) + + # any honor tile is suitable + tile = self._string_to_136_tile(honors='2') + meld, _, _ = player.try_to_call_meld(tile, False) + self.assertNotEqual(meld, None) + + tile = self._string_to_136_tile(man='1') + player.draw_tile(tile) + tile_to_discard = player.discard_tile() + + # we are in honitsu mode, so we should discard man suits + self.assertEqual(self._to_string([tile_to_discard]), '1m') + + def test_riichi_and_tiles_from_another_suit_in_the_hand(self): + table = Table() + player = 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') + + 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') + + 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') + + 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): + + 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_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) + 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) + 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, False) + self.assertEqual(meld, None) + + # with 3 shanten we can open a hand + tiles = self._string_to_136_array(man='236', pin='378', sou='3488', honors='123') + tile = self._string_to_136_tile(sou='2') + player.init_hand(tiles) + meld, _, _ = player.try_to_call_meld(tile, True) + self.assertNotEqual(meld, None) + + def test_dont_open_hand_with_not_suitable_melds(self): + table = 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, 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') + + 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) + + 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) + + 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') + + 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') diff --git a/project/mahjong/client.py b/project/mahjong/client.py index d4b1af61..c631737f 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,24 +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 call_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 - self.table.count_of_remaining_tiles += 1 - - return self.table.get_player(meld.who).add_meld(meld) - - def enemy_discard(self, player_seat, tile): - 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 discard_tile(self, tile=None): + return self.player.discard_tile(tile) def enemy_riichi(self, player_seat): self.table.get_player(player_seat).in_riichi = True 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', diff --git a/project/mahjong/hand.py b/project/mahjong/hand.py index a84c4d18..f935c47c 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 @@ -51,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 @@ -63,12 +64,6 @@ def estimate_hand_value(self, """ if not open_sets: open_sets = [] - else: - # 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: @@ -78,6 +73,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] @@ -118,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() @@ -339,28 +336,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 +356,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 = { @@ -411,6 +415,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'] @@ -1251,14 +1265,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: @@ -1314,13 +1328,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 @@ -1330,12 +1344,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 +1413,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/meld.py b/project/mahjong/meld.py index f29c3172..0e0631f1 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): @@ -12,9 +13,10 @@ class Meld(object): tiles = [] type = None from_who = None + called_tile = 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 d5fd2b8e..e6c18867 100644 --- a/project/mahjong/player.py +++ b/project/mahjong/player.py @@ -1,10 +1,13 @@ # -*- coding: utf-8 -*- import logging +from functools import reduce + +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 -from mahjong.tile import Tile logger = logging.getLogger('tenhou') @@ -29,10 +32,16 @@ class Player(object): tiles = [] melds = [] table = None + last_draw = None in_tempai = False 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 = [] @@ -72,45 +81,76 @@ 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.last_draw = None + self.in_tempai = False + self.in_riichi = False + self.in_defence_mode = False + + self.dealer_seat = 0 + + self.ai.erase_state() + + self._is_daburi = False + self._is_ippatsu = False + + 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) - def discard_tile(self): - tile_to_discard = self.ai.discard_tile() + self.ai.determine_strategy() + + 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: + """ + # 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) 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 + return tile_to_discard 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 ]) + 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): position = self.dealer_seat @@ -126,3 +166,41 @@ 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 + + @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] + + @property + def meld_tiles(self): + """ + 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 + + 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/mahjong/table.py b/project/mahjong/table.py index 37dd2b25..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 @@ -17,9 +18,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 +41,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 +58,30 @@ 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_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 chankan we already added 3 tiles + if meld.type == Meld.CHAKAN: + tiles = tiles[0] + + 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 +115,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 +151,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 f5be9057..f9e80be3 100644 --- a/project/mahjong/tests/tests_client.py +++ b/project/mahjong/tests/tests_client.py @@ -39,11 +39,9 @@ def test_call_meld(self): self.assertEqual(client.table.count_of_remaining_tiles, 70) meld = Meld() - meld.who = 3 + client.table.add_called_meld(meld, 0) - client.call_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): @@ -52,7 +50,7 @@ def test_enemy_discard(self): self.assertEqual(client.table.count_of_remaining_tiles, 70) - client.enemy_discard(1, 10) + 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) @@ -62,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(1, 10) + client.table.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..608f764d 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() @@ -68,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() @@ -82,3 +100,18 @@ 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 = [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/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/tests/tests_yaku_calculation.py b/project/mahjong/tests/tests_yaku_calculation.py index b4f63924..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) @@ -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_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) + 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/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')] diff --git a/project/mahjong/tile.py b/project/mahjong/tile.py index 9cbb4a2a..21cccdf8 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 @@ -53,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): """ @@ -61,13 +70,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 @@ -98,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 diff --git a/project/mahjong/utils.py b/project/mahjong/utils.py index f7b2b80b..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 @@ -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 @@ -116,9 +116,36 @@ 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 :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 diff --git a/project/mahjong/yaku.py b/project/mahjong/yaku.py index 90a90909..a17b79a3 100644 --- a/project/mahjong/yaku.py +++ b/project/mahjong/yaku.py @@ -1,16 +1,18 @@ 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 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 @@ -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) diff --git a/project/process.py b/project/process.py new file mode 100644 index 00000000..910cc1c3 --- /dev/null +++ b/project/process.py @@ -0,0 +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(): + 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() 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 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' diff --git a/project/tenhou/client.py b/project/tenhou/client.py index fc8ee173..664a576b 100644 --- a/project/tenhou/client.py +++ b/project/tenhou/client.py @@ -26,12 +26,14 @@ class TenhouClient(Client): decoder = TenhouDecoder() + _count_of_empty_messages = 0 + def __init__(self, socket_object): super(TenhouClient, self).__init__() 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 +42,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 +69,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() @@ -90,10 +92,9 @@ def start_game(self): messages = self._get_multiple_messages() for message in messages: - if ''.format(game_type)) + self._send_message(''.format(game_type)) if '') @@ -102,7 +103,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) + + self.player.tiles.append(meld_tile) + self.player.ai.previous_shanten = shanten + + self.table.add_called_meld(meld, meld.who) - # 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('') + 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) + 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) @@ -245,7 +298,12 @@ 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())) # we need to finish the game, and only after this try to send statistics # if order will be different, tenhou will return 404 on log download endpoint @@ -256,9 +314,9 @@ def start_game(self): if settings.STAT_SERVER_URL: sleep(60) result = self.statistics.send_statistics() - logger.info('Statistics sent: {0}'.format(result)) + logger.info('Statistics sent: {}'.format(result)) - def end_game(self): + def end_game(self, success=True): self.game_is_continue = False self._send_message('') @@ -268,17 +326,20 @@ 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 required the empty byte in the end of each sending message - logger.debug('Send: {0}'.format(message)) + # tenhou requires an empty byte in the end of each sending message + logger.debug('Send: {}'.format(message)) message += '\0' self.socket.sendall(message.encode()) def _read_message(self): message = self.socket.recv(1024) - logger.debug('Get: {0}'.format(message.decode('utf-8').replace('\x00', ' '))) + logger.debug('Get: {}'.format(message.decode('utf-8').replace('\x00', ' '))) message = message.decode('utf-8') # sometimes tenhou send messages in lower case, sometime in upper case, let's unify the behaviour diff --git a/project/tenhou/decoder.py b/project/tenhou/decoder.py index b53ab8c3..03d1a25d 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): @@ -156,30 +155,37 @@ 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 = [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)] + 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 = [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] + 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 = [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] + 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 - 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..55242626 100644 --- a/project/utils/logger.py +++ b/project/utils/logger.py @@ -10,17 +10,17 @@ 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' + 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) @@ -30,3 +30,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/utils/tests.py b/project/utils/tests.py index 21de7c91..111aa17d 100644 --- a/project/utils/tests.py +++ b/project/utils/tests.py @@ -1,4 +1,5 @@ from mahjong.hand import HandDivider +from mahjong.meld import Meld from mahjong.tile import TilesConverter @@ -28,6 +29,16 @@ 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] + + def _make_meld(self, meld_type, tiles): + meld = Meld() + meld.who = 0 + meld.type = meld_type + meld.tiles = tiles + return meld