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