├── trainer ├── Dockerfile ├── __init__.py ├── config.yaml ├── config.py └── tuner.py ├── logs_parser ├── __init__.py ├── test.py ├── main.py └── parser_io.py ├── tenhou_env ├── __init__.py ├── project │ ├── __init__.py │ ├── game │ │ ├── __init__.py │ │ ├── ai │ │ │ ├── __init__.py │ │ │ ├── configs │ │ │ │ ├── __init__.py │ │ │ │ ├── bot8.py │ │ │ │ ├── bot9.py │ │ │ │ ├── bot11.py │ │ │ │ ├── bot5.py │ │ │ │ ├── bot6.py │ │ │ │ ├── bot7.py │ │ │ │ ├── bot_kaavi.py │ │ │ │ ├── bot_xenia.py │ │ │ │ ├── bot10.py │ │ │ │ ├── bot12.py │ │ │ │ ├── bot_ichihime.py │ │ │ │ ├── bot_wanjirou.py │ │ │ │ └── default.py │ │ │ ├── defence │ │ │ │ ├── __init__.py │ │ │ │ ├── tests │ │ │ │ │ └── __init__.py │ │ │ │ └── yaku_analyzer │ │ │ │ │ ├── yaku_analyzer.py │ │ │ │ │ ├── yakuhai.py │ │ │ │ │ ├── tanyao.py │ │ │ │ │ ├── atodzuke.py │ │ │ │ │ ├── honitsu_analyzer_base.py │ │ │ │ │ ├── toitoi.py │ │ │ │ │ ├── chinitsu.py │ │ │ │ │ └── honitsu.py │ │ │ ├── helpers │ │ │ │ ├── __init__.py │ │ │ │ ├── suji.py │ │ │ │ └── kabe.py │ │ │ ├── tests │ │ │ │ └── __init__.py │ │ │ ├── trainer │ │ │ │ ├── __init__.py │ │ │ │ ├── config.yaml │ │ │ │ ├── config.py │ │ │ │ └── tuner.py │ │ │ ├── strategies │ │ │ │ ├── __init__.py │ │ │ │ ├── tests │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── test_chiitoitsu.py │ │ │ │ │ ├── test_common_open_tempai.py │ │ │ │ │ └── test_formal_tempai.py │ │ │ │ ├── formal_tempai.py │ │ │ │ └── common_open_tempai.py │ │ │ ├── utils.py │ │ │ ├── exp_buffer.py │ │ │ ├── server.py │ │ │ └── statistics_collector.py │ │ ├── tests │ │ │ ├── __init__.py │ │ │ ├── test_client.py │ │ │ └── test_player.py │ │ ├── bots_battle │ │ │ ├── __init__.py │ │ │ ├── replays │ │ │ │ ├── __init__.py │ │ │ │ ├── base.py │ │ │ │ └── test_tenhou_encoder.py │ │ │ ├── battle_config.py │ │ │ └── local_client.py │ │ └── client.py │ ├── tenhou │ │ ├── __init__.py │ │ └── main.py │ ├── utils │ │ ├── __init__.py │ │ ├── decisions_constants.py │ │ ├── settings_handler.py │ │ ├── statistics.py │ │ ├── cache.py │ │ ├── test_helpers.py │ │ ├── decisions_logger.py │ │ ├── logger.py │ │ └── general.py │ ├── settings │ │ ├── __init__.py │ │ └── base.py │ ├── statistics │ │ ├── __init__.py │ │ ├── cases │ │ │ ├── __init__.py │ │ │ ├── main.py │ │ │ └── agari_riichi_cost.py │ │ ├── db.py │ │ ├── merge_csv_files.py │ │ ├── log_parser.py │ │ └── calculate_error_rate.py │ ├── system_testing │ │ ├── __init__.py │ │ ├── fixtures │ │ │ ├── 1.jpg │ │ │ ├── 2.jpg │ │ │ ├── 3.jpg │ │ │ ├── 4.jpg │ │ │ ├── 5.jpg │ │ │ ├── 6.jpg │ │ │ ├── 7.jpg │ │ │ ├── 8.jpg │ │ │ ├── 9.jpg │ │ │ ├── 10.jpg │ │ │ ├── 11.jpg │ │ │ ├── 12.jpg │ │ │ ├── 13.jpg │ │ │ ├── 14.jpg │ │ │ ├── 15.jpg │ │ │ ├── 16.jpg │ │ │ ├── 17.jpg │ │ │ ├── 18.jpg │ │ │ ├── 19.jpg │ │ │ ├── 20.jpg │ │ │ ├── 26.jpg │ │ │ ├── 28.jpg │ │ │ ├── 30.jpg │ │ │ ├── 31.jpg │ │ │ ├── 32.jpg │ │ │ ├── 33.jpg │ │ │ ├── 34.jpg │ │ │ ├── 35.jpg │ │ │ ├── 36.jpg │ │ │ ├── 37.jpg │ │ │ ├── 38.jpg │ │ │ ├── 39.jpg │ │ │ ├── 40.jpg │ │ │ ├── 23.txt │ │ │ └── 25.txt │ │ ├── generate_documentation.py │ │ └── generate_tests.py │ ├── system.py │ ├── run_stat.py │ ├── options.py │ ├── actor_learner.py │ ├── main.py │ └── bots_battle.py ├── pytest.ini ├── requirements │ ├── lint.txt │ ├── dev.txt │ └── base.txt ├── setup.py ├── .editorconfig ├── .flake8 ├── CODE_OF_CONDUCT.md ├── Dockerfile ├── pyproject.toml ├── .gitignore ├── .coveragerc ├── LICENSE.txt ├── Makefile ├── docker-compose.yml ├── .github │ └── workflows │ │ └── pythonapp.yml ├── README.md └── doc │ ├── reproducer.md │ └── versions.md ├── extract_features ├── __init__.py └── FeatureGenerator.ipynb ├── logs_crawler ├── __init__.py ├── main.py ├── README.md ├── debug.py └── download_logs_content.py ├── __init__.py ├── RL ├── environment.py ├── reinforcement_learning.py └── policy_gradient.py ├── Dockerfile ├── setup.py ├── docker-compose.yml ├── README.md ├── requirements.txt ├── .gitignore └── run_cloud.sh /trainer/Dockerfile: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /logs_parser/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tenhou_env/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /trainer/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /extract_features/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /logs_crawler/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tenhou_env/project/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tenhou_env/project/game/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tenhou_env/project/game/ai/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tenhou_env/project/tenhou/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tenhou_env/project/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tenhou_env/project/game/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tenhou_env/project/settings/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tenhou_env/project/statistics/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tenhou_env/project/game/ai/configs/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tenhou_env/project/game/ai/defence/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tenhou_env/project/game/ai/helpers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tenhou_env/project/game/ai/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tenhou_env/project/game/ai/trainer/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tenhou_env/project/game/bots_battle/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tenhou_env/project/statistics/cases/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tenhou_env/project/system_testing/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tenhou_env/project/game/ai/defence/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tenhou_env/project/game/ai/strategies/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | -------------------------------------------------------------------------------- /tenhou_env/project/game/ai/strategies/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tenhou_env/project/game/bots_battle/replays/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | -------------------------------------------------------------------------------- /RL/environment.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | class LearningEnv: 4 | def __init__(self) -> None: 5 | pass 6 | -------------------------------------------------------------------------------- /tenhou_env/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | python_files = test_*.py 3 | log_format = %(asctime)s %(levelname)s %(message)s 4 | log_date_format = . 5 | -------------------------------------------------------------------------------- /tenhou_env/project/system_testing/fixtures/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/USC-CSCI527-Spring2021/Phoenix/HEAD/tenhou_env/project/system_testing/fixtures/1.jpg -------------------------------------------------------------------------------- /tenhou_env/project/system_testing/fixtures/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/USC-CSCI527-Spring2021/Phoenix/HEAD/tenhou_env/project/system_testing/fixtures/2.jpg -------------------------------------------------------------------------------- /tenhou_env/project/system_testing/fixtures/3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/USC-CSCI527-Spring2021/Phoenix/HEAD/tenhou_env/project/system_testing/fixtures/3.jpg -------------------------------------------------------------------------------- /tenhou_env/project/system_testing/fixtures/4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/USC-CSCI527-Spring2021/Phoenix/HEAD/tenhou_env/project/system_testing/fixtures/4.jpg -------------------------------------------------------------------------------- /tenhou_env/project/system_testing/fixtures/5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/USC-CSCI527-Spring2021/Phoenix/HEAD/tenhou_env/project/system_testing/fixtures/5.jpg -------------------------------------------------------------------------------- /tenhou_env/project/system_testing/fixtures/6.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/USC-CSCI527-Spring2021/Phoenix/HEAD/tenhou_env/project/system_testing/fixtures/6.jpg -------------------------------------------------------------------------------- /tenhou_env/project/system_testing/fixtures/7.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/USC-CSCI527-Spring2021/Phoenix/HEAD/tenhou_env/project/system_testing/fixtures/7.jpg -------------------------------------------------------------------------------- /tenhou_env/project/system_testing/fixtures/8.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/USC-CSCI527-Spring2021/Phoenix/HEAD/tenhou_env/project/system_testing/fixtures/8.jpg -------------------------------------------------------------------------------- /tenhou_env/project/system_testing/fixtures/9.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/USC-CSCI527-Spring2021/Phoenix/HEAD/tenhou_env/project/system_testing/fixtures/9.jpg -------------------------------------------------------------------------------- /tenhou_env/project/system_testing/fixtures/10.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/USC-CSCI527-Spring2021/Phoenix/HEAD/tenhou_env/project/system_testing/fixtures/10.jpg -------------------------------------------------------------------------------- /tenhou_env/project/system_testing/fixtures/11.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/USC-CSCI527-Spring2021/Phoenix/HEAD/tenhou_env/project/system_testing/fixtures/11.jpg -------------------------------------------------------------------------------- /tenhou_env/project/system_testing/fixtures/12.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/USC-CSCI527-Spring2021/Phoenix/HEAD/tenhou_env/project/system_testing/fixtures/12.jpg -------------------------------------------------------------------------------- /tenhou_env/project/system_testing/fixtures/13.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/USC-CSCI527-Spring2021/Phoenix/HEAD/tenhou_env/project/system_testing/fixtures/13.jpg -------------------------------------------------------------------------------- /tenhou_env/project/system_testing/fixtures/14.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/USC-CSCI527-Spring2021/Phoenix/HEAD/tenhou_env/project/system_testing/fixtures/14.jpg -------------------------------------------------------------------------------- /tenhou_env/project/system_testing/fixtures/15.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/USC-CSCI527-Spring2021/Phoenix/HEAD/tenhou_env/project/system_testing/fixtures/15.jpg -------------------------------------------------------------------------------- /tenhou_env/project/system_testing/fixtures/16.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/USC-CSCI527-Spring2021/Phoenix/HEAD/tenhou_env/project/system_testing/fixtures/16.jpg -------------------------------------------------------------------------------- /tenhou_env/project/system_testing/fixtures/17.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/USC-CSCI527-Spring2021/Phoenix/HEAD/tenhou_env/project/system_testing/fixtures/17.jpg -------------------------------------------------------------------------------- /tenhou_env/project/system_testing/fixtures/18.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/USC-CSCI527-Spring2021/Phoenix/HEAD/tenhou_env/project/system_testing/fixtures/18.jpg -------------------------------------------------------------------------------- /tenhou_env/project/system_testing/fixtures/19.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/USC-CSCI527-Spring2021/Phoenix/HEAD/tenhou_env/project/system_testing/fixtures/19.jpg -------------------------------------------------------------------------------- /tenhou_env/project/system_testing/fixtures/20.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/USC-CSCI527-Spring2021/Phoenix/HEAD/tenhou_env/project/system_testing/fixtures/20.jpg -------------------------------------------------------------------------------- /tenhou_env/project/system_testing/fixtures/26.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/USC-CSCI527-Spring2021/Phoenix/HEAD/tenhou_env/project/system_testing/fixtures/26.jpg -------------------------------------------------------------------------------- /tenhou_env/project/system_testing/fixtures/28.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/USC-CSCI527-Spring2021/Phoenix/HEAD/tenhou_env/project/system_testing/fixtures/28.jpg -------------------------------------------------------------------------------- /tenhou_env/project/system_testing/fixtures/30.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/USC-CSCI527-Spring2021/Phoenix/HEAD/tenhou_env/project/system_testing/fixtures/30.jpg -------------------------------------------------------------------------------- /tenhou_env/project/system_testing/fixtures/31.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/USC-CSCI527-Spring2021/Phoenix/HEAD/tenhou_env/project/system_testing/fixtures/31.jpg -------------------------------------------------------------------------------- /tenhou_env/project/system_testing/fixtures/32.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/USC-CSCI527-Spring2021/Phoenix/HEAD/tenhou_env/project/system_testing/fixtures/32.jpg -------------------------------------------------------------------------------- /tenhou_env/project/system_testing/fixtures/33.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/USC-CSCI527-Spring2021/Phoenix/HEAD/tenhou_env/project/system_testing/fixtures/33.jpg -------------------------------------------------------------------------------- /tenhou_env/project/system_testing/fixtures/34.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/USC-CSCI527-Spring2021/Phoenix/HEAD/tenhou_env/project/system_testing/fixtures/34.jpg -------------------------------------------------------------------------------- /tenhou_env/project/system_testing/fixtures/35.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/USC-CSCI527-Spring2021/Phoenix/HEAD/tenhou_env/project/system_testing/fixtures/35.jpg -------------------------------------------------------------------------------- /tenhou_env/project/system_testing/fixtures/36.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/USC-CSCI527-Spring2021/Phoenix/HEAD/tenhou_env/project/system_testing/fixtures/36.jpg -------------------------------------------------------------------------------- /tenhou_env/project/system_testing/fixtures/37.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/USC-CSCI527-Spring2021/Phoenix/HEAD/tenhou_env/project/system_testing/fixtures/37.jpg -------------------------------------------------------------------------------- /tenhou_env/project/system_testing/fixtures/38.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/USC-CSCI527-Spring2021/Phoenix/HEAD/tenhou_env/project/system_testing/fixtures/38.jpg -------------------------------------------------------------------------------- /tenhou_env/project/system_testing/fixtures/39.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/USC-CSCI527-Spring2021/Phoenix/HEAD/tenhou_env/project/system_testing/fixtures/39.jpg -------------------------------------------------------------------------------- /tenhou_env/project/system_testing/fixtures/40.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/USC-CSCI527-Spring2021/Phoenix/HEAD/tenhou_env/project/system_testing/fixtures/40.jpg -------------------------------------------------------------------------------- /tenhou_env/requirements/lint.txt: -------------------------------------------------------------------------------- 1 | -r ./dev.txt 2 | 3 | # for code formatting and linting 4 | black==20.8b1 5 | isort==5.6.4 6 | flake8==3.8.4 7 | flake8-bugbear==20.1.4 8 | -------------------------------------------------------------------------------- /tenhou_env/requirements/dev.txt: -------------------------------------------------------------------------------- 1 | -r ./base.txt 2 | 3 | # for bots battle 4 | tqdm==4.51.0 5 | 6 | # for unit tests 7 | pytest==6.1.2 8 | pytest-xdist==2.1.0 9 | pytest-cov==2.10.1 10 | -------------------------------------------------------------------------------- /tenhou_env/setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup(name='tenhou-mahjong', 4 | version='0.0.1', 5 | install_requires=['gym'] # And any other dependencies foo needs 6 | ) 7 | -------------------------------------------------------------------------------- /tenhou_env/.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*.py] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 4 8 | end_of_line = lf 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /tenhou_env/requirements/base.txt: -------------------------------------------------------------------------------- 1 | # our core library 2 | mahjong==1.2.0.dev5 3 | 4 | # to send information about games to statistics server 5 | requests==2.24.0 6 | 7 | # to capture crash logs 8 | sentry-sdk==0.19.2 9 | -------------------------------------------------------------------------------- /tenhou_env/.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E203, E266, E501, W503 3 | max-line-length = 120 4 | select = B,C,E,F,W,T4,B9 5 | exclude = project/settings/*,tests_validate_hand.py,project/system_testing/cases.py,project/system_testing/test_system.py -------------------------------------------------------------------------------- /tenhou_env/project/system.py: -------------------------------------------------------------------------------- 1 | from system_testing.generate_documentation import DocGen 2 | from system_testing.generate_tests import TestsGen 3 | 4 | 5 | def main(): 6 | DocGen.generate_documentation() 7 | TestsGen.generate_documentation() 8 | 9 | 10 | if __name__ == "__main__": 11 | main() 12 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM tensorflow/tensorflow:2.4.1 2 | 3 | WORKDIR / 4 | COPY extract_features /extract_features 5 | COPY logs_parser /logs_parser 6 | COPY trainer /trainer 7 | COPY requirements.txt /requirements.txt 8 | COPY setup.py /setup.py 9 | RUN pip install --no-cache-dir -r /requirements.txt 10 | 11 | ENTRYPOINT ["python","-m", "trainer.task"] 12 | -------------------------------------------------------------------------------- /tenhou_env/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Tenhou bot code of conduct: 2 | 3 | 1. A robot may not injure a human being or, through inaction, allow a human being to come to harm. 4 | 2. A robot must obey orders given it by human beings except where such orders would conflict with the First Law. 5 | 3. A robot must protect its own existence as long as such protection does not conflict with the First or Second Law. 6 | -------------------------------------------------------------------------------- /tenhou_env/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM rayproject/ray-ml:latest-cpu 2 | 3 | WORKDIR /app/ 4 | 5 | COPY requirements.txt /requirements.txt 6 | COPY config.yaml /config.yaml 7 | 8 | RUN pip install --no-cache-dir -r /requirements.txt 9 | RUN pip install google-api-python-client==1.7.8 10 | 11 | COPY trainer /trainer 12 | COPY tenhou_env/project . 13 | COPY models /models 14 | RUN export PYTHONPATH=PYTHONPATH:/ 15 | 16 | USER root -------------------------------------------------------------------------------- /tenhou_env/project/game/ai/configs/bot8.py: -------------------------------------------------------------------------------- 1 | from game.ai.configs.default import BotDefaultConfig 2 | from game.ai.placement import PlacementHandler 3 | 4 | 5 | class MaiConfig(BotDefaultConfig): 6 | name = "Mai" 7 | 8 | PLACEMENT_HANDLER_CLASS = PlacementHandler 9 | 10 | TUNE_DANGER_BORDER_TEMPAI_VALUE = 3 11 | TUNE_DANGER_BORDER_1_SHANTEN_VALUE = 0 12 | TUNE_DANGER_BORDER_2_SHANTEN_VALUE = 0 13 | -------------------------------------------------------------------------------- /tenhou_env/project/game/ai/configs/bot9.py: -------------------------------------------------------------------------------- 1 | from game.ai.configs.default import BotDefaultConfig 2 | from game.ai.placement import PlacementHandler 3 | 4 | 5 | class YuiConfig(BotDefaultConfig): 6 | name = "Yui" 7 | 8 | PLACEMENT_HANDLER_CLASS = PlacementHandler 9 | 10 | TUNE_DANGER_BORDER_TEMPAI_VALUE = 3 11 | TUNE_DANGER_BORDER_1_SHANTEN_VALUE = 1 12 | TUNE_DANGER_BORDER_2_SHANTEN_VALUE = 1 13 | -------------------------------------------------------------------------------- /tenhou_env/project/game/ai/configs/bot11.py: -------------------------------------------------------------------------------- 1 | from game.ai.configs.default import BotDefaultConfig 2 | from game.ai.placement import PlacementHandler 3 | 4 | 5 | class RiuConfig(BotDefaultConfig): 6 | name = "Riu" 7 | 8 | PLACEMENT_HANDLER_CLASS = PlacementHandler 9 | 10 | TUNE_DANGER_BORDER_TEMPAI_VALUE = 0 11 | TUNE_DANGER_BORDER_1_SHANTEN_VALUE = -1 12 | TUNE_DANGER_BORDER_2_SHANTEN_VALUE = -1 13 | -------------------------------------------------------------------------------- /tenhou_env/project/game/ai/configs/bot5.py: -------------------------------------------------------------------------------- 1 | from game.ai.configs.default import BotDefaultConfig 2 | from game.ai.placement import PlacementHandler 3 | 4 | 5 | class MikiConfig(BotDefaultConfig): 6 | name = "Miki" 7 | 8 | PLACEMENT_HANDLER_CLASS = PlacementHandler 9 | 10 | TUNE_DANGER_BORDER_TEMPAI_VALUE = 1 11 | TUNE_DANGER_BORDER_1_SHANTEN_VALUE = 0 12 | TUNE_DANGER_BORDER_2_SHANTEN_VALUE = 0 13 | -------------------------------------------------------------------------------- /tenhou_env/project/game/ai/configs/bot6.py: -------------------------------------------------------------------------------- 1 | from game.ai.configs.default import BotDefaultConfig 2 | from game.ai.placement import PlacementHandler 3 | 4 | 5 | class ChioriConfig(BotDefaultConfig): 6 | name = "Chiori" 7 | 8 | PLACEMENT_HANDLER_CLASS = PlacementHandler 9 | 10 | TUNE_DANGER_BORDER_TEMPAI_VALUE = 1 11 | TUNE_DANGER_BORDER_1_SHANTEN_VALUE = 1 12 | TUNE_DANGER_BORDER_2_SHANTEN_VALUE = 1 13 | -------------------------------------------------------------------------------- /tenhou_env/project/game/ai/configs/bot7.py: -------------------------------------------------------------------------------- 1 | from game.ai.configs.default import BotDefaultConfig 2 | from game.ai.placement import PlacementHandler 3 | 4 | 5 | class KanaConfig(BotDefaultConfig): 6 | name = "Kana" 7 | 8 | PLACEMENT_HANDLER_CLASS = PlacementHandler 9 | 10 | TUNE_DANGER_BORDER_TEMPAI_VALUE = 1 11 | TUNE_DANGER_BORDER_1_SHANTEN_VALUE = -1 12 | TUNE_DANGER_BORDER_2_SHANTEN_VALUE = -1 13 | -------------------------------------------------------------------------------- /tenhou_env/project/game/ai/configs/bot_kaavi.py: -------------------------------------------------------------------------------- 1 | from game.ai.configs.default import BotDefaultConfig 2 | from game.ai.placement import PlacementHandler 3 | 4 | 5 | class KaaviConfig(BotDefaultConfig): 6 | name = "Kaavi" 7 | 8 | PLACEMENT_HANDLER_CLASS = PlacementHandler 9 | 10 | TUNE_DANGER_BORDER_TEMPAI_VALUE = 2 11 | TUNE_DANGER_BORDER_1_SHANTEN_VALUE = 0 12 | TUNE_DANGER_BORDER_2_SHANTEN_VALUE = 0 13 | -------------------------------------------------------------------------------- /tenhou_env/project/game/ai/configs/bot_xenia.py: -------------------------------------------------------------------------------- 1 | from game.ai.configs.default import BotDefaultConfig 2 | from game.ai.placement import PlacementHandler 3 | 4 | 5 | class XeniaConfig(BotDefaultConfig): 6 | name = "Xenia" 7 | 8 | PLACEMENT_HANDLER_CLASS = PlacementHandler 9 | 10 | TUNE_DANGER_BORDER_TEMPAI_VALUE = 2 11 | TUNE_DANGER_BORDER_1_SHANTEN_VALUE = -1 12 | TUNE_DANGER_BORDER_2_SHANTEN_VALUE = -1 13 | -------------------------------------------------------------------------------- /tenhou_env/project/game/ai/configs/bot10.py: -------------------------------------------------------------------------------- 1 | from game.ai.configs.default import BotDefaultConfig 2 | from game.ai.placement import PlacementHandler 3 | 4 | 5 | class NadeshikoConfig(BotDefaultConfig): 6 | name = "Nadeshiko" 7 | 8 | PLACEMENT_HANDLER_CLASS = PlacementHandler 9 | 10 | TUNE_DANGER_BORDER_TEMPAI_VALUE = 3 11 | TUNE_DANGER_BORDER_1_SHANTEN_VALUE = -1 12 | TUNE_DANGER_BORDER_2_SHANTEN_VALUE = -1 13 | -------------------------------------------------------------------------------- /tenhou_env/project/game/ai/configs/bot12.py: -------------------------------------------------------------------------------- 1 | from game.ai.configs.default import BotDefaultConfig 2 | from game.ai.placement import PlacementHandler 3 | 4 | 5 | class KeikumusumeConfig(BotDefaultConfig): 6 | name = "Keikumusume" 7 | 8 | PLACEMENT_HANDLER_CLASS = PlacementHandler 9 | 10 | TUNE_DANGER_BORDER_TEMPAI_VALUE = -1 11 | TUNE_DANGER_BORDER_1_SHANTEN_VALUE = -2 12 | TUNE_DANGER_BORDER_2_SHANTEN_VALUE = -2 13 | -------------------------------------------------------------------------------- /tenhou_env/project/game/ai/configs/bot_ichihime.py: -------------------------------------------------------------------------------- 1 | from game.ai.configs.default import BotDefaultConfig 2 | from game.ai.placement import PlacementHandler 3 | 4 | 5 | class IchihimeConfig(BotDefaultConfig): 6 | name = "Ichihime" 7 | 8 | PLACEMENT_HANDLER_CLASS = PlacementHandler 9 | 10 | TUNE_DANGER_BORDER_TEMPAI_VALUE = 0 11 | TUNE_DANGER_BORDER_1_SHANTEN_VALUE = 0 12 | TUNE_DANGER_BORDER_2_SHANTEN_VALUE = 0 13 | -------------------------------------------------------------------------------- /tenhou_env/project/game/ai/configs/bot_wanjirou.py: -------------------------------------------------------------------------------- 1 | from game.ai.configs.default import BotDefaultConfig 2 | from game.ai.placement import PlacementHandler 3 | 4 | 5 | class WanjirouConfig(BotDefaultConfig): 6 | name = "Wanjirou" 7 | 8 | PLACEMENT_HANDLER_CLASS = PlacementHandler 9 | 10 | TUNE_DANGER_BORDER_TEMPAI_VALUE = 2 11 | TUNE_DANGER_BORDER_1_SHANTEN_VALUE = 1 12 | TUNE_DANGER_BORDER_2_SHANTEN_VALUE = 1 13 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name='Phoenix', 5 | version='0.0.1', 6 | packages=find_packages(), 7 | url='', 8 | license='', 9 | author='Phoenix', 10 | author_email='', 11 | description='', 12 | include_package_data=True, 13 | install_requires=[ 14 | 'mahjong==1.2.0.dev5', 15 | 'apache-beam==2.28.0', 16 | ], 17 | scripts=['pipeline.py'] 18 | 19 | ) 20 | -------------------------------------------------------------------------------- /logs_parser/test.py: -------------------------------------------------------------------------------- 1 | import tensorflow as tf 2 | 3 | 4 | def read_tfrecord(serialized_example): 5 | discard_feature_spec = { 6 | "features": tf.io.FixedLenFeature((13, 34, 1), tf.int64), 7 | "labels": tf.io.FixedLenFeature([], tf.int64), 8 | } 9 | 10 | example = tf.io.parse_single_example(serialized_example, discard_feature_spec) 11 | 12 | feature0 = example['features'] 13 | feature1 = example['labels'] 14 | 15 | return feature0, feature1 16 | -------------------------------------------------------------------------------- /tenhou_env/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 120 3 | target-version = ['py38'] 4 | exclude = ''' 5 | /( 6 | \.eggs 7 | | \.git 8 | | \.hg 9 | | \.mypy_cache 10 | | \.tox 11 | | \.venv 12 | | _build 13 | | buck-out 14 | | build 15 | | dist 16 | 17 | # Project related excludes 18 | | migrations 19 | )/ 20 | ''' 21 | 22 | [tool.isort] 23 | force_grid_wrap = 0 24 | include_trailing_comma = true 25 | line_length = 120 26 | multi_line_output = 3 27 | use_parentheses = true 28 | skip_glob = "migrations" 29 | -------------------------------------------------------------------------------- /tenhou_env/project/game/ai/configs/default.py: -------------------------------------------------------------------------------- 1 | from game.ai.placement import PlacementHandler 2 | 3 | 4 | class BotDefaultConfig: 5 | # all features that we are testing should starts with FEATURE_ prefix 6 | # with that it will be easier to track these flags usage over the code 7 | FEATURE_DEFENCE_ENABLED = True 8 | 9 | PLACEMENT_HANDLER_CLASS = PlacementHandler 10 | 11 | TUNE_DANGER_BORDER_TEMPAI_VALUE = 0 12 | TUNE_DANGER_BORDER_1_SHANTEN_VALUE = 0 13 | TUNE_DANGER_BORDER_2_SHANTEN_VALUE = 0 14 | 15 | # TODO move all separate configs as subclasses here 16 | -------------------------------------------------------------------------------- /tenhou_env/project/game/client.py: -------------------------------------------------------------------------------- 1 | from game.table import Table 2 | 3 | 4 | class Client: 5 | table = None 6 | 7 | def __init__(self, bot_config=None): 8 | self.table = Table(bot_config) 9 | 10 | def connect(self): 11 | raise NotImplementedError() 12 | 13 | def authenticate(self): 14 | raise NotImplementedError() 15 | 16 | def start_game(self): 17 | raise NotImplementedError() 18 | 19 | def end_game(self): 20 | raise NotImplementedError() 21 | 22 | @property 23 | def player(self): 24 | return self.table.player 25 | -------------------------------------------------------------------------------- /tenhou_env/.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | env 3 | 4 | failed_*.txt 5 | 6 | *.py[cod] 7 | __pycache__ 8 | .DS_Store 9 | .pytest_cache 10 | logs 11 | project/settings/* 12 | !project/settings/__init__.py 13 | !project/settings/base.py 14 | project/game/ai/common 15 | project/statistics/output 16 | project/statistics/db 17 | 18 | test_validate_hand.py 19 | loader.py 20 | *.db 21 | temp 22 | *.log 23 | seeds.txt 24 | 25 | project/game/data/* 26 | project/analytics/data/* 27 | 28 | project/battle_results/**/* 29 | *.tar.gz 30 | 31 | # temporary files 32 | experiments 33 | 34 | *.prof 35 | profile.py 36 | 37 | .coverage 38 | htmlcov -------------------------------------------------------------------------------- /tenhou_env/.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | env/* 4 | project/bots_battle.py 5 | project/game/ai/configs/* 6 | project/game/bots_battle/* 7 | project/settings/* 8 | project/statistics/* 9 | */test_*.py 10 | */__init__.py 11 | project/reproducer.py 12 | project/main.py 13 | project/tenhou/* 14 | project/utils/statistics.py 15 | project/utils/logger.py 16 | project/conftest.py 17 | project/utils/settings_handler.py 18 | project/game/client.py 19 | project/system_testing/generate_tests.py 20 | project/system_testing/generate_documentation.py 21 | project/system_testing/cases.py 22 | project/system.py -------------------------------------------------------------------------------- /tenhou_env/project/game/ai/trainer/config.yaml: -------------------------------------------------------------------------------- 1 | trainingInput: 2 | scaleTier: CUSTOM 3 | # network: projects/538372709834/global/networks/default 4 | # Configure a master worker with 4 T4 GPUs 5 | masterType: n1-highmem-8 6 | masterConfig: 7 | acceleratorConfig: 8 | count: 4 9 | type: NVIDIA_TESLA_T4 10 | # Configure 2 workers, each with 4 T4 GPUs 11 | # workerCount: 2 12 | # workerType: n1-standard-4 13 | # workerConfig: 14 | # acceleratorConfig: 15 | # count: 4 16 | # type: NVIDIA_TESLA_K80 17 | # Configure 3 parameter servers with no GPUs 18 | parameterServerCount: 1 19 | parameterServerType: n1-highmem-8 20 | -------------------------------------------------------------------------------- /trainer/config.yaml: -------------------------------------------------------------------------------- 1 | trainingInput: 2 | runtimeVersion: "2.4" 3 | pythonVersion: "3.7" 4 | scaleTier: CUSTOM 5 | # network: projects/538372709834/global/networks/default 6 | # Configure a master worker with 4 T4 GPUs 7 | masterType: n1-highmem-8 8 | masterConfig: 9 | acceleratorConfig: 10 | count: 1 11 | type: NVIDIA_TESLA_T4 12 | # Configure 2 workers, each with 4 T4 GPUs 13 | # workerCount: 2 14 | # workerType: n1-standard-4 15 | # workerConfig: 16 | # acceleratorConfig: 17 | # count: 4 18 | # type: NVIDIA_TESLA_K80 19 | # Configure 3 parameter servers with no GPUs 20 | # parameterServerCount: 1 21 | # parameterServerType: n1-highmem-8 22 | -------------------------------------------------------------------------------- /tenhou_env/project/tenhou/main.py: -------------------------------------------------------------------------------- 1 | from tenhou.client import TenhouClient 2 | from utils.logger import set_up_logging 3 | 4 | 5 | def connect_and_play(): 6 | logger = set_up_logging() 7 | 8 | client = TenhouClient(logger) 9 | client.connect() 10 | 11 | try: 12 | was_auth = client.authenticate() 13 | 14 | if was_auth: 15 | client.start_game() 16 | else: 17 | client.end_game() 18 | except KeyboardInterrupt: 19 | logger.info("Ending the game...") 20 | client.end_game() 21 | except Exception as e: 22 | logger.exception("Unexpected exception", exc_info=e) 23 | logger.info("Ending the game...") 24 | client.end_game(False) 25 | -------------------------------------------------------------------------------- /tenhou_env/project/utils/decisions_constants.py: -------------------------------------------------------------------------------- 1 | DRAW = "draw" 2 | 3 | DISCARD_OPTIONS = "discard_options" 4 | DISCARD = "discard" 5 | DISCARD_SAFE_TILE = "discard_safe_tile" 6 | 7 | KAN_DEBUG = "kan_debug" 8 | 9 | STRATEGY_ACTIVATE = "activate_strategy" 10 | STRATEGY_DROP = "drop_strategy" 11 | 12 | INIT_HAND = "init_hand" 13 | 14 | MELD_CALL = "meld" 15 | MELD_PREPARE = "meld_prepare" 16 | MELD_HAND = "meld_hand" 17 | MELD_DEBUG = "meld_debug" 18 | 19 | RIICHI = "riichi" 20 | 21 | AGARI = "agari" 22 | 23 | PLACEMENT_MELD_DECISION = "placement_meld_decision" 24 | PLACEMENT_PUSH_DECISION = "placement_push_decision" 25 | PLACEMENT_DANGER_MODIFIER = "placement_danger_modifier" 26 | PLACEMENT_RIICHI_OR_DAMATEN = "placement_riichi_or_damaten" 27 | 28 | DEFENCE_THREATENING_ENEMY = "defence_threatening_enemy" 29 | -------------------------------------------------------------------------------- /tenhou_env/project/game/ai/defence/yaku_analyzer/yaku_analyzer.py: -------------------------------------------------------------------------------- 1 | class YakuAnalyzer: 2 | def get_safe_tiles_34(self): 3 | return [] 4 | 5 | def get_bonus_danger(self, tile_136, number_of_revealed_tiles): 6 | return [] 7 | 8 | def get_tempai_probability_modifier(self): 9 | return 1 10 | 11 | def is_absorbed(self, possible_yaku, tile_34=None): 12 | return False 13 | 14 | def _is_absorbed_by(self, possible_yaku, id, tile_34): 15 | absorbing_yaku_possible = [x for x in possible_yaku if x.id == id] 16 | if absorbing_yaku_possible: 17 | analyzer = absorbing_yaku_possible[0] 18 | if tile_34 is None: 19 | return True 20 | 21 | if not (tile_34 in analyzer.get_safe_tiles_34()): 22 | return True 23 | 24 | return False 25 | -------------------------------------------------------------------------------- /tenhou_env/project/utils/settings_handler.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | 3 | 4 | class SettingsSingleton: 5 | """ 6 | Let's load a settings in the memory one time when the app starts 7 | Than override some settings with command arguments 8 | After this we not should change the object 9 | """ 10 | 11 | instance = None 12 | 13 | def __init__(self): 14 | if not SettingsSingleton.instance: 15 | SettingsSingleton.instance = Settings() 16 | 17 | def __getattr__(self, name): 18 | return getattr(self.instance, name) 19 | 20 | def __setattr__(self, key, value): 21 | return setattr(self.instance, key, value) 22 | 23 | 24 | class Settings: 25 | def __init__(self): 26 | mod = importlib.import_module("settings.base") 27 | 28 | for setting in dir(mod): 29 | setting_value = getattr(mod, setting) 30 | setattr(self, setting, setting_value) 31 | 32 | 33 | settings = SettingsSingleton() 34 | -------------------------------------------------------------------------------- /RL/reinforcement_learning.py: -------------------------------------------------------------------------------- 1 | ## Main driver for the reinforcement learning... 2 | import numpy as np 3 | from numpy.lib.histograms import histogram 4 | from policy_gradient import Agent 5 | from environment import LearningEnv 6 | 7 | 8 | if __name__ == "main": 9 | agent = Agent(0.0005, 0.99, 4, 256, 256) 10 | 11 | env = LearningEnv() 12 | score_history = [] 13 | num_episodes = 2000 14 | 15 | for i in range(num_episodes): 16 | done = False 17 | score = 0 18 | observation = env.reset() 19 | while not done: 20 | action = agent.choose_action(observation) 21 | observation_, reward, done, info = env.step(action) 22 | agent.store_transition(observation, action, reward) 23 | observation = observation_ 24 | score = score + reward 25 | score_history.append(score) 26 | agent.learn() 27 | avg_score = np.mean(score_history[-100, :]) 28 | print("episode: ", i, "score: %.1f" % score, "average score %.1f" % avg_score) 29 | 30 | 31 | -------------------------------------------------------------------------------- /tenhou_env/project/game/bots_battle/battle_config.py: -------------------------------------------------------------------------------- 1 | from game.ai.configs.bot10 import NadeshikoConfig 2 | from game.ai.configs.bot11 import RiuConfig 3 | from game.ai.configs.bot12 import KeikumusumeConfig 4 | from game.ai.configs.bot5 import MikiConfig 5 | from game.ai.configs.bot6 import ChioriConfig 6 | from game.ai.configs.bot7 import KanaConfig 7 | from game.ai.configs.bot8 import MaiConfig 8 | from game.ai.configs.bot9 import YuiConfig 9 | from game.ai.configs.bot_ichihime import IchihimeConfig 10 | from game.ai.configs.bot_kaavi import KaaviConfig 11 | from game.ai.configs.bot_wanjirou import WanjirouConfig 12 | from game.ai.configs.bot_xenia import XeniaConfig 13 | 14 | 15 | class BattleConfig: 16 | CLIENTS_CONFIGS = [ 17 | IchihimeConfig, 18 | KaaviConfig, 19 | WanjirouConfig, 20 | XeniaConfig, 21 | MikiConfig, 22 | ChioriConfig, 23 | KanaConfig, 24 | MaiConfig, 25 | YuiConfig, 26 | NadeshikoConfig, 27 | RiuConfig, 28 | KeikumusumeConfig, 29 | ] 30 | -------------------------------------------------------------------------------- /tenhou_env/project/game/ai/defence/yaku_analyzer/yakuhai.py: -------------------------------------------------------------------------------- 1 | from game.ai.defence.yaku_analyzer.yaku_analyzer import YakuAnalyzer 2 | 3 | 4 | class YakuhaiAnalyzer(YakuAnalyzer): 5 | id = "yakuhai" 6 | 7 | def __init__(self, enemy): 8 | self.enemy = enemy 9 | 10 | def serialize(self): 11 | return {"id": self.id} 12 | 13 | def is_yaku_active(self): 14 | return len(self._get_suitable_melds()) > 0 15 | 16 | def melds_han(self): 17 | han = 0 18 | suitable_melds = self._get_suitable_melds() 19 | for x in suitable_melds: 20 | tile_34 = x.tiles[0] // 4 21 | # we need to do that to support double winds yakuhais 22 | han += len([x for x in self.enemy.valued_honors if x == tile_34]) 23 | return han 24 | 25 | def _get_suitable_melds(self): 26 | suitable_melds = [] 27 | for x in self.enemy.melds: 28 | tile_34 = x.tiles[0] // 4 29 | if tile_34 in self.enemy.valued_honors: 30 | suitable_melds.append(x) 31 | return suitable_melds 32 | -------------------------------------------------------------------------------- /tenhou_env/LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. -------------------------------------------------------------------------------- /tenhou_env/project/game/ai/trainer/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | # GCP_BUCKET = 'mahjong-dataset' 4 | GCP_BUCKET = 'mahjong-bucket' 5 | BATCH_SIZE = 1 6 | TRAIN_SPLIT = 0.8 7 | CHECKPOINT_DIR = "checkpoints" 8 | RANDOM_SEED = 1 9 | DISCARD_TABLE_BQ = "mahjong.discarded" 10 | CHI_PON_KAN_TABLE_BQ = "mahjong.chi_pon_kan" 11 | # PROJECT_ID = "mahjong-305819" 12 | PROJECT_ID = "mahjong-307020" 13 | REGION = "us-central1" 14 | 15 | 16 | def get_root_path(): 17 | JOB_DIR = os.environ.get("TF_KERAS_RUNNING_REMOTELY") 18 | if bool(JOB_DIR): 19 | return JOB_DIR 20 | else: 21 | l = os.path.dirname(__file__).split("/") 22 | l.pop() 23 | return "/".join(l) 24 | 25 | 26 | def create_or_join(dir_name): 27 | JOB_DIR = os.environ.get("TF_KERAS_RUNNING_REMOTELY") 28 | root = get_root_path() 29 | if bool(JOB_DIR): 30 | dirs = os.path.join(JOB_DIR, dir_name) 31 | return dirs 32 | else: 33 | if not os.path.exists(os.path.join(root, dir_name)): 34 | os.makedirs(os.path.join(root, dir_name)) 35 | return os.path.join(root, dir_name) 36 | -------------------------------------------------------------------------------- /tenhou_env/project/game/ai/utils.py: -------------------------------------------------------------------------------- 1 | 2 | from keras import backend as K 3 | 4 | 5 | ENTROPY_LOSS = 5e-3 6 | LOSS_CLIPPING = 0.2 7 | 8 | def proximal_policy_optimization_loss(advantage, old_prediction): 9 | def loss(y_true, y_pred): 10 | prob = K.sum(y_true * y_pred, axis=-1) 11 | old_prob = K.sum(y_true * old_prediction, axis=-1) 12 | r = prob / (old_prob + 1e-10) 13 | return -K.mean(K.minimum(r * advantage, K.clip(r, min_value=1-LOSS_CLIPPING, max_value=1+LOSS_CLIPPING) * advantage) + ENTROPY_LOSS * -(prob * K.log(prob + 1e-10))) 14 | return loss 15 | 16 | input_shape_dict = {'chi': (63,34,1), 'pon':(63,34,1), 'kan':(66,34,1), 'riichi':(62,34,1), 'discard': (16,34,1)} 17 | BATCH_SIZE = 64 18 | LR = 1e-4 19 | EPOCHS = 3 20 | 21 | RANKS = [ 22 | "新人", 23 | "9級", 24 | "8級", 25 | "7級", 26 | "6級", 27 | "5級", 28 | "4級", 29 | "3級", 30 | "2級", 31 | "1級", 32 | "初段", 33 | "二段", 34 | "三段", 35 | "四段", 36 | "五段", 37 | "六段", 38 | "七段", 39 | "八段", 40 | "九段", 41 | "十段", 42 | "天鳳位", 43 | ] 44 | 45 | pred_emb_dim = 15 46 | round_num = 15 -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | services: 3 | chief: 4 | container_name: chief 5 | hostname: chief 6 | image: gcr.io/[GCP-PROJECT]/[CONTAINER]:[TAG] 7 | volumes: 8 | - ${GOOGLE_APPLICATION_CREDENTIALS_LOCAL}:/tmp/keys/creds.json:ro 9 | environment: 10 | GOOGLE_APPLICATION_CREDENTIALS: /tmp/keys/creds.json 11 | TF_CONFIG: "{ 12 | \"cluster\": { 13 | \"chief\": [\"chief:2222\"], 14 | \"worker\": [\"worker:2222\"] 15 | }, 16 | \"task\": {\"type\": \"chief\", \"index\": 0} 17 | }" 18 | command: ${COMMAND} 19 | worker: 20 | container_name: worker 21 | hostname: worker 22 | image: gcr.io/[GCP-PROJECT]/[CONTAINER]:[TAG] 23 | volumes: 24 | - ${GOOGLE_APPLICATION_CREDENTIALS_LOCAL}:/tmp/keys/creds.json:ro 25 | environment: 26 | GOOGLE_APPLICATION_CREDENTIALS: /tmp/keys/creds.json 27 | TF_CONFIG: "{ 28 | \"cluster\": { 29 | \"chief\": [\"chief:2222\"], 30 | \"worker\": [\"worker:2222\"] 31 | }, 32 | \"task\": {\"type\": \"worker\", \"index\": 0} 33 | }" 34 | command: ${COMMAND} -------------------------------------------------------------------------------- /trainer/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | # GCP_BUCKET = 'mahjong-dataset' 4 | # GCP_BUCKET = 'mahjong-bucket' 5 | GCP_BUCKET = 'mahjong1' 6 | BATCH_SIZE = 64 7 | TRAIN_SPLIT = 0.8 8 | CHECKPOINT_DIR = "checkpoints" 9 | RANDOM_SEED = 1 10 | DISCARD_TABLE_BQ = "mahjong.discarded" 11 | CHI_PON_KAN_TABLE_BQ = "mahjong.chi_pon_kan" 12 | # PROJECT_ID = "mahjong-305819" 13 | # PROJECT_ID = "mahjong-307020" 14 | PROJECT_ID = "lithe-cursor-307422" 15 | REGION = "us-central1" 16 | 17 | 18 | def get_root_path(): 19 | JOB_DIR = os.environ.get("TF_KERAS_RUNNING_REMOTELY") 20 | if bool(JOB_DIR): 21 | return JOB_DIR 22 | else: 23 | l = os.path.dirname(__file__).split("/") 24 | l.pop() 25 | return "/".join(l) 26 | 27 | 28 | def create_or_join(dir_name): 29 | JOB_DIR = os.environ.get("TF_KERAS_RUNNING_REMOTELY") 30 | root = get_root_path() 31 | if bool(JOB_DIR): 32 | dirs = os.path.join(JOB_DIR, dir_name) 33 | return dirs 34 | else: 35 | if not os.path.exists(os.path.join(root, dir_name)): 36 | os.makedirs(os.path.join(root, dir_name)) 37 | return os.path.join(root, dir_name) 38 | -------------------------------------------------------------------------------- /tenhou_env/project/run_stat.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from optparse import OptionParser 4 | 5 | from statistics.cases.agari_riichi_cost import AgariRiichiCostCase 6 | from utils.logger import DATE_FORMAT, LOG_FORMAT 7 | 8 | stats_output_folder = os.path.join(os.path.dirname(os.path.realpath(__file__)), "statistics", "output") 9 | if not os.path.exists(stats_output_folder): 10 | os.mkdir(stats_output_folder) 11 | 12 | 13 | def main(): 14 | _set_up_bots_battle_game_logger() 15 | 16 | parser = OptionParser() 17 | parser.add_option("-p", "--db_path", type="string", help="Path to sqlite database with logs") 18 | opts, _ = parser.parse_args() 19 | 20 | case = AgariRiichiCostCase(opts.db_path, stats_output_folder) 21 | case.prepare_statistics() 22 | 23 | 24 | def _set_up_bots_battle_game_logger(): 25 | logger = logging.getLogger("stat") 26 | logger.setLevel(logging.DEBUG) 27 | 28 | ch = logging.StreamHandler() 29 | ch.setLevel(logging.DEBUG) 30 | formatter = logging.Formatter(LOG_FORMAT, datefmt=DATE_FORMAT) 31 | ch.setFormatter(formatter) 32 | 33 | logger.addHandler(ch) 34 | 35 | 36 | if __name__ == "__main__": 37 | main() 38 | -------------------------------------------------------------------------------- /tenhou_env/project/utils/statistics.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from utils.settings_handler import settings 3 | 4 | 5 | class Statistics: 6 | """ 7 | Send data to https://github.com/MahjongRepository/mahjong-stat/ project 8 | """ 9 | 10 | game_id = "" 11 | username = "" 12 | 13 | def send_start_game(self): 14 | url = settings.STAT_SERVER_URL 15 | if not url or not self.game_id: 16 | return False 17 | url = "{0}/api/v1/tenhou/game/start/".format(url) 18 | data = {"id": self.game_id, "username": self.username} 19 | result = requests.post(url, data, headers={"Token": settings.STAT_TOKEN}, timeout=5) 20 | return result.status_code == 200 and result.json()["success"] 21 | 22 | def send_end_game(self): 23 | url = settings.STAT_SERVER_URL 24 | if not url or not self.game_id: 25 | return False 26 | url = "{0}/api/v1/tenhou/game/finish/".format(url) 27 | data = {"id": self.game_id, "username": self.username} 28 | result = requests.post(url, data, headers={"Token": settings.STAT_TOKEN}, timeout=5) 29 | return result.status_code == 200 and result.json()["success"] 30 | -------------------------------------------------------------------------------- /tenhou_env/project/utils/cache.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import marshal 3 | from typing import List 4 | 5 | from utils.decisions_logger import MeldPrint 6 | 7 | 8 | def build_shanten_cache_key(tiles_34: List[int], use_chiitoitsu: bool): 9 | prepared_array = tiles_34 + [int(use_chiitoitsu)] 10 | return hashlib.md5(marshal.dumps(prepared_array)).hexdigest() 11 | 12 | 13 | def build_estimate_hand_value_cache_key( 14 | tiles_136: List[int], 15 | is_riichi: bool, 16 | is_tsumo: bool, 17 | melds: List[MeldPrint], 18 | dora_indicators: List[int], 19 | count_of_riichi_sticks: int, 20 | count_of_honba_sticks: int, 21 | additional_han: int, 22 | is_rinshan: bool, 23 | is_chankan: bool, 24 | ): 25 | prepared_array = ( 26 | tiles_136 27 | + [is_tsumo and 1 or 0] 28 | + [is_riichi and 1 or 0] 29 | + (melds and [x.tiles for x in melds] or []) 30 | + dora_indicators 31 | + [count_of_riichi_sticks] 32 | + [count_of_honba_sticks] 33 | + [additional_han] 34 | + [is_rinshan and 1 or 0] 35 | + [is_chankan and 1 or 0] 36 | ) 37 | return hashlib.md5(marshal.dumps(prepared_array)).hexdigest() 38 | -------------------------------------------------------------------------------- /tenhou_env/project/game/ai/defence/yaku_analyzer/tanyao.py: -------------------------------------------------------------------------------- 1 | from game.ai.defence.yaku_analyzer.yaku_analyzer import YakuAnalyzer 2 | from mahjong.constants import HONOR_INDICES, TERMINAL_INDICES 3 | 4 | 5 | class TanyaoAnalyzer(YakuAnalyzer): 6 | id = "tanyao" 7 | 8 | def __init__(self, enemy): 9 | self.enemy = enemy 10 | 11 | def serialize(self): 12 | return {"id": self.id} 13 | 14 | def is_yaku_active(self): 15 | return len(self._get_suitable_melds()) > 0 16 | 17 | def melds_han(self): 18 | return len(self._get_suitable_melds()) > 0 and 1 or 0 19 | 20 | def _get_suitable_melds(self): 21 | suitable_melds = [] 22 | for meld in self.enemy.melds: 23 | tiles_34 = [x // 4 for x in meld.tiles] 24 | not_suitable_tiles = TERMINAL_INDICES + HONOR_INDICES 25 | if not any(x in not_suitable_tiles for x in tiles_34): 26 | suitable_melds.append(meld) 27 | else: 28 | # if there is an unsuitable meld we consider tanyao impossible 29 | return [] 30 | 31 | return suitable_melds 32 | 33 | def get_safe_tiles_34(self): 34 | return TERMINAL_INDICES + HONOR_INDICES 35 | -------------------------------------------------------------------------------- /trainer/tuner.py: -------------------------------------------------------------------------------- 1 | from kerastuner.engine import base_tuner 2 | 3 | TunerFnResult = NamedTuple('TunerFnResult', [('tuner', base_tuner.BaseTuner), 4 | ('fit_kwargs', Dict[Text, Any])]) 5 | 6 | 7 | def tuner_fn(fn_args: FnArgs) -> TunerFnResult: 8 | """Build the tuner using the KerasTuner API. 9 | Args: 10 | fn_args: Holds args as name/value pairs. 11 | 12 | - working_dir: working dir for tuning. 13 | - train_files: List of file paths containing training tf.Example data. 14 | - eval_files: List of file paths containing eval tf.Example data. 15 | - train_steps: number of train steps. 16 | - eval_steps: number of eval steps. 17 | - schema_path: optional schema of the input data. 18 | - transform_graph_path: optional transform graph produced by TFT. 19 | Returns: 20 | A namedtuple contains the following: 21 | - tuner: A BaseTuner that will be used for tuning. 22 | - fit_kwargs: Args to pass to tuner's run_trial function for fitting the 23 | model , e.g., the training and validation dataset. Required 24 | args depend on the above tuner's implementation. 25 | """ 26 | ... 27 | -------------------------------------------------------------------------------- /tenhou_env/project/game/bots_battle/replays/base.py: -------------------------------------------------------------------------------- 1 | class Replay: 2 | replays_directory = "" 3 | replay_name = "" 4 | tags = [] 5 | clients = [] 6 | 7 | def __init__(self, replay_name, clients, replays_directory): 8 | self.replay_name = replay_name 9 | self.clients = clients 10 | self.replays_directory = replays_directory 11 | 12 | def init_game(self, seed): 13 | raise NotImplementedError() 14 | 15 | def end_game(self): 16 | raise NotImplementedError() 17 | 18 | def init_round(self, dealer, round_number, honba_sticks, riichi_sticks, dora): 19 | raise NotImplementedError() 20 | 21 | def draw(self, who, tile): 22 | raise NotImplementedError() 23 | 24 | def discard(self, who, tile): 25 | raise NotImplementedError() 26 | 27 | def riichi(self, who, step): 28 | raise NotImplementedError() 29 | 30 | def open_meld(self, meld): 31 | raise NotImplementedError() 32 | 33 | def retake(self, tempai_players, honba_sticks, riichi_sticks): 34 | raise NotImplementedError() 35 | 36 | def win(self, who, from_who, win_tile, honba_sticks, riichi_sticks, han, fu, cost, yaku_list, dora, ura_dora): 37 | raise NotImplementedError() 38 | -------------------------------------------------------------------------------- /tenhou_env/project/game/ai/trainer/tuner.py: -------------------------------------------------------------------------------- 1 | from kerastuner.engine import base_tuner 2 | 3 | TunerFnResult = NamedTuple('TunerFnResult', [('tuner', base_tuner.BaseTuner), 4 | ('fit_kwargs', Dict[Text, Any])]) 5 | 6 | 7 | def tuner_fn(fn_args: FnArgs) -> TunerFnResult: 8 | """Build the tuner using the KerasTuner API. 9 | Args: 10 | fn_args: Holds args as name/value pairs. 11 | 12 | - working_dir: working dir for tuning. 13 | - train_files: List of file paths containing training tf.Example data. 14 | - eval_files: List of file paths containing eval tf.Example data. 15 | - train_steps: number of train steps. 16 | - eval_steps: number of eval steps. 17 | - schema_path: optional schema of the input data. 18 | - transform_graph_path: optional transform graph produced by TFT. 19 | Returns: 20 | A namedtuple contains the following: 21 | - tuner: A BaseTuner that will be used for tuning. 22 | - fit_kwargs: Args to pass to tuner's run_trial function for fitting the 23 | model , e.g., the training and validation dataset. Required 24 | args depend on the above tuner's implementation. 25 | """ 26 | ... 27 | -------------------------------------------------------------------------------- /tenhou_env/project/statistics/db.py: -------------------------------------------------------------------------------- 1 | import bz2 2 | import sqlite3 3 | 4 | 5 | def load_logs_from_db(db_path: str, limit: int, offset: int): 6 | """ 7 | Load logs from db and decompress logs content. 8 | How to download games content you can learn there: https://github.com/MahjongRepository/phoenix-logs 9 | """ 10 | connection = sqlite3.connect(db_path) 11 | 12 | with connection: 13 | cursor = connection.cursor() 14 | cursor.execute( 15 | "SELECT log_id, log_content FROM logs where is_sanma = 0 ORDER BY date LIMIT ? OFFSET ?;", 16 | [limit, offset], 17 | ) 18 | data = cursor.fetchall() 19 | 20 | results = [] 21 | for x in data: 22 | log_id = x[0] 23 | try: 24 | results.append({"log_id": log_id, "log_content": bz2.decompress(x[1]).decode("utf-8")}) 25 | except Exception as e: 26 | print(e) 27 | print(log_id) 28 | 29 | return results 30 | 31 | 32 | def get_total_logs_count(db_path: str): 33 | connection = sqlite3.connect(db_path) 34 | 35 | with connection: 36 | cursor = connection.cursor() 37 | cursor.execute( 38 | "SELECT COUNT(*) FROM logs where is_sanma = 0;", 39 | ) 40 | result = cursor.fetchall() 41 | return result[0][0] 42 | -------------------------------------------------------------------------------- /tenhou_env/project/statistics/merge_csv_files.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import pathlib 3 | 4 | from tqdm import tqdm 5 | 6 | 7 | def main(): 8 | merge() 9 | 10 | 11 | def merge(): 12 | csv_files_dir = pathlib.Path(__file__).parent.absolute() / "output" 13 | 14 | dealer_files = [] 15 | regular_files = [] 16 | for file_obj in csv_files_dir.glob("*.csv"): 17 | if file_obj.name.startswith("01"): 18 | continue 19 | 20 | if file_obj.name.startswith("dealer"): 21 | dealer_files.append(file_obj) 22 | else: 23 | regular_files.append(file_obj) 24 | 25 | merge_files_into_one(dealer_files, csv_files_dir / "01_dealer_total.csv") 26 | merge_files_into_one(regular_files, csv_files_dir / "01_total.csv") 27 | 28 | 29 | def merge_files_into_one(files, file_path): 30 | data = [] 31 | for file_obj in tqdm(files, desc=file_path.name): 32 | with file_obj.open(mode="r") as f: 33 | reader = csv.DictReader(f) 34 | results = list(reader) 35 | for row in results: 36 | data.append(row) 37 | 38 | with open(file_path, "w") as csv_file: 39 | writer = csv.DictWriter(csv_file, fieldnames=data[0].keys()) 40 | for data in data: 41 | writer.writerow(data) 42 | 43 | 44 | if __name__ == "__main__": 45 | main() 46 | -------------------------------------------------------------------------------- /tenhou_env/project/game/bots_battle/local_client.py: -------------------------------------------------------------------------------- 1 | from game.client import Client 2 | from utils.general import make_random_letters_and_digit_string 3 | from utils.logger import set_up_logging 4 | from utils.settings_handler import settings 5 | 6 | 7 | class LocalClient(Client): 8 | seat = 0 9 | is_daburi = False 10 | is_ippatsu = False 11 | is_rinshan = False 12 | 13 | def __init__(self, bot_config, print_logs, replay_name, game_count): 14 | super().__init__(bot_config) 15 | self.id = make_random_letters_and_digit_string() 16 | self.player.name = bot_config.name 17 | 18 | if print_logs: 19 | settings.LOG_PREFIX = self.player.name 20 | logger = set_up_logging( 21 | save_to_file=True, print_to_console=False, logger_name=self.player.name + str(game_count) 22 | ) 23 | logger.info(f"Replay name: {replay_name}") 24 | self.player.init_logger(logger) 25 | 26 | def connect(self): 27 | pass 28 | 29 | def authenticate(self): 30 | pass 31 | 32 | def start_game(self): 33 | pass 34 | 35 | def end_game(self): 36 | pass 37 | 38 | def erase_state(self): 39 | self.is_daburi = False 40 | self.is_ippatsu = False 41 | self.is_rinshan = False 42 | 43 | self.table.erase_state() 44 | -------------------------------------------------------------------------------- /tenhou_env/project/settings/base.py: -------------------------------------------------------------------------------- 1 | TENHOU_HOST = "133.242.10.78" 2 | TENHOU_PORT = 10080 3 | 4 | USER_ID = "ID4E811190-HRS6RAXZ" 5 | 6 | # 0-our ai 7 | AI = 0 8 | 9 | LOBBY = "0" 10 | WAITING_GAME_TIMEOUT_MINUTES = 10 11 | 12 | # in tournament mode bot is not trying to search the game 13 | # it just sitting in the lobby and waiting for the game start 14 | IS_TOURNAMENT = False 15 | 16 | STAT_SERVER_URL = "" 17 | STAT_TOKEN = "" 18 | PAPERTRAIL_HOST_AND_PORT = "" 19 | SENTRY_URL = "" 20 | 21 | LOG_PREFIX = "" 22 | 23 | PRINT_LOGS = True 24 | 25 | """ 26 | Game type decoding: 27 | 28 | 0 - 1 - online, 0 - bots 29 | 1 - aka forbiden 30 | 2 - kuitan forbidden 31 | 3 - hanchan 32 | 4 - 3man 33 | 5 - dan flag 34 | 6 - fast game 35 | 7 - dan flag 36 | 37 | Combine them as: 38 | 76543210 39 | 40 | # hanchan, ari-ari examples 41 | 00001001 = 9 - kyu 42 | 10001001 = 137 - dan 43 | 00101001 = 41 - upperdan 44 | 10101001 = 169 - phoenix 45 | 46 | 00001011 = 11 - hanchan no red five, but with open tanyao 47 | 48 | 00001001 = 9 - kyu, hanchan ari-ari 49 | 00000001 = 1 - kyu, tonpusen ari-ari 50 | """ 51 | 52 | # for dynamic game type selection (based on the bot rank and rate) 53 | # you can use: 54 | # GAME_TYPE = None 55 | GAME_TYPE = "1" 56 | 57 | try: 58 | from .settings_local import * 59 | except ImportError: 60 | pass 61 | -------------------------------------------------------------------------------- /tenhou_env/Makefile: -------------------------------------------------------------------------------- 1 | MAKE_FILE_PATH=$(abspath $(lastword $(MAKEFILE_LIST))) 2 | CURRENT_DIR=$(dir $(MAKE_FILE_PATH)) 3 | 4 | check: generate_system_tests format lint tests 5 | 6 | format: 7 | isort project/* 8 | black project/* 9 | 10 | lint: 11 | isort --check-only project/* 12 | black --check project/* 13 | flake8 project/* 14 | 15 | generate_system_tests: 16 | python project/system.py 17 | 18 | tests: 19 | PYTHONPATH=./project pytest -n 4 20 | 21 | tests_coverage: 22 | PYTHONPATH=./project pytest --cov=. --cov-report html -n 4 23 | 24 | build_docker: 25 | docker build -t mahjong_bot . 26 | 27 | GAMES=1 28 | run_battle: 29 | docker run -u `id -u` -it --rm \ 30 | --cpus=".9" \ 31 | -v "$(CURRENT_DIR)project/:/app/" \ 32 | -v /dev/urandom:/dev/urandom \ 33 | mahjong_bot pypy3 bots_battle.py -g $(GAMES) $(ARGS) 34 | 35 | run_stat: 36 | docker run -u `id -u` -it --rm \ 37 | --cpus=".9" \ 38 | --memory="4g" \ 39 | -v "$(CURRENT_DIR)project/:/app/" \ 40 | -v "$(db_folder):/app/statistics/db/" \ 41 | mahjong_bot pypy3 run_stat.py -p /app/statistics/db/$(file_name) 42 | 43 | run_on_tenhou: 44 | docker-compose up 45 | 46 | archive_replays: 47 | tar -czvf "logs-$(shell date '+%Y-%m-%d-%H-%M').tar.gz" -C ./project/battle_results/logs/ . 48 | tar -czvf "replays-$(shell date '+%Y-%m-%d-%H-%M').tar.gz" -C ./project/battle_results/replays/ . 49 | -------------------------------------------------------------------------------- /logs_parser/main.py: -------------------------------------------------------------------------------- 1 | """Define `view` command""" 2 | from __future__ import absolute_import 3 | 4 | import logging 5 | import sys 6 | import xml.etree.ElementTree as ET 7 | # from io import load_mjlog 8 | from parser import parse_mjlog 9 | 10 | import pandas as pd 11 | from viewer import print_node 12 | 13 | _LG = logging.getLogger(__name__) 14 | 15 | 16 | def _print_meta(meta_data): 17 | for tag in ['SHUFFLE', 'GO', 'UN', 'TAIKYOKU']: 18 | if tag in meta_data: 19 | print_node(tag, meta_data[tag]) 20 | 21 | 22 | def _print_round(round_data): 23 | _LG.info('=' * 40) 24 | for node in round_data: 25 | print_node(node['tag'], node['data']) 26 | 27 | 28 | def print_game(xml_str): 29 | node = ET.fromstring(xml_str) 30 | data = parse_mjlog(node) 31 | _print_meta(data['meta']) 32 | rounds = data['rounds'] 33 | for round_data in rounds: 34 | _print_round(round_data) 35 | 36 | 37 | def _init_logging(debug=False): 38 | level = logging.DEBUG if debug else logging.INFO 39 | format_ = ( 40 | '%(message)s' if not debug else 41 | '%(asctime)s: %(levelname)5s: %(funcName)10s: %(message)s' 42 | ) 43 | logging.basicConfig(level=level, format=format_, stream=sys.stdout) 44 | 45 | 46 | if __name__ == "__main__": 47 | _init_logging() 48 | df = pd.read_csv("../dataset/2009.csv", encoding='utf8') 49 | print_game(df["log_content"][0]) 50 | -------------------------------------------------------------------------------- /tenhou_env/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | bot1: 5 | image: mahjong_bot 6 | volumes: 7 | - ./project/:/app 8 | command: pypy3 main.py -s bot_1_settings 9 | restart: always 10 | logging: 11 | driver: json-file 12 | options: 13 | max-size: '10m' 14 | max-file: '5' 15 | bot2: 16 | image: mahjong_bot 17 | volumes: 18 | - ./project/:/app 19 | command: pypy3 main.py -s bot_2_settings 20 | restart: always 21 | logging: 22 | driver: json-file 23 | options: 24 | max-size: '10m' 25 | max-file: '5' 26 | bot3: 27 | image: mahjong_bot 28 | volumes: 29 | - ./project/:/app 30 | command: pypy3 main.py -s bot_3_settings 31 | restart: always 32 | logging: 33 | driver: json-file 34 | options: 35 | max-size: '10m' 36 | max-file: '5' 37 | bot4: 38 | image: mahjong_bot 39 | volumes: 40 | - ./project/:/app 41 | command: pypy3 main.py -s bot_4_settings 42 | restart: always 43 | logging: 44 | driver: json-file 45 | options: 46 | max-size: '10m' 47 | max-file: '5' 48 | # bot5: 49 | # image: mahjong_bot 50 | # volumes: 51 | # - ./project/:/app 52 | # command: pypy3 main.py -s bot_5_settings 53 | # restart: always 54 | # logging: 55 | # driver: json-file 56 | # options: 57 | # max-size: '10m' 58 | # max-file: '5' 59 | 60 | -------------------------------------------------------------------------------- /tenhou_env/project/game/ai/defence/yaku_analyzer/atodzuke.py: -------------------------------------------------------------------------------- 1 | from game.ai.defence.yaku_analyzer.yaku_analyzer import YakuAnalyzer 2 | from game.ai.helpers.defence import TileDanger 3 | from mahjong.utils import is_honor 4 | 5 | 6 | class AtodzukeAnalyzer(YakuAnalyzer): 7 | id = "atodzuke_yakuhai" 8 | 9 | def __init__(self, enemy): 10 | self.enemy = enemy 11 | 12 | def serialize(self): 13 | return {"id": self.id} 14 | 15 | # we must check atodzuke after all other yaku and only if there are no other yaku 16 | # so activation check is on the caller's side 17 | def is_yaku_active(self): 18 | return True 19 | 20 | def melds_han(self): 21 | return 1 22 | 23 | def get_safe_tiles_34(self): 24 | safe_tiles = [] 25 | for x in range(0, 34): 26 | if not is_honor(x): 27 | safe_tiles.append(x) 28 | elif not self.enemy.valued_honors.count(x): 29 | safe_tiles.append(x) 30 | 31 | return safe_tiles 32 | 33 | def get_bonus_danger(self, tile_136, number_of_revealed_tiles): 34 | bonus_danger = [] 35 | tile_34 = tile_136 // 4 36 | number_of_yakuhai = self.enemy.valued_honors.count(tile_34) 37 | 38 | if number_of_yakuhai > 0 and number_of_revealed_tiles < 3: 39 | bonus_danger.append(TileDanger.ATODZUKE_YAKUHAI_HONOR_BONUS_DANGER) 40 | 41 | return bonus_danger 42 | 43 | def is_absorbed(self, possible_yaku, tile_34=None): 44 | return len(possible_yaku) > 1 45 | -------------------------------------------------------------------------------- /tenhou_env/project/game/ai/exp_buffer.py: -------------------------------------------------------------------------------- 1 | class ExperienceCollector: 2 | def __init__(self, model_type, buffer): 3 | self.model_type = model_type 4 | self.states = [] 5 | self.actions = [] 6 | self.importances = [] 7 | self.rewards = [] 8 | self.buffer = buffer 9 | 10 | self.current_episode_states = [] 11 | self.current_episode_actions = [] 12 | self.current_episode_importances = [] 13 | 14 | def record_decision(self, state, action, importance): 15 | self.current_episode_states.append(state) 16 | self.current_episode_actions.append(action) 17 | self.current_episode_importances.append(importance) 18 | 19 | def start_episode(self): 20 | self.current_episode_actions = [] 21 | self.current_episode_states = [] 22 | self.current_episode_importances = [] 23 | 24 | def complete_episode(self, reward): 25 | num_states = len(self.current_episode_states) 26 | self.states += self.current_episode_states 27 | self.actions += self.current_episode_actions 28 | self.importances += self.current_episode_importances 29 | self.rewards += [reward] * num_states 30 | 31 | self.current_episode_actions = [] 32 | self.current_episode_states = [] 33 | self.current_episode_importances = [] 34 | 35 | def to_buffer(self): 36 | for sample in list(zip(self.states, self.rewards, self.importances, self.actions)): 37 | self.buffer.store(*sample) 38 | self.states, self.rewards, self.actions, self.importances = [], [], [], [] -------------------------------------------------------------------------------- /tenhou_env/project/options.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import os 3 | import sys 4 | import datetime 5 | from math import ceil 6 | 7 | 8 | class Options: 9 | def __init__(self, num_nodes, num_workers): 10 | # parameters set 11 | 12 | self.num_nodes = num_nodes 13 | self.num_workers = num_workers 14 | self.num_games = 10 15 | self.num_learners = 1 16 | 17 | self.push_freq = 100 18 | 19 | self.gamma = 0.99 20 | 21 | # self.a_l_ratio = a_l_ratio 22 | # self.weights_file = weights_file 23 | 24 | self.recover = False 25 | self.checkpoint_freq = 21600 # 21600s = 6h 26 | 27 | # gpu memory fraction 28 | self.gpu_fraction = 0.3 29 | 30 | self.hidden_size = [400, 300] 31 | 32 | 33 | self.buffer_size = int(1e6) 34 | self.buffer_size = self.buffer_size // self.num_buffers 35 | 36 | # self.start_steps = int(1e4) // self.num_buffers 37 | 38 | # if self.weights_file: 39 | # self.start_steps = self.buffer_size 40 | 41 | self.lr = 1e-3 42 | self.polyak = 0.995 43 | 44 | self.batch_size = 128 45 | 46 | # n-step 47 | self.Ln = 1 48 | 49 | self.save_freq = 1 50 | 51 | self.seed = 0 52 | 53 | ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 54 | 55 | self.summary_dir = ROOT_DIR + '/tboard_ray' # Directory for storing tensorboard summary results 56 | self.save_dir = ROOT_DIR # Directory for storing trained model 57 | self.save_interval = int(5e5) 58 | 59 | self.log_dir = self.summary_dir + "/" + str(datetime.datetime.now()) + "-workers_num:" + \ 60 | str(self.num_workers) 61 | -------------------------------------------------------------------------------- /tenhou_env/.github/workflows/pythonapp.yml: -------------------------------------------------------------------------------- 1 | name: Mahjong bot 2 | 3 | on: 4 | push: 5 | branches: [master, dev] 6 | pull_request: 7 | types: [opened, synchronize, reopened] 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Set up Python 3.8 15 | uses: actions/setup-python@v1 16 | with: 17 | python-version: '3.8' 18 | - name: Install libs 19 | run: pip install -r requirements/lint.txt 20 | - name: Lint files 21 | run: make lint 22 | coverage: 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v2 26 | - name: Set up Python 3.8 27 | uses: actions/setup-python@v1 28 | with: 29 | python-version: '3.8' 30 | - name: Install libs 31 | run: pip install -r requirements/lint.txt 32 | - name: Generate coverage report 33 | run: make tests_coverage 34 | - name: Deploy to GitHub Pages 35 | uses: JamesIves/github-pages-deploy-action@3.7.1 36 | with: 37 | GITHUB_TOKEN: ${{ secrets.PASSWORD }} 38 | BRANCH: gh-pages # The branch the action should deploy to. 39 | FOLDER: htmlcov # The folder the action should deploy. 40 | CLEAN: true # Automatically remove deleted files from the deploy branch 41 | tests: 42 | runs-on: ubuntu-latest 43 | strategy: 44 | matrix: 45 | python-version: [3.7, 3.8, 3.9] 46 | steps: 47 | - uses: actions/checkout@v2 48 | - name: Set up Python ${{ matrix.python-version }} 49 | uses: actions/setup-python@v1 50 | with: 51 | python-version: ${{ matrix.python-version }} 52 | - name: Install libs 53 | run: pip install -r requirements/dev.txt 54 | - name: Run tests 55 | run: make tests -------------------------------------------------------------------------------- /logs_parser/parser_io.py: -------------------------------------------------------------------------------- 1 | """Utility functions for I/O""" 2 | from __future__ import absolute_import 3 | 4 | import sys 5 | import gzip 6 | import xml.etree.ElementTree as ET 7 | 8 | 9 | def _load_gzipped(filepath): 10 | with gzip.open(filepath) as file_: 11 | return ET.parse(file_).getroot() 12 | 13 | 14 | def load_mjlog(filepath): 15 | """Load [gzipped] mjlog file 16 | 17 | Parameters 18 | ---------- 19 | filepath : str 20 | Path to the mjlog file to load 21 | 22 | Returns 23 | ------- 24 | xml.etree.ElementTree.Element 25 | Element object which represents the root node. 26 | """ 27 | if '.gz' in filepath: 28 | return _load_gzipped(filepath) 29 | return ET.parse(filepath).getroot() 30 | 31 | 32 | if sys.version_info[0] < 3: 33 | def ensure_unicode(string): 34 | """Convert string into unicode.""" 35 | if not isinstance(string, unicode): 36 | return string.decode('utf-8') 37 | return string 38 | 39 | 40 | def ensure_str(string): 41 | """Convert string into str (bytes) object.""" 42 | if not isinstance(string, str): 43 | return string.encode('utf-8') 44 | return string 45 | 46 | 47 | from urllib2 import unquote as _unquote 48 | def unquote(string): 49 | unquoted = _unquote(ensure_str(string)) 50 | if isinstance(string, unicode): 51 | return unquoted.decode('utf-8') 52 | return unquoted 53 | 54 | 55 | else: 56 | def ensure_unicode(string): 57 | """Convert string into unicode.""" 58 | if not isinstance(string, str): 59 | return string.decode('utf-8') 60 | return string 61 | 62 | def ensure_str(string): 63 | """Convert string into str (bytes) object.""" 64 | if not isinstance(string, str): 65 | return string.decode('utf-8') 66 | return string 67 | 68 | 69 | from urllib.parse import unquote as _unquote 70 | def unquote(string): 71 | return _unquote(string) 72 | -------------------------------------------------------------------------------- /tenhou_env/project/game/ai/server.py: -------------------------------------------------------------------------------- 1 | from tensorflow import keras 2 | import os 3 | import numpy as np 4 | from keras.layers import Input 5 | from game.ai.utils import proximal_policy_optimization_loss 6 | from keras.optimizers import Adam 7 | import tensorflow as tf 8 | import datetime 9 | ''' 10 | TO DO: implement Advantage as Q - V 11 | ''' 12 | BATCH_SIZE = 64 13 | LR = 1e-4 14 | EPOCHS = 3 15 | def buffer_reader(buffer_path): 16 | # TO DO: implement replay buffer select logic 17 | with open(buffer_path) as buffer: 18 | while True: 19 | yield [np.asarray(x) for x in zip(*np.random.choice(buffer, size=BATCH_SIZE))] 20 | 21 | class PGTrainer: 22 | def __init__(self, model_type): 23 | self.model_path = os.path.join(os.getcwd(), 'models', model_type) 24 | self.buffer_path = os.path.join(os.getcwd(), 'buffer', model_type) 25 | 26 | 27 | def build_model(self): 28 | actor = keras.models.load_model(self.model_path) 29 | 30 | state_input = Input(actor.input.shape) 31 | advantage = Input(shape=(1,)) 32 | old_prediction = Input(shape=(actor.output.shape)) 33 | 34 | output = actor(state_input) 35 | model = keras.Model(inputs=[state_input, advantage, old_prediction], outputs=[output]) 36 | model.compile(optimizer=Adam(lr=(LR)), 37 | loss=[proximal_policy_optimization_loss( 38 | advantage=advantage, 39 | old_prediction = old_prediction 40 | )]) 41 | model.summary() 42 | return model 43 | 44 | def run(self): 45 | self.model = self.build_model() 46 | log_dir = "logs/fit/" + datetime.datetime.now().strftime("%Y%m%d-%H%M%S") 47 | tensorboard_callback = tf.keras.callbacks.TensorBoard(log_dir=log_dir, histogram_freq=1) 48 | generator = buffer_reader(self.buffer_path) 49 | 50 | for _ in range(EPOCHS): 51 | features, action, importance, advantage = next(generator) 52 | self.model.fit([features, advantage, importance], [action], callbacks=[tensorboard_callback]) 53 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CSCI527 2 | CSCI527 Mahjong Agent Repo 3 | 4 | ## AI Documentation 5 | > A guide to create AI agent 6 | 7 | ```python 8 | ai.erase_state() 9 | ``` 10 | erase_state is called in table.py for round initialization 11 | 12 | ```python 13 | ai.init_hand() 14 | ``` 15 | init_hand is called in player.py for hand initialization 16 | 17 | ```python 18 | ai.draw_tile(tile_136) 19 | ``` 20 | - tile_136: list of int, 136_array. Generate from TilesConverter.string_to_136_array 21 | 22 | ```python 23 | tile_to_discard, with_riichi = ai.discard_tile(discard_tile) 24 | ``` 25 | - discard_tile: int, 136_tile. Generated from TilesConverter.string_to_136_array[0]. A previous suggestion from ai.try_to_call_meld() or riichi rules. Can be none. 26 | - tile_to_discard: int, 136_tile. Generated from TilesConverter.string_to_136_array[0]. 27 | - with_riichi: bool. Should call riichi after tile discard. 28 | 29 | ```python 30 | ai.kan.should_call_kan(tile, open_kan, from_riichi) 31 | ``` 32 | - tile: int, 136_tile. Generated from TilesConverter.string_to_136_array[0]. 33 | - open_kan: bool. False for 暗杠. 34 | - from_riichi: bool. Already riichi. 35 | - return: bool. Should call kan. 36 | 37 | ```python 38 | ai.should_call_win(tile, is_tsumo, enemy_seat, is_chankan) 39 | ``` 40 | - tile: int, 136_tile. Generated from TilesConverter.string_to_136_array[0]. 41 | - is_tsumo: bool. True for 自摸. 42 | - enemy_seat: int. Generated from player.table.get_players_sorted_by_scores()[x] 放铳者 43 | - is_chankan: bool. True for 抢杠. 44 | - return: bool. Should call win. 45 | 46 | ```python 47 | ai.should_call_kyuushu_kyuuhai() 48 | ``` 49 | - return: bool. Should call kyuushu_kyuuhai(九种九牌). 50 | 51 | ```python 52 | meld, discard_option = self.ai.try_to_call_meld(tile, is_kamicha_discard) 53 | ``` 54 | - tile: int, 136_tile. Generated from TilesConverter.string_to_136_array[0]. 55 | - is_kamicha_discard: bool. Discard tile is from opponent on the left(上家). 56 | - meld: meld==none for skip. meld.type=MeldPrint.CHI\PON, meld.tiles=list of int, size of 3, 136_array. meld.opened=bool. 57 | - discard_option: int, 136_tile. Tile to discard. 58 | 59 | ```python 60 | ai.enemy_called_riichi(player_seat) 61 | ``` 62 | - player_seat: int. 63 | -------------------------------------------------------------------------------- /tenhou_env/project/game/bots_battle/replays/test_tenhou_encoder.py: -------------------------------------------------------------------------------- 1 | from game.bots_battle.replays.tenhou import TenhouReplay 2 | from mahjong.meld import Meld 3 | from utils.test_helpers import make_meld 4 | 5 | 6 | def test_encode_called_chi(): 7 | meld = make_meld(Meld.CHI, tiles=[26, 29, 35]) 8 | meld.who = 3 9 | meld.from_who = 2 10 | meld.called_tile = 29 11 | replay = TenhouReplay("", [], "") 12 | 13 | result = replay._encode_meld(meld) 14 | assert result == "19895" 15 | 16 | meld = make_meld(Meld.CHI, tiles=[4, 11, 13]) 17 | meld.who = 1 18 | meld.from_who = 0 19 | meld.called_tile = 4 20 | replay = TenhouReplay("", [], "") 21 | 22 | result = replay._encode_meld(meld) 23 | assert result == "3303" 24 | 25 | 26 | def test_encode_called_pon(): 27 | meld = make_meld(Meld.PON, tiles=[104, 105, 107]) 28 | meld.who = 0 29 | meld.from_who = 1 30 | meld.called_tile = 105 31 | replay = TenhouReplay("", [], "") 32 | 33 | result = replay._encode_meld(meld) 34 | assert result == "40521" 35 | 36 | meld = make_meld(Meld.PON, tiles=[124, 126, 127]) 37 | meld.who = 0 38 | meld.from_who = 2 39 | meld.called_tile = 124 40 | replay = TenhouReplay("", [], "") 41 | 42 | result = replay._encode_meld(meld) 43 | assert result == "47658" 44 | 45 | 46 | def test_encode_called_daiminkan(): 47 | meld = make_meld(Meld.KAN, tiles=[100, 101, 102, 103]) 48 | meld.who = 2 49 | meld.from_who = 3 50 | meld.called_tile = 103 51 | replay = TenhouReplay("", [], "") 52 | 53 | result = replay._encode_meld(meld) 54 | assert result == "26369" 55 | 56 | 57 | def test_encode_called_shouminkan(): 58 | meld = make_meld(Meld.SHOUMINKAN, tiles=[112, 113, 115, 114]) 59 | meld.who = 2 60 | meld.from_who = 3 61 | meld.called_tile = 114 62 | replay = TenhouReplay("", [], "") 63 | 64 | result = replay._encode_meld(meld) 65 | assert result == "44113" 66 | 67 | 68 | def test_encode_called_ankan(): 69 | meld = make_meld(Meld.KAN, tiles=[72, 73, 74, 75]) 70 | meld.who = 2 71 | meld.from_who = 2 72 | meld.called_tile = 74 73 | replay = TenhouReplay("", [], "") 74 | 75 | result = replay._encode_meld(meld) 76 | assert result == "18944" 77 | -------------------------------------------------------------------------------- /logs_crawler/main.py: -------------------------------------------------------------------------------- 1 | import os 2 | from datetime import datetime 3 | from distutils.dir_util import mkpath 4 | from optparse import OptionParser 5 | 6 | from download_game_ids import DownloadGameId 7 | from download_logs_content import DownloadLogContent 8 | 9 | current_directory = os.path.dirname(os.path.realpath(__file__)) 10 | logs_directory = os.path.join(current_directory, "temp") 11 | db_folder = os.path.join(current_directory, "db") 12 | 13 | current_year = str(datetime.now().year) 14 | 15 | 16 | def set_up_folders(): 17 | if not os.path.exists(logs_directory): 18 | mkpath(logs_directory) 19 | 20 | if not os.path.exists(db_folder): 21 | mkpath(db_folder) 22 | 23 | 24 | def parse_command_line_arguments(): 25 | parser = OptionParser() 26 | 27 | parser.add_option( 28 | "-y", "--year", type="string", default=None, help="Target year to download logs" 29 | ) 30 | parser.add_option("-p", "--db_path", type="string") 31 | parser.add_option("-a", "--action", type="string", default="id", help="id or content") 32 | parser.add_option("-l", "--limit", type="int", default=0, help="To download content script") 33 | parser.add_option("-t", "--threads", type="int", default=3, help="Count of threads") 34 | 35 | parser.add_option( 36 | "-s", action="store_true", dest="start", help="Download log ids from the start of the year" 37 | ) 38 | 39 | opts, _ = parser.parse_args() 40 | return opts 41 | 42 | 43 | def main(): 44 | set_up_folders() 45 | 46 | opts = parse_command_line_arguments() 47 | 48 | if opts.db_path: 49 | db_file = opts.db_path 50 | else: 51 | db_file = os.path.join(db_folder, f"{opts.year}.db") 52 | 53 | # special condition to download historical data from previous years 54 | historical_download = None 55 | if not opts.year == current_year: 56 | historical_download = opts.year 57 | 58 | if opts.action == "id": 59 | DownloadGameId(logs_directory, db_file, historical_download, opts.start).process(,, 60 | elif opts.action == "content": 61 | DownloadLogContent(db_file, opts.limit, opts.threads).process(, 62 | , 63 | else: 64 | print("Unknown action") 65 | 66 | if __name__ == "__main__": 67 | main() 68 | -------------------------------------------------------------------------------- /tenhou_env/project/utils/test_helpers.py: -------------------------------------------------------------------------------- 1 | from mahjong.tile import TilesConverter 2 | from utils.decisions_logger import MeldPrint 3 | 4 | 5 | def string_to_136_array(sou="", pin="", man="", honors=""): 6 | return TilesConverter.string_to_136_array(sou=sou, pin=pin, man=man, honors=honors, has_aka_dora=True) 7 | 8 | 9 | def string_to_136_tile(sou="", pin="", man="", honors=""): 10 | return string_to_136_array( 11 | sou=sou, 12 | pin=pin, 13 | man=man, 14 | honors=honors, 15 | )[0] 16 | 17 | 18 | def string_to_34_tile(sou="", pin="", man="", honors=""): 19 | item = TilesConverter.string_to_136_array(sou=sou, pin=pin, man=man, honors=honors, has_aka_dora=True) 20 | item[0] //= 4 21 | return item[0] 22 | 23 | 24 | def make_meld(meld_type, is_open=True, man="", pin="", sou="", honors="", tiles=None): 25 | if not tiles: 26 | tiles = string_to_136_array(man=man, pin=pin, sou=sou, honors=honors) 27 | meld = MeldPrint( 28 | meld_type=meld_type, 29 | tiles=tiles, 30 | opened=is_open, 31 | called_tile=tiles[0], 32 | who=0, 33 | ) 34 | return meld 35 | 36 | 37 | def tiles_to_string(tiles_136): 38 | return TilesConverter.to_one_line_string(tiles_136, print_aka_dora=True) 39 | 40 | 41 | def find_discard_option(player, sou="", pin="", man="", honors=""): 42 | discard_options, _ = player.ai.hand_builder.find_discard_options() 43 | tile = string_to_136_tile(sou=sou, pin=pin, man=man, honors=honors) 44 | discard_option = [x for x in discard_options if x.tile_to_discard_34 == tile // 4][0] 45 | 46 | player.ai.hand_builder.mark_tiles_riichi_decision(discard_options) 47 | 48 | for x in discard_options: 49 | if x.shanten in [1]: 50 | player.ai.hand_builder.calculate_second_level_ukeire(x) 51 | 52 | discard_options, _ = player.ai.defence.mark_tiles_danger_for_threats(discard_options) 53 | 54 | return discard_option 55 | 56 | 57 | def enemy_called_riichi_helper(table, enemy_seat, riichi_tile=None): 58 | if not riichi_tile: 59 | riichi_tile = string_to_136_tile(honors="1") 60 | table.add_discarded_tile(enemy_seat, riichi_tile, False) 61 | table.add_called_riichi_step_one(enemy_seat) 62 | table.add_called_riichi_step_two(enemy_seat) 63 | table.get_player(enemy_seat).is_ippatsu = False 64 | -------------------------------------------------------------------------------- /tenhou_env/project/game/ai/helpers/suji.py: -------------------------------------------------------------------------------- 1 | from mahjong.utils import is_man, is_pin, is_sou, simplify 2 | 3 | 4 | class Suji: 5 | # 1-4-7 6 | FIRST_SUJI = 1 7 | # 2-5-8 8 | SECOND_SUJI = 2 9 | # 3-6-9 10 | THIRD_SUJI = 3 11 | 12 | def __init__(self, player): 13 | self.player = player 14 | 15 | def find_suji(self, tiles_136): 16 | tiles_34 = list(set([x // 4 for x in tiles_136])) 17 | 18 | suji = [] 19 | suits = [[], [], []] 20 | 21 | # let's cast each tile to 0-8 presentation 22 | for tile in tiles_34: 23 | if is_man(tile): 24 | suits[0].append(simplify(tile)) 25 | 26 | if is_pin(tile): 27 | suits[1].append(simplify(tile)) 28 | 29 | if is_sou(tile): 30 | suits[2].append(simplify(tile)) 31 | 32 | for x in range(0, 3): 33 | simplified_tiles = suits[x] 34 | base = x * 9 35 | 36 | # 1-4-7 37 | if 3 in simplified_tiles: 38 | suji.append(self.FIRST_SUJI + base) 39 | 40 | # double 1-4-7 41 | if 0 in simplified_tiles and 6 in simplified_tiles: 42 | suji.append(self.FIRST_SUJI + base) 43 | 44 | # 2-5-8 45 | if 4 in simplified_tiles: 46 | suji.append(self.SECOND_SUJI + base) 47 | 48 | # double 2-5-8 49 | if 1 in simplified_tiles and 7 in simplified_tiles: 50 | suji.append(self.SECOND_SUJI + base) 51 | 52 | # 3-6-9 53 | if 5 in simplified_tiles: 54 | suji.append(self.THIRD_SUJI + base) 55 | 56 | # double 3-6-9 57 | if 2 in simplified_tiles and 8 in simplified_tiles: 58 | suji.append(self.THIRD_SUJI + base) 59 | 60 | all_suji = list(set(suji)) 61 | result = [] 62 | for suji in all_suji: 63 | suji_temp = suji % 9 64 | base = suji - suji_temp - 1 65 | 66 | if suji_temp == self.FIRST_SUJI: 67 | result += [base + 1, base + 4, base + 7] 68 | 69 | if suji_temp == self.SECOND_SUJI: 70 | result += [base + 2, base + 5, base + 8] 71 | 72 | if suji_temp == self.THIRD_SUJI: 73 | result += [base + 3, base + 6, base + 9] 74 | 75 | return result 76 | -------------------------------------------------------------------------------- /tenhou_env/project/utils/decisions_logger.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from copy import deepcopy 4 | 5 | from mahjong.meld import Meld 6 | from mahjong.tile import TilesConverter 7 | from utils.settings_handler import settings 8 | 9 | 10 | class DecisionsLogger: 11 | logger = logging.getLogger() 12 | 13 | def debug(self, message_id, message="", context=None): 14 | if not settings.PRINT_LOGS: 15 | return None 16 | 17 | self.logger.debug(f"id={message_id}") 18 | 19 | if message: 20 | self.logger.debug(f"msg={message}") 21 | 22 | if context: 23 | if isinstance(context, list): 24 | for x in context: 25 | self.log_message(x) 26 | else: 27 | self.log_message(context) 28 | 29 | def log_message(self, message): 30 | if hasattr(message, "serialize"): 31 | message = message.serialize() 32 | 33 | if isinstance(message, dict): 34 | message = deepcopy(message) 35 | self.serialize_dict_objects(message) 36 | self.logger.debug(json.dumps(message)) 37 | else: 38 | self.logger.debug(message) 39 | 40 | def serialize_dict_objects(self, d): 41 | for k, v in d.items(): 42 | if isinstance(v, dict): 43 | self.serialize_dict_objects(v) 44 | elif isinstance(v, list): 45 | for i in range(len(v)): 46 | if isinstance(v, dict): 47 | self.serialize_dict_objects(v) 48 | elif hasattr(v[i], "serialize"): 49 | v[i] = v[i].serialize() 50 | elif hasattr(v, "serialize"): 51 | d[k] = v.serialize() 52 | 53 | 54 | class MeldPrint(Meld): 55 | """ 56 | Wrapper to be able use mahjong package MeldPrint object in our loggers. 57 | """ 58 | 59 | def __str__(self): 60 | meld_type_str = self.type 61 | if meld_type_str == self.KAN: 62 | meld_type_str += f" open={self.opened}" 63 | return f"Type: {meld_type_str}, Tiles: {TilesConverter.to_one_line_string(self.tiles)} {self.tiles}" 64 | 65 | def serialize(self): 66 | return { 67 | "type": self.type, 68 | "tiles_string": TilesConverter.to_one_line_string(self.tiles), 69 | "tiles": self.tiles, 70 | } 71 | -------------------------------------------------------------------------------- /tenhou_env/README.md: -------------------------------------------------------------------------------- 1 | ![Build](https://github.com/MahjongRepository/tenhou-python-bot/workflows/Mahjong%20bot/badge.svg) [[Tests coverage]](http://mahjongrepository.github.io/tenhou-python-bot/) 2 | 3 | Bot was tested with Python 3.7+ and PyPy3, we are not supporting Python 2. 4 | 5 | # What do we have here? 6 | 7 | ![Example of bot game](https://cloud.githubusercontent.com/assets/475367/25059936/31b33ac2-21c3-11e7-8cb2-de33d7ba96cb.gif) 8 | 9 | ## Mahjong hands calculation 10 | 11 | You can find it here: https://github.com/MahjongRepository/mahjong 12 | 13 | ## Mahjong bot 14 | 15 | For research purposes we built a simple bot to play riichi mahjong on tenhou.net server. 16 | 17 | Here you can read about bot played games statistic: [versions history](doc/versions.md) 18 | 19 | # For developers 20 | 21 | ## How to run it? 22 | 23 | 1. `pip install -r requirements/lint.txt` 24 | 1. Run `cd project && python main.py` it will connect to the tenhou.net and will play a game. 25 | 26 | ## How to run bot battle with pypy 27 | 28 | To make it easier run bot vs bot battles we prepared PyPy3 Docker container. 29 | 30 | Run the game locally: 31 | 32 | 1. [Install Docker](https://docs.docker.com/get-docker/) 33 | 1. Run `make build_docker` 34 | 1. Run `make GAMES=1 run_battle` it will play one game locally. Logs and replays will be stored in `bots_battle` folder. 35 | 36 | Run bots with enabled decision logger (use it only for debug, since it harms performance): 37 | 1. Run `make GAMES=1 ARGS=--logs run_battle` 38 | 39 | ## Run multiple bots to play one game 40 | 41 | 1. [Install Docker](https://docs.docker.com/get-docker/) and [Install Docker Compose](https://docs.docker.com/compose/install/) 42 | 1. Run `make build_docker` 43 | 1. Put bot configs to `project/settings/`. By default we are looking for these configs: `bot_1_settings.py`, `bot_2_settings.py`, `bot_3_settings.py`, `bot_4_settings.py`, `bot_5_settings.py`. Why 5 settings? Because tenhou doesn't start 2+ game in the custom lobby if you are running only 4 bots. 44 | 1. Run `make run_on_tenhou` 45 | 46 | ## Configuration instructions 47 | 48 | 1. Put your own settings to the `project/settings/settings_local.py` file. 49 | They will override settings from default `settings/base.py` file. 50 | 1. Also, you can override some default settings with command arguments. 51 | Use `python main.py -h` to check all available commands. 52 | 53 | ## Game reproducer 54 | 55 | It can be useful to debug bot errors or strange discards: [game reproducer](doc/reproducer.md) 56 | -------------------------------------------------------------------------------- /tenhou_env/project/game/ai/defence/yaku_analyzer/honitsu_analyzer_base.py: -------------------------------------------------------------------------------- 1 | from game.ai.defence.yaku_analyzer.yaku_analyzer import YakuAnalyzer 2 | from mahjong.utils import is_honor 3 | 4 | 5 | class HonitsuAnalyzerBase(YakuAnalyzer): 6 | chosen_suit = None 7 | 8 | def __init__(self, enemy): 9 | self.enemy = enemy 10 | self.chosen_suit = None 11 | 12 | def serialize(self): 13 | return {"id": self.id, "chosen_suit": self.chosen_suit and self.chosen_suit.__name__} 14 | 15 | def get_tempai_probability_modifier(self): 16 | # if enemy has not yet discarded his suit and there are less than 3 melds, consider tempai less probable 17 | suit_discards = [x for x in self.enemy.discards if self.chosen_suit(x.value // 4)] 18 | 19 | if not suit_discards and len(self.enemy.melds) <= 3: 20 | return 0.5 21 | 22 | return 1 23 | 24 | def _check_discard_order(self, suit, early_position): 25 | # let's check the following considiton: 26 | # if enemy had discarded tiles from that suit or honor and after that he had discarded a tile from a different 27 | # suit from his hand - let's believe it's not honitsu 28 | suit_discards_positions = [ 29 | self.enemy.discards.index(x) for x in self.enemy.discards if suit["function"](x.value // 4) 30 | ] 31 | if suit_discards_positions: 32 | # we consider second discard of chosen suit to be reference point 33 | # first one could have happened when player was not yet sure if he is going to honitsu 34 | # after the second one there should be no discars of other suit from hand 35 | reference_discard = suit_discards_positions[min(1, len(suit_discards_positions) - 1)] 36 | discards_after = self.enemy.discards[reference_discard:] 37 | if discards_after: 38 | has_discarded_other_suit_from_hand = [ 39 | x 40 | for x in discards_after 41 | if (not x.is_tsumogiri and not is_honor(x.value // 4) and not suit["function"](x.value // 4)) 42 | ] 43 | if has_discarded_other_suit_from_hand: 44 | return False 45 | 46 | # if we started discards suit tiles early, it's probably not honitsu 47 | if suit_discards_positions[0] <= early_position: 48 | return False 49 | 50 | # discard order seems similar to honitsu/chinitsu one 51 | return True 52 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | absl-py==0.10.0 2 | apache-beam==2.28.0 3 | astor==0.8.1 4 | astunparse==1.6.3 5 | attrs==20.3.0 6 | avro-python3==1.9.2.1 7 | cachetools==4.2.1 8 | certifi==2020.12.5 9 | cffi==1.14.5 10 | chardet==4.0.0 11 | cloudpickle==1.6.0 12 | colorama==0.4.4 13 | crcmod==1.7 14 | cycler==0.10.0 15 | docker==4.4.4 16 | docopt==0.6.2 17 | fastavro==1.3.2 18 | fasteners==0.16 19 | flatbuffers==1.12 20 | future==0.18.2 21 | gast==0.3.3 22 | google-api-core==1.26.0 23 | google-api-python-client==1.12.8 24 | google-apitools==0.5.31 25 | google-auth==1.26.1 26 | google-auth-httplib2==0.0.4 27 | google-auth-oauthlib==0.4.2 28 | google-cloud-bigquery==1.28.0 29 | google-cloud-bigtable==1.7.0 30 | google-cloud-build==2.0.0 31 | google-cloud-core==1.6.0 32 | google-cloud-datastore==1.15.3 33 | google-cloud-dlp==1.0.0 34 | google-cloud-language==1.3.0 35 | google-cloud-pubsub==1.7.0 36 | google-cloud-spanner==1.19.1 37 | google-cloud-storage==1.36.1 38 | google-cloud-videointelligence==1.16.1 39 | google-cloud-vision==1.0.0 40 | google-crc32c==1.1.2 41 | google-pasta==0.2.0 42 | google-resumable-media==1.2.0 43 | googleapis-common-protos==1.52.0 44 | grpc-google-iam-v1==0.12.3 45 | grpcio==1.32.0 46 | grpcio-gcp==0.2.2 47 | gviz-api==1.9.0 48 | gym==0.18.0 49 | hdfs==2.6.0 50 | httplib2==0.17.4 51 | joblib==1.0.1 52 | Keras==2.4.3 53 | keras-tuner==1.0.2 54 | kiwisolver==1.3.1 55 | libcst==0.3.17 56 | lxml==4.6.2 57 | mahjong==1.2.0.dev5 58 | matplotlib==3.3.4 59 | mock==2.0.0 60 | mypy-extensions==0.4.3 61 | numexpr==2.7.2 62 | oauth2client==4.1.3 63 | oauthlib==3.1.0 64 | opt-einsum==3.3.0 65 | packaging==20.9 66 | pandas 67 | pbr==5.5.1 68 | Pillow==7.2.0 69 | promise==2.3 70 | proto-plus==1.14.2 71 | protobuf==3.14.0 72 | pyarrow==2.0.0 73 | pyasn1==0.4.8 74 | pyasn1-modules==0.2.8 75 | pycparser==2.20 76 | pydot==1.4.2 77 | pyglet==1.5.0 78 | pymongo==3.11.3 79 | pyparsing==2.4.7 80 | PyYAML==5.4.1 81 | requests==2.25.1 82 | requests-oauthlib==1.3.0 83 | rsa==4.7 84 | scikit-learn==0.24.1 85 | tables==3.6.1 86 | tabulate==0.8.9 87 | tensorboard==2.4.1 88 | tensorboard-plugin-profile==2.4.0 89 | tensorboard-plugin-wit==1.8.0 90 | tensorflow==2.4.1 91 | tensorflow-cloud==0.1.13 92 | tensorflow-datasets==3.0.0 93 | tensorflow-estimator==2.4.0 94 | tensorflow-metadata==0.28.0 95 | tensorflow-serving-api==2.4.1 96 | tensorflow-transform==0.28.0 97 | termcolor==1.1.0 98 | terminaltables==3.1.0 99 | tfx-bsl==0.28.1 100 | threadpoolctl==2.1.0 101 | tqdm==4.57.0 102 | typing-inspect==0.6.0 103 | uritemplate==3.0.1 104 | urllib3==1.26.3 105 | websocket-client==0.57.0 106 | wrapt==1.12.1 107 | -------------------------------------------------------------------------------- /tenhou_env/project/game/tests/test_client.py: -------------------------------------------------------------------------------- 1 | from game.client import Client 2 | from utils.decisions_logger import MeldPrint 3 | 4 | 5 | def test_discard_tile(): 6 | client = Client() 7 | 8 | client.table.init_round(0, 0, 0, 0, 0, [0, 0, 0, 0]) 9 | tiles = [1, 22, 3, 4, 43, 6, 7, 8, 9, 55, 11, 12, 13, 99] 10 | client.table.player.init_hand(tiles) 11 | 12 | assert len(client.table.player.tiles) == 14 13 | assert client.table.count_of_remaining_tiles == 70 14 | 15 | tile = client.player.discard_tile() 16 | 17 | assert len(client.table.player.tiles) == 13 18 | assert len(client.table.player.discards) == 1 19 | assert not (tile in client.table.player.tiles) 20 | 21 | 22 | def test_call_meld_closed_kan(): 23 | client = Client() 24 | 25 | client.table.init_round(0, 0, 0, 100, 0, [0, 0, 0, 0]) 26 | assert client.table.count_of_remaining_tiles == 70 27 | 28 | meld = MeldPrint() 29 | client.table.add_called_meld(0, meld) 30 | 31 | assert len(client.player.melds) == 1 32 | assert client.table.count_of_remaining_tiles == 71 33 | 34 | client.player.tiles = [0] 35 | meld = MeldPrint() 36 | meld.type = MeldPrint.KAN 37 | # closed kan 38 | meld.tiles = [0, 1, 2, 3] 39 | meld.called_tile = None 40 | meld.opened = False 41 | client.table.add_called_meld(0, meld) 42 | 43 | assert len(client.player.melds) == 2 44 | # kan was closed, so -1 45 | assert client.table.count_of_remaining_tiles == 70 46 | 47 | 48 | def test_call_meld_kan_from_player(): 49 | client = Client() 50 | 51 | client.table.init_round(0, 0, 0, 0, 0, [0, 0, 0, 0]) 52 | assert client.table.count_of_remaining_tiles == 70 53 | 54 | meld = MeldPrint() 55 | client.table.add_called_meld(0, meld) 56 | 57 | assert len(client.player.melds) == 1 58 | assert client.table.count_of_remaining_tiles == 71 59 | 60 | client.player.tiles = [0] 61 | meld = MeldPrint() 62 | meld.type = MeldPrint.KAN 63 | # closed kan 64 | meld.tiles = [0, 1, 2, 3] 65 | meld.called_tile = 0 66 | meld.opened = True 67 | client.table.add_called_meld(0, meld) 68 | 69 | assert len(client.player.melds) == 2 70 | # kan was called from another player, total number of remaining tiles stays the same 71 | assert client.table.count_of_remaining_tiles == 71 72 | 73 | 74 | def test_enemy_discard(): 75 | client = Client() 76 | client.table.init_round(0, 0, 0, 0, 0, [0, 0, 0, 0]) 77 | 78 | assert client.table.count_of_remaining_tiles == 70 79 | 80 | client.table.add_discarded_tile(1, 10, False) 81 | 82 | assert client.table.count_of_remaining_tiles == 69 83 | -------------------------------------------------------------------------------- /tenhou_env/project/game/ai/strategies/formal_tempai.py: -------------------------------------------------------------------------------- 1 | from game.ai.strategies.main import BaseStrategy 2 | 3 | 4 | class FormalTempaiStrategy(BaseStrategy): 5 | def should_activate_strategy(self, tiles_136, meld_tile=None): 6 | """ 7 | When we get closer to the end of the round, we start to consider 8 | going for formal tempai. 9 | """ 10 | 11 | result = super(FormalTempaiStrategy, self).should_activate_strategy(tiles_136) 12 | if not result: 13 | return False 14 | 15 | # if we already in tempai, we don't need this strategy 16 | if self.player.in_tempai: 17 | return False 18 | 19 | # it's too early to go for formal tempai before 11th turn 20 | if self.player.round_step < 11: 21 | return False 22 | 23 | # it's 11th turn or later and we still have 3 shanten or more, 24 | # let's try to go for formal tempai at least 25 | if self.player.ai.shanten >= 3: 26 | return True 27 | 28 | if self.player.ai.shanten == 2: 29 | if self.dora_count_total < 2: 30 | # having 0 or 1 dora and 2 shanten, let's go for formal tempai 31 | # starting from 11th turn 32 | return True 33 | # having 2 or more doras and 2 shanten, let's go for formal 34 | # tempai starting from 12th turn 35 | return self.player.round_step >= 12 36 | 37 | # for 1 shanten we check number of doras and ukeire to determine 38 | # correct time to go for formal tempai 39 | if self.player.ai.shanten == 1: 40 | if self.dora_count_total == 0: 41 | if self.player.ai.ukeire <= 16: 42 | return True 43 | 44 | if self.player.ai.ukeire <= 28: 45 | return self.player.round_step >= 12 46 | 47 | return self.player.round_step >= 13 48 | 49 | if self.dora_count_total == 1: 50 | if self.player.ai.ukeire <= 16: 51 | return self.player.round_step >= 12 52 | 53 | if self.player.ai.ukeire <= 28: 54 | return self.player.round_step >= 13 55 | 56 | return self.player.round_step >= 14 57 | 58 | if self.player.ai.ukeire <= 16: 59 | return self.player.round_step >= 13 60 | 61 | return self.player.round_step >= 14 62 | 63 | # we actually never reach here 64 | return False 65 | 66 | def is_tile_suitable(self, tile): 67 | """ 68 | All tiles are suitable for formal tempai. 69 | :param tile: 136 tiles format 70 | :return: True 71 | """ 72 | return True 73 | -------------------------------------------------------------------------------- /tenhou_env/project/utils/logger.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import hashlib 3 | import logging 4 | import os 5 | from logging.handlers import SysLogHandler 6 | 7 | from utils.settings_handler import settings 8 | 9 | LOG_FORMAT = "%(asctime)s %(levelname)s: %(message)s" 10 | DATE_FORMAT = "%Y-%m-%d %H:%M:%S" 11 | 12 | 13 | class ColoredFormatter(logging.Formatter): 14 | """ 15 | Apply only to the console handler. 16 | """ 17 | 18 | green = "\u001b[32m" 19 | cyan = "\u001b[36m" 20 | reset = "\u001b[0m" 21 | 22 | def format(self, record): 23 | format_style = self._fmt 24 | 25 | if record.getMessage().startswith("id="): 26 | format_style = f"{ColoredFormatter.green}{format_style}{ColoredFormatter.reset}" 27 | if record.getMessage().startswith("msg="): 28 | format_style = f"{ColoredFormatter.cyan}{format_style}{ColoredFormatter.reset}" 29 | 30 | formatter = logging.Formatter(format_style) 31 | return formatter.format(record) 32 | 33 | 34 | def set_up_logging(save_to_file=True, print_to_console=True, logger_name="bot"): 35 | """ 36 | Logger for tenhou communication and AI output 37 | """ 38 | logger = logging.getLogger(logger_name) 39 | logger.setLevel(logging.DEBUG) 40 | 41 | if print_to_console: 42 | ch = logging.StreamHandler() 43 | ch.setLevel(logging.DEBUG) 44 | formatter = ColoredFormatter(LOG_FORMAT, datefmt=DATE_FORMAT) 45 | ch.setFormatter(formatter) 46 | 47 | logger.addHandler(ch) 48 | 49 | log_prefix = settings.LOG_PREFIX 50 | if not log_prefix: 51 | log_prefix = hashlib.sha1(settings.USER_ID.encode("utf-8")).hexdigest()[:5] 52 | 53 | if save_to_file: 54 | logs_directory = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "logs") 55 | if not os.path.exists(logs_directory): 56 | os.mkdir(logs_directory) 57 | 58 | formatter = logging.Formatter(LOG_FORMAT, datefmt=DATE_FORMAT) 59 | 60 | # we need it to distinguish different bots logs (if they were run in the same time) 61 | file_name = "{}_{}.log".format(log_prefix, datetime.datetime.now().strftime("%Y-%m-%d_%H_%M_%S")) 62 | 63 | fh = logging.FileHandler(os.path.join(logs_directory, file_name), encoding="utf-8") 64 | fh.setLevel(logging.DEBUG) 65 | fh.setFormatter(formatter) 66 | logger.addHandler(fh) 67 | 68 | if settings.PAPERTRAIL_HOST_AND_PORT: 69 | syslog = SysLogHandler(address=settings.PAPERTRAIL_HOST_AND_PORT) 70 | game_id = f"BOT_{log_prefix}" 71 | 72 | formatter = ColoredFormatter(f"%(asctime)s {game_id}: %(message)s", datefmt=DATE_FORMAT) 73 | syslog.setFormatter(formatter) 74 | 75 | logger.addHandler(syslog) 76 | 77 | return logger 78 | -------------------------------------------------------------------------------- /tenhou_env/project/system_testing/generate_documentation.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from system_testing.cases import ACTION_CRASH, ACTION_DISCARD, ACTION_MELD, SYSTEM_TESTING_CASES 4 | 5 | system_testing_folder = Path(__file__).parent.absolute() 6 | project_folder = Path(__file__).parent.parent.parent.absolute() 7 | 8 | 9 | class DocGen: 10 | @staticmethod 11 | def generate_documentation(): 12 | doc_file = system_testing_folder.parent.parent / "doc" / "system_testing.md" 13 | doc_content = [] 14 | 15 | doc_content.append("WARNING! It is an autogenerated file, don't change it manually.") 16 | 17 | doc_content.append("# System testing") 18 | doc_content.append( 19 | "The documentation contains steps to reproduce real game situations and the description of " 20 | "the result that we want to have after bot turn (discard an exact tile, meld, skip meld)." 21 | ) 22 | doc_content.append( 23 | "We are using these cases in automated tests to be sure " 24 | "that we don't have regressions in the bot logic after new changes." 25 | ) 26 | doc_content.append("And this documentation created to help debug filed unit tests.") 27 | 28 | for case in SYSTEM_TESTING_CASES: 29 | index = case["index"] 30 | relative_image_path = (system_testing_folder / "fixtures" / f"{index}.jpg").relative_to(project_folder) 31 | 32 | doc_content.append(f"## Case {index}") 33 | if case.get("skip_reason"): 34 | doc_content.append(f'SKIPPED: **{case.get("skip_reason")}**') 35 | 36 | if case["action"] == ACTION_DISCARD: 37 | doc_content.append( 38 | f"Action: `{ACTION_DISCARD}`, allowed discard: `{', '.join(case['allowed_discards'])}`," 39 | f" with riichi: `{case['with_riichi']}`." 40 | ) 41 | 42 | if case["action"] == ACTION_MELD: 43 | doc_content.append( 44 | f"Action: `{ACTION_MELD}`, meld: `{case['meld']}`, tile after meld: `{case['tile_after_meld']}`." 45 | ) 46 | 47 | if case["action"] == ACTION_CRASH: 48 | doc_content.append(f"Action: `{ACTION_CRASH}`.") 49 | doc_content.append("We are checking that bot doesnt crash on this action anymore.") 50 | 51 | if case["description"]: 52 | doc_content.append(case["description"]) 53 | 54 | doc_content.append("Reproduce:") 55 | doc_content.append("> " + case["reproducer_command"]) 56 | 57 | if case["action"] != ACTION_CRASH: 58 | doc_content.append(f"![image](../{relative_image_path})") 59 | 60 | doc_file.write_text("\n\n".join(doc_content)) 61 | -------------------------------------------------------------------------------- /tenhou_env/project/game/ai/strategies/tests/test_chiitoitsu.py: -------------------------------------------------------------------------------- 1 | from game.ai.strategies.main import BaseStrategy 2 | from game.table import Table 3 | from utils.test_helpers import string_to_136_array, string_to_136_tile, tiles_to_string 4 | 5 | 6 | def test_should_activate_strategy(): 7 | table = Table() 8 | player = table.player 9 | 10 | # obvious chiitoitsu, let's activate 11 | tiles = string_to_136_array(sou="2266", man="3399", pin="289", honors="11") 12 | player.init_hand(tiles) 13 | player.draw_tile(string_to_136_tile(honors="6")) 14 | 15 | # less than 5 pairs, don't activate 16 | tiles = string_to_136_array(sou="2266", man="3389", pin="289", honors="11") 17 | player.draw_tile(string_to_136_tile(honors="6")) 18 | player.init_hand(tiles) 19 | 20 | # 5 pairs, but we are already tempai, let's no consider this hand as chiitoitsu 21 | tiles = string_to_136_array(sou="234", man="223344", pin="5669") 22 | player.init_hand(tiles) 23 | player.draw_tile(string_to_136_tile(pin="5")) 24 | player.discard_tile() 25 | 26 | tiles = string_to_136_array(sou="234", man="22334455669") 27 | player.init_hand(tiles) 28 | 29 | 30 | def test_dont_call_meld(): 31 | table = Table() 32 | player = table.player 33 | 34 | tiles = string_to_136_array(sou="112234", man="2334499") 35 | player.init_hand(tiles) 36 | 37 | tile = string_to_136_tile(man="9") 38 | meld, _ = player.try_to_call_meld(tile, True) 39 | assert meld is None 40 | 41 | 42 | def test_keep_chiitoitsu_tempai(): 43 | table = Table() 44 | player = table.player 45 | 46 | tiles = string_to_136_array(sou="113355", man="22669", pin="99") 47 | player.init_hand(tiles) 48 | 49 | player.draw_tile(string_to_136_tile(man="6")) 50 | 51 | discard, _ = player.discard_tile() 52 | assert tiles_to_string([discard]) == "6m" 53 | 54 | 55 | def test_5_pairs_yakuhai_not_chiitoitsu(): 56 | table = Table() 57 | player = table.player 58 | 59 | table.add_dora_indicator(string_to_136_tile(sou="9")) 60 | table.add_dora_indicator(string_to_136_tile(sou="1")) 61 | 62 | tiles = string_to_136_array(sou="112233", pin="16678", honors="66") 63 | player.init_hand(tiles) 64 | 65 | tile = string_to_136_tile(honors="6") 66 | meld, _ = player.try_to_call_meld(tile, True) 67 | 68 | assert player.ai.current_strategy.type == BaseStrategy.YAKUHAI 69 | 70 | assert meld is not None 71 | 72 | 73 | def chiitoitsu_tanyao_tempai(): 74 | table = Table() 75 | player = table.player 76 | 77 | tiles = string_to_136_array(sou="223344", pin="788", man="4577") 78 | player.init_hand(tiles) 79 | 80 | player.draw_tile(string_to_136_tile(man="4")) 81 | 82 | discard = player.discard_tile() 83 | discard_correct = tiles_to_string([discard]) == "7p" or tiles_to_string([discard]) == "5m" 84 | assert discard_correct is True 85 | -------------------------------------------------------------------------------- /tenhou_env/project/statistics/log_parser.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import List 3 | 4 | 5 | class LogParser: 6 | def split_log_to_game_rounds(self, log_content: str) -> List[List[str]]: 7 | """ 8 | XML parser was really slow here, 9 | so I built simple parser to separate log content on tags (grouped by rounds) 10 | """ 11 | tag_start = 0 12 | rounds = [] 13 | tag = None 14 | 15 | current_round_tags = [] 16 | for x in range(0, len(log_content)): 17 | if log_content[x] == ">": 18 | tag = log_content[tag_start: x + 1] 19 | tag_start = x + 1 20 | 21 | # not useful tags 22 | skip_tags = ["SHUFFLE", "TAIKYOKU", "mjloggm"] 23 | if tag and any([x in tag for x in skip_tags]): 24 | tag = None 25 | 26 | # new hand was started 27 | if self.is_init_tag(tag) and current_round_tags: 28 | rounds.append(current_round_tags) 29 | current_round_tags = [] 30 | 31 | # the end of the game 32 | if tag and "owari" in tag: 33 | rounds.append(current_round_tags) 34 | 35 | if tag: 36 | if self.is_init_tag(tag): 37 | # we dont need seed information 38 | # it appears in old logs format 39 | find = re.compile(r'shuffle="[^"]*"') 40 | tag = find.sub("", tag) 41 | 42 | # add processed tag to the round 43 | current_round_tags.append(tag) 44 | tag = None 45 | 46 | return rounds 47 | 48 | def get_attribute_content(self, tag: str, attribute_name: str): 49 | result = re.findall(r'{}="([^"]*)"'.format(attribute_name), tag) 50 | return result and result[0] or None 51 | 52 | def comma_separated_string_to_ints(self, string: str): 53 | return [int(x) for x in string.split(",")] 54 | 55 | def is_init_tag(self, tag): 56 | return tag and "INIT" in tag 57 | 58 | def is_agari_tag(self, tag): 59 | return tag and "AGARI" in tag 60 | 61 | def is_start_game_tag(self, tag): 62 | return tag and "= 31 12 | 13 | 14 | # TODO move to mahjong lib 15 | def is_tiles_same_suit(first_tile_34, second_tile_34): 16 | if is_pin(first_tile_34) and is_pin(second_tile_34): 17 | return True 18 | if is_man(first_tile_34) and is_man(second_tile_34): 19 | return True 20 | if is_sou(first_tile_34) and is_sou(second_tile_34): 21 | return True 22 | return False 23 | 24 | 25 | # TODO move to mahjong lib 26 | def is_dora_connector(tile_136: int, dora_indicators_136: List[int]) -> bool: 27 | tile_34 = tile_136 // 4 28 | if is_honor(tile_34): 29 | return False 30 | 31 | for dora_indicator in dora_indicators_136: 32 | dora_indicator_34 = dora_indicator // 4 33 | if not is_tiles_same_suit(dora_indicator_34, tile_34): 34 | continue 35 | 36 | simplified_tile = simplify(tile_34) 37 | simplified_dora_indicator = simplify(dora_indicator_34) 38 | 39 | if simplified_dora_indicator - 1 == simplified_tile: 40 | return True 41 | 42 | if simplified_dora_indicator + 1 == simplified_tile: 43 | return True 44 | 45 | return False 46 | 47 | 48 | def make_random_letters_and_digit_string(length=15): 49 | random_chars = string.ascii_lowercase + string.digits 50 | return "".join(random.choice(random_chars) for _ in range(length)) 51 | 52 | 53 | def revealed_suits_tiles(player, tiles_34): 54 | """ 55 | Return all reviled tiles separated by suits for provided tiles list 56 | """ 57 | return _suits_tiles_helper( 58 | tiles_34, lambda _tile_34_index, _tiles_34: player.number_of_revealed_tiles(_tile_34_index, _tiles_34) 59 | ) 60 | 61 | 62 | def separate_tiles_by_suits(tiles_34): 63 | """ 64 | Return tiles separated by suits for provided tiles list 65 | """ 66 | return _suits_tiles_helper(tiles_34, lambda _tile_34_index, _tiles_34: _tiles_34[_tile_34_index]) 67 | 68 | 69 | def _suits_tiles_helper(tiles_34, total_tiles_lambda): 70 | """ 71 | Separate tiles by suit 72 | """ 73 | suits = [ 74 | [0] * 9, 75 | [0] * 9, 76 | [0] * 9, 77 | ] 78 | 79 | for tile_34_index in range(0, EAST): 80 | total_tiles = total_tiles_lambda(tile_34_index, tiles_34) 81 | if not total_tiles: 82 | continue 83 | 84 | suit_index = None 85 | simplified_tile = simplify(tile_34_index) 86 | 87 | if is_man(tile_34_index): 88 | suit_index = 0 89 | 90 | if is_pin(tile_34_index): 91 | suit_index = 1 92 | 93 | if is_sou(tile_34_index): 94 | suit_index = 2 95 | 96 | suits[suit_index][simplified_tile] += total_tiles 97 | 98 | return suits 99 | -------------------------------------------------------------------------------- /tenhou_env/project/game/ai/strategies/tests/test_formal_tempai.py: -------------------------------------------------------------------------------- 1 | from game.ai.strategies.formal_tempai import FormalTempaiStrategy 2 | from game.ai.strategies.main import BaseStrategy 3 | from game.table import Table 4 | from mahjong.tile import Tile 5 | from utils.decisions_logger import MeldPrint 6 | from utils.test_helpers import make_meld, string_to_136_array, string_to_136_tile, tiles_to_string 7 | 8 | 9 | def test_should_activate_strategy(): 10 | table = Table() 11 | table.player.dealer_seat = 3 12 | 13 | strategy = FormalTempaiStrategy(BaseStrategy.FORMAL_TEMPAI, table.player) 14 | 15 | tiles = string_to_136_array(sou="12355689", man="89", pin="339") 16 | table.player.init_hand(tiles) 17 | assert strategy.should_activate_strategy(table.player.tiles) is False 18 | 19 | # Let's move to 10th round step 20 | for _ in range(0, 10): 21 | table.player.add_discarded_tile(Tile(0, False)) 22 | 23 | assert strategy.should_activate_strategy(table.player.tiles) is False 24 | 25 | # Now we move to 11th turn, we have 2 shanten and no doras, 26 | # we should go for formal tempai 27 | table.player.add_discarded_tile(Tile(0, True)) 28 | assert strategy.should_activate_strategy(table.player.tiles) is True 29 | 30 | 31 | def test_get_tempai(): 32 | table = Table() 33 | table.player.dealer_seat = 3 34 | 35 | tiles = string_to_136_array(man="2379", sou="4568", pin="22299") 36 | table.player.init_hand(tiles) 37 | 38 | # Let's move to 15th round step 39 | for _ in range(0, 15): 40 | table.player.add_discarded_tile(Tile(0, False)) 41 | 42 | tile = string_to_136_tile(man="8") 43 | meld, _ = table.player.try_to_call_meld(tile, True) 44 | assert meld is not None 45 | assert tiles_to_string(meld.tiles) == "789m" 46 | 47 | # reinit hand with meld 48 | tiles = string_to_136_array(man="23789", sou="4568", pin="22299") 49 | table.player.init_hand(tiles) 50 | table.player.add_called_meld(meld) 51 | 52 | tile_to_discard, _ = table.player.discard_tile() 53 | assert tiles_to_string([tile_to_discard]) == "8s" 54 | 55 | 56 | def test_dont_meld_agari(): 57 | """ 58 | We shouldn't open when we are already in tempai expect for some special cases 59 | """ 60 | table = Table() 61 | table.player.dealer_seat = 3 62 | 63 | strategy = FormalTempaiStrategy(BaseStrategy.FORMAL_TEMPAI, table.player) 64 | 65 | tiles = string_to_136_array(man="2379", sou="4568", pin="22299") 66 | table.player.init_hand(tiles) 67 | 68 | # Let's move to 15th round step 69 | for _ in range(0, 15): 70 | table.player.add_discarded_tile(Tile(0, False)) 71 | 72 | assert strategy.should_activate_strategy(table.player.tiles) is True 73 | 74 | tiles = string_to_136_array(man="23789", sou="456", pin="22299") 75 | table.player.init_hand(tiles) 76 | 77 | meld = make_meld(MeldPrint.CHI, man="789") 78 | table.player.add_called_meld(meld) 79 | 80 | tile = string_to_136_tile(man="4") 81 | meld, _ = table.player.try_to_call_meld(tile, True) 82 | assert meld is None 83 | -------------------------------------------------------------------------------- /tenhou_env/project/system_testing/fixtures/23.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /logs_crawler/README.md: -------------------------------------------------------------------------------- 1 | Tested with **Python 3.5+** 2 | 3 | # Logs downloader 4 | 5 | Tools to download phoenix replays from tenhou.net. 6 | 7 | For example, these logs can be useful for machine learning. 8 | 9 | This repo contains two main scripts: 10 | 11 | - Download and store log IDs. 12 | It can both obtain game IDs from year archive (e.g., http://tenhou.net/sc/raw/scraw2009.zip) 13 | or from latest phoenix games page (http://tenhou.net/sc/raw/list.cgi). 14 | - Download logs content for already collected log IDs. 15 | 16 | # Installation 17 | 18 | Just install requirements with command `pip install -r requirements.txt` 19 | 20 | # Download historical log IDs 21 | 22 | For example, we want to download game IDs for the 2009 year (keep in mind that phoenix games started to appear only from the 2009 year). 23 | 24 | Input command: 25 | ``` 26 | python main.py -a id -y 2009 27 | ``` 28 | 29 | If the script is doing download really slow, you can download the archive with `wget` or your browser and put it in the `temp` folder. 30 | 31 | Example: Download http://tenhou.net/sc/raw/scraw2009.zip and put it to the `temp/scraw2009.zip`. In that case, the script will skip the downloading step. 32 | 33 | Output: 34 | ``` 35 | Set up new database /path/to/db/2009.db 36 | Downloading... scraw2009.zip 37 | [==================================================] 50822/50822 38 | Downloaded 39 | Extracting archive... 40 | Extracted 41 | Preparing the list of games... 42 | Found 80156 games 43 | Temp folder was removed 44 | Inserting new IDs to the database... 45 | Done 46 | ``` 47 | 48 | # Download latest log IDs 49 | 50 | To download games from 1 January (current year) until (current day - 7 days) specify `-s` flag: 51 | 52 | `python main.py -a id -s` 53 | 54 | To download just log IDs from the latest 7 days: 55 | 56 | `python main.py -a id` 57 | 58 | You can add this command to the cron (for example to run each one hour) and it will add new log IDs to the DB. 59 | 60 | # Download log content 61 | 62 | To download log content for already downloaded IDs use this command: 63 | 64 | `python main.py -a content -y 2009 -l 50 -t 3` 65 | 66 | Where is `-l` is how many items to download and `-t` is the number of threads to use. 67 | 68 | It will create N threads and parallel downloads. 69 | 70 | You can choose that `-l` and `-t` numbers to download logs that will take ~one minute and add this command to a cron job. 71 | I used `-l 180 -t 5` for my downloads. 72 | 73 | # Data consistency checking 74 | 75 | Sometimes log content can't be downloaded because of different reasons (e.g., internet connection issues, tenhou server responsibility). 76 | 77 | And sometimes tenhou returns for log A content from log B and it causes the same log content for different log IDs in our DB. 78 | 79 | For example for the 2009 year (with total 80156 logs) I had ~1500 not downloaded logs and ~800 logs with double content. 80 | So, ~2.9% of records had issues at the end of the downloading process. 81 | 82 | To fix these issues run this command: 83 | 84 | `python debug.py -y 2009` 85 | 86 | It will detect and add all broken records to the download queue again and you can redownload them as usual. -------------------------------------------------------------------------------- /tenhou_env/project/game/ai/defence/yaku_analyzer/toitoi.py: -------------------------------------------------------------------------------- 1 | from copy import copy 2 | 3 | from game.ai.defence.yaku_analyzer.tanyao import TanyaoAnalyzer 4 | from game.ai.defence.yaku_analyzer.yaku_analyzer import YakuAnalyzer 5 | from game.ai.helpers.defence import TileDanger 6 | from mahjong.tile import TilesConverter 7 | from mahjong.utils import plus_dora 8 | from utils.decisions_logger import MeldPrint 9 | 10 | 11 | class ToitoiAnalyzer(YakuAnalyzer): 12 | id = "toitoi" 13 | 14 | def __init__(self, enemy): 15 | self.enemy = enemy 16 | self.table = enemy.table 17 | 18 | # is our bot 19 | self.main_player = self.table.player 20 | 21 | def serialize(self): 22 | return {"id": self.id} 23 | 24 | def is_yaku_active(self): 25 | if len(self.enemy.melds) < 2: 26 | return False 27 | 28 | for meld in self.enemy.melds: 29 | if meld.type == MeldPrint.CHI: 30 | return False 31 | 32 | if len(self.enemy.discards) < 10: 33 | return len(self.enemy.melds) >= 3 34 | 35 | return True 36 | 37 | def melds_han(self): 38 | return 2 39 | 40 | def get_safe_tiles_34(self): 41 | safe_tiles_34 = [] 42 | closed_hand_34 = TilesConverter.to_34_array(self.main_player.closed_hand) 43 | for tile_34 in range(0, 34): 44 | number_of_revealed_tiles = self.main_player.number_of_revealed_tiles(tile_34, closed_hand_34) 45 | if number_of_revealed_tiles == 4: 46 | safe_tiles_34.append(tile_34) 47 | 48 | return safe_tiles_34 49 | 50 | def get_bonus_danger(self, tile_136, number_of_revealed_tiles): 51 | bonus_danger = [] 52 | tile_34 = tile_136 // 4 53 | number_of_yakuhai = self.enemy.valued_honors.count(tile_34) 54 | 55 | # shonpai tiles 56 | if number_of_revealed_tiles == 1: 57 | # aka doras don't get additional danger against toitoi, they just get their regular one 58 | dora_count = plus_dora(tile_136, self.enemy.table.dora_indicators) 59 | if dora_count > 0: 60 | danger = copy(TileDanger.TOITOI_SHONPAI_DORA_BONUS_DANGER) 61 | danger["value"] = dora_count * danger["value"] 62 | danger["dora_count"] = dora_count 63 | bonus_danger.append(danger) 64 | 65 | if number_of_yakuhai > 0: 66 | bonus_danger.append(TileDanger.TOITOI_SHONPAI_YAKUHAI_BONUS_DANGER) 67 | else: 68 | bonus_danger.append(TileDanger.TOITOI_SHONPAI_NON_YAKUHAI_BONUS_DANGER) 69 | elif number_of_revealed_tiles == 2: 70 | if number_of_yakuhai > 0: 71 | bonus_danger.append(TileDanger.TOITOI_SECOND_YAKUHAI_HONOR_BONUS_DANGER) 72 | elif number_of_revealed_tiles == 3: 73 | # FIXME: we should add negative bonus danger exclusively against toitoi for such tiles 74 | # except for doras and honors maybe 75 | pass 76 | 77 | return bonus_danger 78 | 79 | def is_absorbed(self, possible_yaku, tile_34=None): 80 | return self._is_absorbed_by(possible_yaku, TanyaoAnalyzer.id, tile_34) 81 | -------------------------------------------------------------------------------- /logs_crawler/debug.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | import sqlite3 4 | from optparse import OptionParser 5 | from datetime import datetime 6 | 7 | db_folder = os.path.join(os.path.dirname(os.path.realpath(__file__)), "db") 8 | 9 | 10 | def main(): 11 | parser = OptionParser() 12 | parser.add_option("-y", "--year", type="string", default=str(datetime.now().year), help="Target year") 13 | parser.add_option("-p", "--db_path", type="string") 14 | opts, _ = parser.parse_args() 15 | 16 | if opts.db_path: 17 | db_file = opts.db_path 18 | else: 19 | db_file = os.path.join(db_folder, f"{opts.year}.db") 20 | 21 | connection = sqlite3.connect(db_file) 22 | 23 | with connection: 24 | cursor = connection.cursor() 25 | 26 | cursor.execute("SELECT COUNT(*) from logs;") 27 | total = cursor.fetchone()[0] 28 | 29 | cursor.execute("SELECT COUNT(*) from logs where is_processed = 1;") 30 | processed = cursor.fetchone()[0] 31 | 32 | cursor.execute("SELECT COUNT(*) from logs where was_error = 1;") 33 | with_errors = cursor.fetchone()[0] 34 | 35 | print("Total: {}".format(total)) 36 | print("Processed: {}".format(processed)) 37 | print("With errors: {}".format(with_errors)) 38 | 39 | was_errors = False 40 | 41 | if with_errors > 0: 42 | was_errors = True 43 | print("") 44 | print("WARNING!") 45 | print("There are {} records with errors".format(with_errors)) 46 | print("It means that they weren't downloaded properly") 47 | cursor.execute( 48 | 'UPDATE logs set is_processed = 0, was_error = 0, log_hash="", log_content="" where was_error = 1' 49 | ) 50 | print("{} records were added to the download queue again".format(with_errors)) 51 | print("") 52 | 53 | cursor.execute( 54 | "SELECT COUNT(log_hash) AS count, log_hash FROM logs GROUP BY log_hash ORDER BY count DESC;" 55 | ) 56 | not_unique_hashes = [x for x in cursor.fetchall() if x[0] > 1 and x[1]] 57 | not_unique_hashes = [x[1] for x in not_unique_hashes] 58 | count_of_not_unique = len(not_unique_hashes) 59 | 60 | if count_of_not_unique: 61 | was_errors = True 62 | print("") 63 | print("WARNING!") 64 | print("There are {} not unique hashes in the DB".format(count_of_not_unique)) 65 | print("It is happens because sometimes tenhou return content that belongs to other log") 66 | s = ",".join(["'{}'".format(x) for x in not_unique_hashes]) 67 | cursor.execute( 68 | 'UPDATE logs set is_processed = 0, was_error = 0, log_hash="", log_content="" where log_hash in ({});'.format( 69 | s 70 | ) 71 | ) 72 | print("{} records were added to the download queue again".format(count_of_not_unique)) 73 | print("") 74 | 75 | if not was_errors: 76 | print("") 77 | print("Everything is fine") 78 | print("") 79 | 80 | 81 | if __name__ == "__main__": 82 | main() 83 | -------------------------------------------------------------------------------- /tenhou_env/project/actor_learner.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import tensorflow as tf 3 | from tensorflow import keras 4 | import ray 5 | from tenhou.client import TenhouClient 6 | from utils.logger import set_up_logging 7 | from game.ai.configs.default import BotDefaultConfig 8 | from game.ai.models import make_or_restore_model 9 | from game.ai.utils import * 10 | from keras import Input 11 | from keras.optimizers import Adam 12 | 13 | class Learner: 14 | def __init__(self, opt, model_type): 15 | self.opt = opt 16 | self.model_type = model_type 17 | 18 | self.actor = make_or_restore_model(input_shape_dict[model_type], model_type, "local") 19 | state_input = Input(self.actor.input.shape) 20 | advantage = Input(shape=(1,)) 21 | old_prediction = Input(shape=(actor.output.shape)) 22 | 23 | output = self.actor(state_input) 24 | self.model = keras.Model(inputs=[state_input, advantage, old_prediction], outputs=[output]) 25 | self.model.compile(optimizer=Adam(lr=(LR)), 26 | loss=[proximal_policy_optimization_loss( 27 | advantage=advantage, 28 | old_prediction = old_prediction 29 | )]) 30 | self.model.summary() 31 | 32 | def get_weights(self): 33 | return self.actor.get_weights() 34 | 35 | def set_weights(self, weights): 36 | self.actor.set_weights(weights) 37 | 38 | def train(self, batch, cnt): 39 | feature, advantage, old_prediction, action = batch 40 | actor_loss = self.model.fit(x=[feature, advantage, old_prediction], y=[action], shuffle=True, epochs=EPOCHS, verbose=False) 41 | # writer 42 | # self.writer.add_scalar('Actor loss', actor_loss.history['loss'][-1], self.gradient_steps) 43 | if cnt % 500 == 0: 44 | pass #TO DO, do some summary writer thing 45 | 46 | 47 | class Actor: 48 | def __init__(self, opt, job, buffer): 49 | self.opt = opt 50 | self.job = job 51 | self.bot_config = BotDefaultConfig() 52 | self.bot_config.buffer = buffer 53 | 54 | def set_weights(self, weights): 55 | self.bot_config.weights = weights #a dict for all models 56 | 57 | def get_weights(self): 58 | pass 59 | 60 | def run(self): 61 | logger = set_up_logging() 62 | 63 | client = TenhouClient(logger, bot_config=self.bot_config) 64 | 65 | for _ in range(self.opt.num_games): 66 | client.connect() 67 | 68 | try: 69 | was_auth = client.authenticate() 70 | 71 | if was_auth: 72 | client.start_game() 73 | else: 74 | client.end_game() 75 | except KeyboardInterrupt: 76 | logger.info("Ending the game...") 77 | client.end_game() 78 | except Exception as e: 79 | logger.exception("Unexpected exception", exc_info=e) 80 | logger.info("Ending the game...") 81 | client.end_game(False) 82 | 83 | client.table.player.ai.write_buffer() 84 | -------------------------------------------------------------------------------- /RL/policy_gradient.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import tensorflow as tf 3 | import tensorflow.keras as keras 4 | from tensorflow.keras.layers import Dense 5 | import tensorflow_probability as tfp 6 | from tensorflow.keras.optimizers import Adam 7 | 8 | 9 | class PolicyGradientNN(keras.Model): 10 | def __init__(self, n_actions, fc1_dims, fc2_dims): 11 | super(PolicyGradientNN, self).__init__() 12 | self.fc1_dims = fc1_dims 13 | self.fc2_dims = fc2_dims 14 | self.n_actions = n_actions 15 | 16 | self.fc1 = Dense(self.fc1_dims, activation="relu") 17 | self.fc2 = Dense(self.fc2_dims, activation="relu") 18 | self.pi = Dense(n_actions, activation="softmax") 19 | 20 | def call(self, state): 21 | """ 22 | 23 | """ 24 | first_layer_value = self.fc1(state) 25 | second_layer_value = self.fc2(first_layer_value) 26 | final_value = self.pi(second_layer_value) 27 | return final_value 28 | 29 | 30 | class Agent: 31 | def __init__(self, alpha=0.003, gamma=0.99, n_actions=4, layer1_size=256, layer2_size=256): 32 | self.gamma = gamma 33 | self.lr = alpha 34 | self.n_actions = n_actions 35 | self.state_memory = [] 36 | self.action_memory = [] 37 | self.reward_memory = [] 38 | self.policy = PolicyGradientNN(n_actions, layer1_size, layer2_size) 39 | self.policy.compile(optimizer=Adam(learning_rate=self.lr)) 40 | 41 | def choose_action(self, observation): 42 | state = tf.convert_to_tensor([observation], dtype=tf.float32) 43 | probs = self.policy(state) 44 | action_probs = tfp.distributions.Categorical(probs=probs) 45 | action = action_probs.sample() 46 | return action.numpy()[0] 47 | 48 | def store_transition(self, observation, action, reward): 49 | self.state_memory.append(observation) 50 | self.action_memory.append(action) 51 | self.reward_memory.append(reward) 52 | 53 | def learn(self): 54 | actions = tf.convert_to_tensor(self.action_memory, dtype=tf.float32) 55 | rewards = np.array(self.reward_memory) ## Need to be changed 56 | G = np.zeros_like(rewards) 57 | for t in range(len(rewards)): 58 | G_sum = 0 59 | discount = 1 60 | for k in range(t, len(rewards)): 61 | G_sum = G_sum + rewards[k] * discount 62 | discount = discount * self.gamma 63 | G[t] = G_sum 64 | with tf.GradientTape() as U: 65 | loss = 0 66 | for index, (g, state) in enumerate(zip(G, self.state_memory)): 67 | state = tf.convert_to_tensor([state], dtype=tf.float32) 68 | probs = self.policy(state) 69 | action_probs = tfp.distributions.Categorical(probs=probs) 70 | log_prob = action_probs.log_prob(actions[index]) 71 | loss += -g * tf.squeeze(log_prob) 72 | grad = U.gradient(loss, self.policy.trainable_variables) 73 | self.policy.optimizer.apply_gradients(zip(grad, self.policy.trainable_variables)) 74 | self.state_memory = [] 75 | self.action_memory = [] 76 | self.reward_memory = [] 77 | 78 | -------------------------------------------------------------------------------- /tenhou_env/project/main.py: -------------------------------------------------------------------------------- 1 | """ 2 | Endpoint to run bot. It will play a game on tenhou.net 3 | """ 4 | import importlib 5 | from optparse import OptionParser 6 | 7 | from tenhou.main import connect_and_play 8 | from utils.settings_handler import settings 9 | 10 | 11 | def parse_args_and_set_up_settings(): 12 | parser = OptionParser() 13 | 14 | parser.add_option( 15 | "-u", 16 | "--user_id", 17 | type="string", 18 | default=settings.USER_ID, 19 | help="Tenhou's user id. Example: IDXXXXXXXX-XXXXXXXX. Default is {0}".format(settings.USER_ID), 20 | ) 21 | parser.add_option( 22 | "-g", 23 | "--game_type", 24 | type="string", 25 | default=settings.GAME_TYPE, 26 | help="The game type in Tenhou.net. Examples: 1 or 9. Default is {0}".format(settings.GAME_TYPE), 27 | ) 28 | parser.add_option( 29 | "-l", 30 | "--lobby", 31 | type="string", 32 | default=settings.LOBBY, 33 | help="Lobby to play. Default is {0}".format(settings.LOBBY), 34 | ) 35 | parser.add_option( 36 | "-t", 37 | "--timeout", 38 | type="int", 39 | default=settings.WAITING_GAME_TIMEOUT_MINUTES, 40 | help="How much minutes bot will looking for a game. " 41 | "If game is not started in timeout, script will be ended. " 42 | "Default is {0}".format(settings.WAITING_GAME_TIMEOUT_MINUTES), 43 | ) 44 | parser.add_option( 45 | "-c", 46 | "--championship", 47 | type="string", 48 | help="Tournament lobby to play.", 49 | ) 50 | parser.add_option( 51 | "-s", 52 | "--settings", 53 | type="string", 54 | default=None, 55 | help="Settings file name (without path, just file name without extension)", 56 | ) 57 | 58 | opts, _ = parser.parse_args() 59 | 60 | settings.USER_ID = opts.user_id 61 | settings.GAME_TYPE = opts.game_type 62 | settings.LOBBY = opts.lobby 63 | settings.WAITING_GAME_TIMEOUT_MINUTES = opts.timeout 64 | 65 | if opts.settings: 66 | module = importlib.import_module(f"settings.{opts.settings}") 67 | for key, value in vars(module).items(): 68 | # let's use only upper case settings 69 | if key.isupper(): 70 | settings.__setattr__(key, value) 71 | 72 | if opts.championship: 73 | settings.IS_TOURNAMENT = True 74 | settings.LOBBY = opts.championship 75 | 76 | 77 | def main(): 78 | parse_args_and_set_up_settings() 79 | 80 | if settings.SENTRY_URL: 81 | import sentry_sdk 82 | 83 | sentry_sdk.init( 84 | settings.SENTRY_URL, 85 | traces_sample_rate=1.0, 86 | ) 87 | 88 | # remove this after debugging 89 | # from tenhou.client import TenhouClient 90 | # from utils.logger import set_up_logging 91 | # logger = set_up_logging() 92 | # client = TenhouClient(logger) 93 | 94 | # print('###### loading finished #######') 95 | # return 96 | connect_and_play() 97 | 98 | 99 | if __name__ == "__main__": 100 | main() 101 | -------------------------------------------------------------------------------- /tenhou_env/doc/reproducer.md: -------------------------------------------------------------------------------- 1 | # Game reproducer 2 | 3 | We built the way to reproduce already played round. 4 | 5 | This is really helpful when you want to reproduce table state and fix bot incorrect behaviour. 6 | 7 | There are two options to do it. 8 | 9 | ## Getting game meta information 10 | 11 | It will be easier to find the round number to reproduce if you check the game meta-information first: 12 | 13 | Command: 14 | ```bash 15 | python reproducer.py --log 2020102008gm-0001-7994-9438a8f4 --meta 16 | ``` 17 | 18 | Output: 19 | ```json 20 | { 21 | "players": [ 22 | { 23 | "seat": 0, 24 | "name": "Wanjirou", 25 | "rank": "新人" 26 | }, 27 | { 28 | "seat": 1, 29 | "name": "Kaavi", 30 | "rank": "新人" 31 | }, 32 | { 33 | "seat": 2, 34 | "name": "Xenia", 35 | "rank": "新人" 36 | }, 37 | { 38 | "seat": 3, 39 | "name": "Ichihime", 40 | "rank": "新人" 41 | } 42 | ], 43 | "game_rounds": [ 44 | { 45 | "wind": 0, 46 | "honba": 0, 47 | "round_start_scores": [ 48 | 250, 49 | 250, 50 | 250, 51 | 250 52 | ] 53 | }, 54 | { 55 | "wind": 1, 56 | "honba": 1, 57 | "round_start_scores": [ 58 | 235, 59 | 235, 60 | 265, 61 | 265 62 | ] 63 | }, 64 | { 65 | "wind": 1, 66 | "honba": 2, 67 | "round_start_scores": [ 68 | 221, 69 | 277, 70 | 251, 71 | 251 72 | ] 73 | }, 74 | { 75 | "wind": 1, 76 | "honba": 3, 77 | "round_start_scores": [ 78 | 221, 79 | 298, 80 | 230, 81 | 251 82 | ] 83 | }, 84 | { 85 | "wind": 2, 86 | "honba": 0, 87 | "round_start_scores": [ 88 | 320, 89 | 255, 90 | 197, 91 | 228 92 | ] 93 | }, 94 | { 95 | "wind": 3, 96 | "honba": 0, 97 | "round_start_scores": [ 98 | 290, 99 | 215, 100 | 137, 101 | 358 102 | ] 103 | } 104 | ] 105 | } 106 | ``` 107 | 108 | From this information player seat and wind number could be useful for the next command run. 109 | 110 | ## Running the reproducing for the game 111 | 112 | To reproduce game situation you need to know: 113 | - log id 114 | - player seat number or player nickname 115 | - wind number (1-4 for east, 5-8 for south, 9-12 for west) 116 | - honba number 117 | - tile where to stop the game 118 | - action 119 | 120 | There are two supported actions for the reproducer: 121 | - `draw`. Sought tile will be added to the hand, then method `discard_tile()` will be called and after that reproducer will stop. 122 | - `enemy_discard`. After enemy discard method `try_to_call_meld()` will be called (if possible) and after that reproducer will stop. 123 | 124 | ## Examples of usage 125 | 126 | ```bash 127 | python reproducer.py --log 2020102008gm-0001-7994-9438a8f4 --player Wanjirou --wind 3 --honba 0 --tile 7p --action enemy_discard 128 | ``` 129 | 130 | ```bash 131 | python reproducer.py --log 2020102009gm-0001-7994-5e2f46c0 --player Kaavi --wind 3 --honba 1 --tile 5m --action draw 132 | ``` 133 | 134 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Python template 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | env/ 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *,cover 48 | .hypothesis/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | 58 | # Flask stuff: 59 | instance/ 60 | .webassets-cache 61 | 62 | # Scrapy stuff: 63 | .scrapy 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # IPython Notebook 72 | .ipynb_checkpoints 73 | 74 | # pyenv 75 | .python-version 76 | 77 | # celery beat schedule file 78 | celerybeat-schedule 79 | 80 | # dotenv 81 | .env 82 | 83 | # virtualenv 84 | venv/ 85 | ENV/ 86 | 87 | # Spyder project settings 88 | .spyderproject 89 | 90 | # Rope project settings 91 | .ropeproject 92 | ### VirtualEnv template 93 | # Virtualenv 94 | # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ 95 | .Python 96 | [Bb]in 97 | [Ii]nclude 98 | [Ll]ib 99 | [Ll]ib64 100 | [Ll]ocal 101 | [Ss]cripts 102 | pyvenv.cfg 103 | .venv 104 | pip-selfcheck.json 105 | ### JetBrains template 106 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 107 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 108 | 109 | # User-specific stuff: 110 | .idea/workspace.xml 111 | .idea/tasks.xml 112 | .idea/dictionaries 113 | .idea/vcs.xml 114 | .idea/jsLibraryMappings.xml 115 | 116 | # Sensitive or high-churn files: 117 | .idea/dataSources.ids 118 | .idea/dataSources.xml 119 | .idea/dataSources.local.xml 120 | .idea/sqlDataSources.xml 121 | .idea/dynamic.xml 122 | .idea/uiDesigner.xml 123 | 124 | # Gradle: 125 | .idea/gradle.xml 126 | .idea/libraries 127 | 128 | # Mongo Explorer plugin: 129 | .idea/mongoSettings.xml 130 | 131 | .idea/ 132 | 133 | ## File-based project format: 134 | *.iws 135 | 136 | ## Plugin-specific files: 137 | 138 | # IntelliJ 139 | /out/ 140 | 141 | # mpeltonen/sbt-idea plugin 142 | .idea_modules/ 143 | 144 | # JIRA plugin 145 | atlassian-ide-plugin.xml 146 | 147 | # Crashlytics plugin (for Android Studio and IntelliJ) 148 | com_crashlytics_export_strings.xml 149 | crashlytics.properties 150 | crashlytics-build.properties 151 | fabric.properties 152 | /data 153 | /logs 154 | *.csv 155 | .DS_Store 156 | /checkpoints/* 157 | *.npy 158 | *.hdf5 159 | /processed_data 160 | 161 | 162 | # Customized ignore 163 | extract_features/assist/ 164 | /models 165 | /tmp 166 | google.json 167 | *.json 168 | /model_backup 169 | /hyper_results_dir -------------------------------------------------------------------------------- /tenhou_env/project/statistics/cases/main.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import gc 3 | import logging 4 | import os 5 | from pathlib import Path 6 | 7 | from reproducer import TenhouLogReproducer 8 | from statistics.db import get_total_logs_count, load_logs_from_db 9 | from statistics.log_parser import LogParser 10 | from tqdm import tqdm 11 | 12 | logger = logging.getLogger("stat") 13 | 14 | 15 | class MainCase: 16 | def __init__(self, db_path: str, stats_output_folder: str): 17 | self.db_path = db_path 18 | self.stats_output_folder = stats_output_folder 19 | 20 | self.parser = LogParser() 21 | self.reproducer = TenhouLogReproducer(None, None, logging.getLogger()) 22 | 23 | def prepare_statistics(self): 24 | limit = 10000 25 | total_logs_count = get_total_logs_count(self.db_path) 26 | total_steps = int(total_logs_count / limit) + 1 27 | 28 | progress_bar = tqdm(range(total_steps), position=2) 29 | for step in progress_bar: 30 | offset = step * limit 31 | progress_bar.set_description(f"{offset} - {offset + limit}") 32 | 33 | logs = load_logs_from_db(self.db_path, offset=offset, limit=limit) 34 | 35 | results = [] 36 | for log in tqdm(logs, position=1): 37 | parsed_rounds = self.parser.split_log_to_game_rounds(log["log_content"]) 38 | results.extend(self._filter_rounds(log["log_id"], parsed_rounds)) 39 | 40 | collected_statistics = [] 41 | for filtered_result in tqdm(results, position=0): 42 | try: 43 | result = self._collect_statistics(filtered_result) 44 | if result: 45 | collected_statistics.append(result) 46 | except Exception: 47 | logger.error(f"Error in statistics calculation for {filtered_result['log_id']}") 48 | 49 | csv_file_name = f"{Path(self.db_path).name}_{offset:07d}_{offset + limit:07d}.csv" 50 | regular_csv_file_path = os.path.join(self.stats_output_folder, csv_file_name) 51 | dealer_csv_file_path = os.path.join(self.stats_output_folder, f"dealer_{csv_file_name}") 52 | 53 | if collected_statistics: 54 | with open(regular_csv_file_path, "w") as csv_file: 55 | writer = csv.DictWriter(csv_file, fieldnames=collected_statistics[0].keys()) 56 | writer.writeheader() 57 | for data in collected_statistics: 58 | if data["is_dealer"]: 59 | continue 60 | writer.writerow(data) 61 | 62 | with open(dealer_csv_file_path, "w") as csv_file: 63 | writer = csv.DictWriter(csv_file, fieldnames=collected_statistics[0].keys()) 64 | writer.writeheader() 65 | for data in collected_statistics: 66 | if not data["is_dealer"]: 67 | continue 68 | writer.writerow(data) 69 | 70 | # it is important for total allocated memory to free memory at the end of iteration 71 | del logs 72 | del results 73 | del collected_statistics 74 | gc.collect() 75 | 76 | def _filter_rounds(self, log_id, parsed_rounds): 77 | return [] 78 | 79 | def _collect_statistics(self, filtered_result): 80 | return {} 81 | -------------------------------------------------------------------------------- /tenhou_env/doc/versions.md: -------------------------------------------------------------------------------- 1 | ### 0.5.0 version 2 | 3 | This version is much more stable than the previous one. It played 150,000 hanchans locally and 1,000 hanchans on tenhou.net. We found and fixed numerous crashes during these games. 4 | 5 | The main change for this version is an improved defense mechanism, now the bot is much smarter in terms of push/fold decisions. 6 | 7 | Also, there are a lot of improvements in other parts (377 commits since the previous version with 17,465 additions and 7,701 deletions of code lines). 8 | 9 | Statistics provided for 1,095 games in 上級 lobby. 10 | 11 | Stable rank was a third dan (三段) and bot achieved fourth dan (四段) with R1900 once. 12 | 13 | | | Result | 14 | | --- | --- | 15 | | Average position | 2.48 | 16 | | Win rate | 21.09% | 17 | | Feed rate | 12.14% | 18 | | Riichi rate | 25.31% | 19 | | Call rate | 26.16% | 20 | 21 | For this version calculations of riichi and call rate were changed and now they are the same as tenhou.net calculation. But because of changes, it is not comparable with previous versions. 22 | 23 | | Places | | 24 | | --- | --- | 25 | | First | 23.65% | 26 | | Second | 28.86% | 27 | | Third| 24.20% | 28 | | Fourth | 23.29% | 29 | | Bankruptcy | 6.76% | 30 | 31 | ### 0.4.0 version 32 | 33 | Version with various improvements in hand building and melds calling. 34 | 35 | This version had played ~1000 games (hanchans) and achieved fourth dan (四段) a couple of times. 36 | 37 | Stable rank was a second dan (二段) and stable rate was ~R1600. 38 | 39 | Stat: 40 | 41 | | | Result | 42 | | --- | --- | 43 | | Average position | 2.53 | 44 | | Win rate | 19.21% | 45 | | Feed rate | 11.78% | 46 | | Riichi rate | 18.48% | 47 | | Call rate | 24.41% | 48 | 49 | | Places | | 50 | | --- | --- | 51 | | First | 20.92% | 52 | | Second | 27.46% | 53 | | Third| 30.17% | 54 | | Fourth | 21.45% | 55 | | Bankruptcy | 6.19% | 56 | 57 | The number of fourth places was decreased. 58 | 59 | ### 0.3.2 version 60 | 61 | Version with various improvements. 62 | 63 | This version had played 600 games (hanchans) and achieved fourth dan (四段) once. 64 | 65 | Stable rank was a first dan (初段). 66 | 67 | Stat: 68 | 69 | | | Result | 70 | | --- | --- | 71 | | Average position | 2.53 | 72 | | Win rate | 19.97% | 73 | | Feed rate | 10.88% | 74 | | Riichi rate | 15.80% | 75 | | Call rate | 36.39% | 76 | 77 | | Places | | 78 | | --- | --- | 79 | | First | 22.41% | 80 | | Second | 25.52% | 81 | | Third| 28.28% | 82 | | Fourth | 23.79% | 83 | | Bankruptcy | 4.48% | 84 | 85 | ### 0.2.5 version 86 | 87 | This version is much smarter than 0.0.x versions. It can open hand, go to defence and build hand more effective. 88 | 89 | This version had played 375 games (hanchans) and achieved second dan (二段). 90 | 91 | Rate was somewhere around R1500. 92 | 93 | Stat: 94 | 95 | | | Result | 96 | | --- | --- | 97 | | Average position | 2.65 | 98 | | Win rate | 18.60% | 99 | | Feed rate | 10.59% | 100 | | Riichi rate | 15.64% | 101 | | Call rate | 34.89% | 102 | 103 | ### 0.0.5 version 104 | 105 | It can reach a tempai and call a riichi. It doesn't know about dora, yaku, defence and etc. 106 | Only about tempai and riichi so far. 107 | 108 | This version had played 335 games (hanchans) and achieved only first dan (初段) on the tenhou.net so far 109 | (and lost it later, and achieved it again...). 110 | 111 | Rate was somewhere around R1350. 112 | 113 | Stat: 114 | 115 | | | Result | 116 | | --- | --- | 117 | | Average position | 2.78 | 118 | | Win rate | 20.73% | 119 | | Feed rate | 19.40% | 120 | | Riichi rate | 36.17% | 121 | | Call rate | 0% | 122 | 123 | So, even with the current simple logic it can play and win. -------------------------------------------------------------------------------- /tenhou_env/project/game/tests/test_player.py: -------------------------------------------------------------------------------- 1 | from game.table import Table 2 | from mahjong.constants import EAST, NORTH, SOUTH, WEST 3 | from utils.decisions_logger import MeldPrint 4 | from utils.test_helpers import make_meld, string_to_136_array 5 | 6 | 7 | def test_can_call_riichi_and_tempai(): 8 | table = Table() 9 | player = table.player 10 | 11 | player.in_tempai = False 12 | player.in_riichi = False 13 | player.scores = 2000 14 | player.table.count_of_remaining_tiles = 40 15 | 16 | assert player.formal_riichi_conditions() is False 17 | 18 | player.in_tempai = True 19 | 20 | assert player.formal_riichi_conditions() is True 21 | 22 | 23 | def test_can_call_riichi_and_already_in_riichi(): 24 | table = Table() 25 | player = table.player 26 | 27 | player.in_tempai = True 28 | player.in_riichi = True 29 | player.scores = 2000 30 | player.table.count_of_remaining_tiles = 40 31 | 32 | assert player.formal_riichi_conditions() is False 33 | 34 | player.in_riichi = False 35 | 36 | assert player.formal_riichi_conditions() is True 37 | 38 | 39 | def test_can_call_riichi_and_scores(): 40 | table = Table() 41 | player = table.player 42 | 43 | player.in_tempai = True 44 | player.in_riichi = False 45 | player.scores = 0 46 | player.table.count_of_remaining_tiles = 40 47 | 48 | assert player.formal_riichi_conditions() is False 49 | 50 | player.scores = 1000 51 | 52 | assert player.formal_riichi_conditions() is True 53 | 54 | 55 | def test_can_call_riichi_and_remaining_tiles(): 56 | table = Table() 57 | player = table.player 58 | 59 | player.in_tempai = True 60 | player.in_riichi = False 61 | player.scores = 2000 62 | player.table.count_of_remaining_tiles = 3 63 | 64 | assert player.formal_riichi_conditions() is False 65 | 66 | player.table.count_of_remaining_tiles = 5 67 | 68 | assert player.formal_riichi_conditions() is True 69 | 70 | 71 | def test_can_call_riichi_and_open_hand(): 72 | table = Table() 73 | player = table.player 74 | 75 | player.in_tempai = True 76 | player.in_riichi = False 77 | player.scores = 2000 78 | player.melds = [MeldPrint()] 79 | player.table.count_of_remaining_tiles = 40 80 | 81 | assert player.formal_riichi_conditions() is False 82 | 83 | player.melds = [] 84 | 85 | assert player.formal_riichi_conditions() is True 86 | 87 | 88 | def test_players_wind(): 89 | table = Table() 90 | player = table.player 91 | 92 | dealer_seat = 0 93 | table.init_round(0, 0, 0, 0, dealer_seat, []) 94 | assert player.player_wind == EAST 95 | assert table.get_player(1).player_wind == SOUTH 96 | 97 | dealer_seat = 1 98 | table.init_round(0, 0, 0, 0, dealer_seat, []) 99 | assert player.player_wind == NORTH 100 | assert table.get_player(1).player_wind == EAST 101 | 102 | dealer_seat = 2 103 | table.init_round(0, 0, 0, 0, dealer_seat, []) 104 | assert player.player_wind == WEST 105 | assert table.get_player(1).player_wind == NORTH 106 | 107 | dealer_seat = 3 108 | table.init_round(0, 0, 0, 0, dealer_seat, []) 109 | assert player.player_wind == SOUTH 110 | assert table.get_player(1).player_wind == WEST 111 | 112 | 113 | def test_player_called_meld_and_closed_hand(): 114 | table = Table() 115 | player = table.player 116 | 117 | tiles = string_to_136_array(sou="123678", pin="3599", honors="555") 118 | player.init_hand(tiles) 119 | 120 | assert len(player.closed_hand) == 13 121 | 122 | player.add_called_meld(make_meld(MeldPrint.PON, honors="555")) 123 | 124 | assert len(player.closed_hand) == 10 125 | -------------------------------------------------------------------------------- /tenhou_env/project/bots_battle.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import itertools 3 | import logging 4 | import os 5 | import random 6 | from optparse import OptionParser 7 | 8 | import game.bots_battle 9 | from game.bots_battle.battle_config import BattleConfig 10 | from game.bots_battle.game_manager import GameManager 11 | from game.bots_battle.local_client import LocalClient 12 | from tqdm import trange 13 | from utils.logger import DATE_FORMAT, LOG_FORMAT 14 | from utils.settings_handler import settings 15 | 16 | logger = logging.getLogger("game") 17 | 18 | battle_results_folder = os.path.join(os.path.dirname(os.path.realpath(__file__)), "battle_results") 19 | if not os.path.exists(battle_results_folder): 20 | os.mkdir(battle_results_folder) 21 | 22 | 23 | def main(number_of_games, print_logs): 24 | seeds = [] 25 | seed_file = "seeds.txt" 26 | if os.path.exists(seed_file): 27 | with open(seed_file, "r") as f: 28 | seeds = f.read().split("\n") 29 | seeds = [int(x.strip()) for x in seeds if x.strip()] 30 | 31 | replays_directory = os.path.join(battle_results_folder, "replays") 32 | if not os.path.exists(replays_directory): 33 | os.mkdir(replays_directory) 34 | 35 | possible_configurations = list(itertools.combinations(BattleConfig.CLIENTS_CONFIGS, 4)) 36 | assert len(BattleConfig.CLIENTS_CONFIGS) == 12 37 | assert len(possible_configurations) == 495 38 | 39 | chosen_configuration = 0 40 | for i in trange(number_of_games): 41 | if i < len(seeds): 42 | seed_value = seeds[i] 43 | else: 44 | seed_value = random.getrandbits(64) 45 | 46 | replay_name = GameManager.generate_replay_name() 47 | 48 | clients = [ 49 | LocalClient(possible_configurations[chosen_configuration][x](), print_logs, replay_name, i) 50 | for x in range(0, 4) 51 | ] 52 | manager = GameManager(clients, replays_directory, replay_name) 53 | 54 | try: 55 | game.bots_battle.game_manager.shuffle_seed = lambda: seed_value 56 | manager.play_game() 57 | except Exception as e: 58 | manager.replay.save_failed_log() 59 | logger.error(f"Hanchan seed={seed_value} crashed", exc_info=e) 60 | 61 | chosen_configuration += 1 62 | if chosen_configuration == len(possible_configurations): 63 | chosen_configuration = 0 64 | 65 | 66 | def _set_up_bots_battle_game_logger(): 67 | logs_directory = os.path.join(battle_results_folder, "logs") 68 | if not os.path.exists(logs_directory): 69 | os.mkdir(logs_directory) 70 | 71 | formatter = logging.Formatter(LOG_FORMAT, datefmt=DATE_FORMAT) 72 | file_name = f"{datetime.datetime.now().strftime('%Y-%m-%d_%H_%M_%S')}.log" 73 | fh = logging.FileHandler(os.path.join(logs_directory, file_name), encoding="utf-8") 74 | fh.setLevel(logging.DEBUG) 75 | fh.setFormatter(formatter) 76 | 77 | logger = logging.getLogger("game") 78 | logger.setLevel(logging.DEBUG) 79 | logger.addHandler(fh) 80 | 81 | 82 | if __name__ == "__main__": 83 | _set_up_bots_battle_game_logger() 84 | 85 | parser = OptionParser() 86 | parser.add_option( 87 | "-g", 88 | "--games", 89 | type="int", 90 | default=1, 91 | help="Number of games to play", 92 | ) 93 | parser.add_option( 94 | "--logs", 95 | action="store_true", 96 | help="Enable logs for bots, use it only for debug, not for live games", 97 | ) 98 | opts, _ = parser.parse_args() 99 | 100 | settings.FIVE_REDS = True 101 | settings.OPEN_TANYAO = True 102 | settings.PRINT_LOGS = False 103 | 104 | if opts.logs: 105 | settings.PRINT_LOGS = True 106 | 107 | main(opts.games, opts.logs) 108 | -------------------------------------------------------------------------------- /tenhou_env/project/game/ai/defence/yaku_analyzer/chinitsu.py: -------------------------------------------------------------------------------- 1 | from game.ai.defence.yaku_analyzer.honitsu_analyzer_base import HonitsuAnalyzerBase 2 | from mahjong.tile import TilesConverter 3 | from mahjong.utils import count_tiles_by_suits, is_honor 4 | 5 | 6 | class ChinitsuAnalyzer(HonitsuAnalyzerBase): 7 | id = "chinitsu" 8 | 9 | MIN_DISCARD = 5 10 | MIN_DISCARD_FOR_LESS_SUIT = 10 11 | MAX_MELDS = 3 12 | EARLY_DISCARD_DIVISOR = 3 13 | LESS_SUIT_PERCENTAGE_BORDER = 30 14 | 15 | def is_yaku_active(self): 16 | # TODO: in some distant future we may want to analyze menchin as well 17 | if not self.enemy.melds: 18 | return False 19 | 20 | total_melds = len(self.enemy.melds) 21 | total_discards = len(self.enemy.discards) 22 | 23 | # let's check if there is too little info to analyze 24 | if total_discards < ChinitsuAnalyzer.MIN_DISCARD and total_melds < ChinitsuAnalyzer.MAX_MELDS: 25 | return False 26 | 27 | # first of all - check melds, they must be all from one suit 28 | current_suit = None 29 | for meld in self.enemy.melds: 30 | tile = meld.tiles[0] 31 | tile_34 = tile // 4 32 | 33 | if is_honor(tile_34): 34 | return False 35 | 36 | suit = self._get_tile_suit(tile) 37 | if not current_suit: 38 | current_suit = suit 39 | elif suit["name"] != current_suit["name"]: 40 | return False 41 | 42 | assert current_suit 43 | 44 | if not self._check_discard_order(current_suit, int(total_discards / ChinitsuAnalyzer.EARLY_DISCARD_DIVISOR)): 45 | return False 46 | 47 | # finally let's check if discard is not too full of chosen suit 48 | 49 | discards = [x.value for x in self.enemy.discards] 50 | discards_34 = TilesConverter.to_34_array(discards) 51 | result = count_tiles_by_suits(discards_34) 52 | 53 | suits = [x for x in result if x["name"] != "honor"] 54 | suits = sorted(suits, key=lambda x: x["count"], reverse=False) 55 | 56 | less_suits = [x for x in suits if x["count"] == suits[0]["count"]] 57 | assert len(less_suits) != 0 58 | 59 | current_suit_is_less_suit = False 60 | for less_suit in less_suits: 61 | if less_suit["name"] == current_suit["name"]: 62 | current_suit_is_less_suit = True 63 | 64 | if not current_suit_is_less_suit: 65 | return False 66 | 67 | less_suit = suits[0] 68 | less_suit_tiles = less_suit["count"] 69 | 70 | if total_discards >= ChinitsuAnalyzer.MIN_DISCARD_FOR_LESS_SUIT: 71 | percentage_of_less_suit = (less_suit_tiles / total_discards) * 100 72 | if percentage_of_less_suit > ChinitsuAnalyzer.LESS_SUIT_PERCENTAGE_BORDER: 73 | return False 74 | else: 75 | if len(self.enemy.melds) < 2: 76 | return False 77 | 78 | if less_suit_tiles > 1: 79 | return False 80 | 81 | self.chosen_suit = current_suit["function"] 82 | return True 83 | 84 | def melds_han(self): 85 | return self.enemy.is_open_hand and 5 or 6 86 | 87 | def get_safe_tiles_34(self): 88 | if not self.chosen_suit: 89 | return [] 90 | 91 | safe_tiles = [] 92 | for x in range(0, 34): 93 | if not self.chosen_suit(x): 94 | safe_tiles.append(x) 95 | 96 | return safe_tiles 97 | 98 | @staticmethod 99 | # FIXME: remove this method and use proper one from mahjong lib 100 | def _get_tile_suit(tile_136): 101 | suits = sorted( 102 | count_tiles_by_suits(TilesConverter.to_34_array([tile_136])), key=lambda x: x["count"], reverse=True 103 | ) 104 | suit = suits[0] 105 | assert suit["count"] == 1 106 | return suit 107 | -------------------------------------------------------------------------------- /tenhou_env/project/game/ai/helpers/kabe.py: -------------------------------------------------------------------------------- 1 | from utils.general import revealed_suits_tiles 2 | 3 | 4 | class Kabe: 5 | STRONG_KABE = 0 6 | WEAK_KABE = 1 7 | PARTIAL_KABE = 2 8 | 9 | def __init__(self, player): 10 | self.player = player 11 | 12 | def find_all_kabe(self, tiles_34): 13 | # all indices shifted to -1 14 | kabe_matrix = [ 15 | {"indices": [1], "blocked_tiles": [0], "type": Kabe.STRONG_KABE}, 16 | {"indices": [2], "blocked_tiles": [0, 1], "type": Kabe.STRONG_KABE}, 17 | {"indices": [6], "blocked_tiles": [7, 8], "type": Kabe.STRONG_KABE}, 18 | {"indices": [7], "blocked_tiles": [8], "type": Kabe.STRONG_KABE}, 19 | {"indices": [0, 3], "blocked_tiles": [2, 3], "type": Kabe.STRONG_KABE}, 20 | {"indices": [1, 3], "blocked_tiles": [2], "type": Kabe.STRONG_KABE}, 21 | {"indices": [1, 4], "blocked_tiles": [2, 3], "type": Kabe.STRONG_KABE}, 22 | {"indices": [2, 4], "blocked_tiles": [3], "type": Kabe.STRONG_KABE}, 23 | {"indices": [2, 5], "blocked_tiles": [3, 4], "type": Kabe.STRONG_KABE}, 24 | {"indices": [3, 5], "blocked_tiles": [4], "type": Kabe.STRONG_KABE}, 25 | {"indices": [3, 6], "blocked_tiles": [4, 5], "type": Kabe.STRONG_KABE}, 26 | {"indices": [4, 6], "blocked_tiles": [5], "type": Kabe.STRONG_KABE}, 27 | {"indices": [4, 7], "blocked_tiles": [5, 6], "type": Kabe.STRONG_KABE}, 28 | {"indices": [5, 7], "blocked_tiles": [6], "type": Kabe.STRONG_KABE}, 29 | {"indices": [5, 8], "blocked_tiles": [6, 7], "type": Kabe.STRONG_KABE}, 30 | {"indices": [3], "blocked_tiles": [1, 2], "type": Kabe.WEAK_KABE}, 31 | {"indices": [4], "blocked_tiles": [2, 6], "type": Kabe.WEAK_KABE}, 32 | {"indices": [5], "blocked_tiles": [6, 7], "type": Kabe.WEAK_KABE}, 33 | {"indices": [1, 5], "blocked_tiles": [3], "type": Kabe.WEAK_KABE}, 34 | {"indices": [2, 6], "blocked_tiles": [4], "type": Kabe.WEAK_KABE}, 35 | {"indices": [3, 7], "blocked_tiles": [5], "type": Kabe.WEAK_KABE}, 36 | ] 37 | 38 | kabe_tiles_strong = [] 39 | kabe_tiles_weak = [] 40 | kabe_tiles_partial = [] 41 | 42 | suits = revealed_suits_tiles(self.player, tiles_34) 43 | for x in range(0, 3): 44 | suit = suits[x] 45 | 46 | # "kabe" - 4 revealed tiles 47 | kabe_tiles = [] 48 | partial_kabe_tiles = [] 49 | for y in range(0, 9): 50 | suit_tile = suit[y] 51 | if suit_tile == 4: 52 | kabe_tiles.append(y) 53 | elif suit_tile == 3: 54 | partial_kabe_tiles.append(y) 55 | 56 | for matrix_item in kabe_matrix: 57 | if len(list(set(matrix_item["indices"]) - set(kabe_tiles))) == 0: 58 | for tile in matrix_item["blocked_tiles"]: 59 | if matrix_item["type"] == Kabe.STRONG_KABE: 60 | kabe_tiles_strong.append(tile + x * 9) 61 | else: 62 | kabe_tiles_weak.append(tile + x * 9) 63 | 64 | if len(list(set(matrix_item["indices"]) - set(partial_kabe_tiles))) == 0: 65 | for tile in matrix_item["blocked_tiles"]: 66 | kabe_tiles_partial.append(tile + x * 9) 67 | 68 | kabe_tiles_unique = [] 69 | kabe_tiles_strong = list(set(kabe_tiles_strong)) 70 | kabe_tiles_weak = list(set(kabe_tiles_weak)) 71 | kabe_tiles_partial = list(set(kabe_tiles_partial)) 72 | 73 | for tile in kabe_tiles_strong: 74 | kabe_tiles_unique.append({"tile": tile, "type": Kabe.STRONG_KABE}) 75 | 76 | for tile in kabe_tiles_weak: 77 | if tile not in kabe_tiles_strong: 78 | kabe_tiles_unique.append({"tile": tile, "type": Kabe.WEAK_KABE}) 79 | 80 | for tile in kabe_tiles_partial: 81 | if tile not in kabe_tiles_strong and tile not in kabe_tiles_weak: 82 | kabe_tiles_unique.append({"tile": tile, "type": Kabe.PARTIAL_KABE}) 83 | 84 | return kabe_tiles_unique 85 | -------------------------------------------------------------------------------- /tenhou_env/project/game/ai/defence/yaku_analyzer/honitsu.py: -------------------------------------------------------------------------------- 1 | from game.ai.defence.yaku_analyzer.chinitsu import ChinitsuAnalyzer 2 | from game.ai.defence.yaku_analyzer.honitsu_analyzer_base import HonitsuAnalyzerBase 3 | from game.ai.helpers.defence import TileDanger 4 | from mahjong.tile import TilesConverter 5 | from mahjong.utils import count_tiles_by_suits, is_honor 6 | 7 | 8 | class HonitsuAnalyzer(HonitsuAnalyzerBase): 9 | id = "honitsu" 10 | 11 | MIN_DISCARD = 6 12 | MAX_MELDS = 3 13 | EARLY_DISCARD_DIVISOR = 4 14 | LESS_SUIT_PERCENTAGE_BORDER = 20 15 | HONORS_PERCENTAGE_BORDER = 30 16 | 17 | def is_yaku_active(self): 18 | # TODO: in some distant future we may want to analyze menhon as well 19 | if not self.enemy.melds: 20 | return False 21 | 22 | total_melds = len(self.enemy.melds) 23 | total_discards = len(self.enemy.discards) 24 | 25 | # let's check if there is too little info to analyze 26 | if total_discards < HonitsuAnalyzer.MIN_DISCARD and total_melds < HonitsuAnalyzer.MAX_MELDS: 27 | return False 28 | 29 | # first of all - check melds, they must be all from one suit or honors 30 | current_suit = None 31 | for meld in self.enemy.melds: 32 | tile = meld.tiles[0] 33 | tile_34 = tile // 4 34 | 35 | if is_honor(tile_34): 36 | continue 37 | 38 | suit = ChinitsuAnalyzer._get_tile_suit(tile) 39 | if not current_suit: 40 | current_suit = suit 41 | elif suit["name"] != current_suit["name"]: 42 | return False 43 | 44 | # let's check discards 45 | discards = [x.value for x in self.enemy.discards] 46 | discards_34 = TilesConverter.to_34_array(discards) 47 | result = count_tiles_by_suits(discards_34) 48 | 49 | honors = [x for x in result if x["name"] == "honor"][0] 50 | suits = [x for x in result if x["name"] != "honor"] 51 | suits = sorted(suits, key=lambda x: x["count"], reverse=False) 52 | 53 | less_suit = suits[0] 54 | less_suit_tiles = less_suit["count"] 55 | percentage_of_less_suit = (less_suit_tiles / total_discards) * 100 56 | percentage_of_honor_tiles = (honors["count"] / total_discards) * 100 57 | 58 | # there is not too much one suit + honor tiles in the discard 59 | # so we can tell that user trying to collect honitsu 60 | if ( 61 | percentage_of_less_suit <= HonitsuAnalyzer.LESS_SUIT_PERCENTAGE_BORDER 62 | and percentage_of_honor_tiles <= HonitsuAnalyzer.HONORS_PERCENTAGE_BORDER 63 | ): 64 | if not current_suit: 65 | current_suit = less_suit 66 | elif current_suit != less_suit: 67 | return False 68 | 69 | # still cannot determine the suit - this is probably not honitsu 70 | if not current_suit: 71 | return False 72 | 73 | if not self._check_discard_order(current_suit, int(total_discards / HonitsuAnalyzer.EARLY_DISCARD_DIVISOR)): 74 | return False 75 | 76 | # all checks have passed - assume this is honitsu 77 | self.chosen_suit = current_suit["function"] 78 | return True 79 | 80 | def melds_han(self): 81 | return self.enemy.is_open_hand and 2 or 3 82 | 83 | def get_safe_tiles_34(self): 84 | if not self.chosen_suit: 85 | return [] 86 | 87 | safe_tiles = [] 88 | for x in range(0, 34): 89 | if not self.chosen_suit(x) and not is_honor(x): 90 | safe_tiles.append(x) 91 | 92 | return safe_tiles 93 | 94 | def get_bonus_danger(self, tile_136, number_of_revealed_tiles): 95 | tile_34 = tile_136 // 4 96 | 97 | if is_honor(tile_34): 98 | if number_of_revealed_tiles == 4: 99 | return [] 100 | elif number_of_revealed_tiles == 3: 101 | return [TileDanger.HONITSU_THIRD_HONOR_BONUS_DANGER] 102 | elif number_of_revealed_tiles == 2: 103 | return [TileDanger.HONITSU_SECOND_HONOR_BONUS_DANGER] 104 | else: 105 | return [TileDanger.HONITSU_SHONPAI_HONOR_BONUS_DANGER] 106 | 107 | return [] 108 | 109 | def is_absorbed(self, possible_yaku, tile_34=None): 110 | return self._is_absorbed_by(possible_yaku, ChinitsuAnalyzer.id, tile_34) 111 | -------------------------------------------------------------------------------- /logs_crawler/download_logs_content.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Script will load log ids from the database and will download log content 4 | """ 5 | import bz2 6 | import hashlib 7 | import sqlite3 8 | import threading 9 | from datetime import datetime 10 | 11 | import requests 12 | 13 | 14 | class DownloadThread(threading.Thread): 15 | def __init__(self, downloader, results, *args, **kwargs): 16 | super().__init__(*args, **kwargs) 17 | 18 | self.downloader = downloader 19 | self.results = results 20 | 21 | def run(self): 22 | self.downloader.download_logs(self.results) 23 | 24 | 25 | class DownloadLogContent(object): 26 | db_file = "" 27 | limit = 0 28 | threads = 0 29 | 30 | def __init__(self, db_file, limit, threads): 31 | """ 32 | :param db_file: db with loaded log ids 33 | """ 34 | self.db_file = db_file 35 | self.limit = limit 36 | self.threads = threads 37 | 38 | def process(self): 39 | start_time = datetime.now() 40 | print("Load {} records".format(self.limit)) 41 | results = self.load_not_processed_logs() 42 | if not results: 43 | print("Nothing to download") 44 | 45 | # separate array to parts and download them simultaneously 46 | threads = [] 47 | part = int(self.limit / self.threads) 48 | for x in range(0, self.threads): 49 | start = x * part 50 | if (x + 1) != self.threads: 51 | end = (x + 1) * part 52 | else: 53 | # we had to add all remaining items to the last thread 54 | # for example with limit=81, threads=4 results will be distributed: 55 | # 20 20 20 21 56 | end = self.limit 57 | 58 | threads.append(DownloadThread(self, results[start:end])) 59 | 60 | # let's start all threads 61 | for t in threads: 62 | t.start() 63 | 64 | # let's wait while all threads will be finished 65 | for t in threads: 66 | t.join() 67 | 68 | print("Worked time: {} seconds".format((datetime.now() - start_time).seconds)) 69 | 70 | def download_logs(self, results): 71 | for log_id in results: 72 | print("Process {}".format(log_id)) 73 | self.download_log_content(log_id) 74 | 75 | def download_log_content(self, log_id): 76 | """ 77 | Download log content and store compressed version in the db 78 | """ 79 | url = "http://tenhou.net/0/log/?{}".format(log_id) 80 | 81 | binary_content = None 82 | was_error = False 83 | try: 84 | response = requests.get( 85 | url, 86 | headers={ 87 | "User-Agent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0" 88 | }, 89 | ) 90 | text = response.content 91 | # it can be an error page 92 | if "mjlog" not in response.text: 93 | print("There is no log content in response") 94 | was_error = True 95 | except Exception as e: 96 | print(e) 97 | was_error = True 98 | 99 | connection = sqlite3.connect(self.db_file) 100 | 101 | with connection: 102 | cursor = connection.cursor() 103 | 104 | compressed_content = "" 105 | log_hash = "" 106 | if not was_error: 107 | try: 108 | # compressed_content = bz2.compress(binary_content) 109 | log_hash = hashlib.sha256(text).hexdigest() 110 | except: 111 | print("Cant compress log content") 112 | was_error = True 113 | 114 | cursor.execute( 115 | "UPDATE logs SET is_processed = ?, was_error = ?, log_content = ?, log_hash = ? WHERE log_id = ?;", 116 | [1, was_error and 1 or 0, text, log_hash, log_id], 117 | ) 118 | 119 | def load_not_processed_logs(self): 120 | connection = sqlite3.connect(self.db_file) 121 | 122 | with connection: 123 | cursor = connection.cursor() 124 | cursor.execute( 125 | "SELECT log_id FROM logs where is_processed = 0 and was_error = 0 LIMIT ?;", [self.limit] 126 | ) 127 | data = cursor.fetchall() 128 | results = [x[0] for x in data] 129 | 130 | return results 131 | -------------------------------------------------------------------------------- /extract_features/FeatureGenerator.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 40, 6 | "metadata": {}, 7 | "outputs": [], 8 | "source": [ 9 | "import sys\n", 10 | "del sys.modules[\"FeatureGenerator\"]" 11 | ] 12 | }, 13 | { 14 | "cell_type": "code", 15 | "execution_count": 41, 16 | "metadata": {}, 17 | "outputs": [], 18 | "source": [ 19 | "from FeatureGenerator import FeatureGenerator\n", 20 | "import json\n", 21 | "import numpy as np\n", 22 | "\n", 23 | "fg = FeatureGenerator()" 24 | ] 25 | }, 26 | { 27 | "cell_type": "code", 28 | "execution_count": 42, 29 | "metadata": {}, 30 | "outputs": [ 31 | { 32 | "name": "stdout", 33 | "output_type": "stream", 34 | "text": [ 35 | "[1. 0.]\n", 36 | "[1. 0.]\n", 37 | "[1. 0.]\n", 38 | "[1. 0.]\n", 39 | "[0. 1.]\n", 40 | "[1. 0.]\n", 41 | "[1. 0.]\n", 42 | "[1. 0.]\n", 43 | "[1. 0.]\n", 44 | "[1. 0.]\n", 45 | "[1. 0.]\n", 46 | "[1. 0.]\n", 47 | "[1. 0.]\n", 48 | "[1. 0.]\n", 49 | "[1. 0.]\n", 50 | "[1. 0.]\n", 51 | "[1. 0.]\n" 52 | ] 53 | } 54 | ], 55 | "source": [ 56 | "with open(\"assist/chi_pon_kan_reach_2021.json\") as infile:\n", 57 | " for idx,line in enumerate(infile):\n", 58 | " if idx==100:\n", 59 | " break\n", 60 | " state = json.loads(line)\n", 61 | "# chifeatures = fg.ChiFeatureGenerator(state)\n", 62 | "# print(next(chifeatures,None))\n", 63 | " if state[\"could_chi\"] == 1:\n", 64 | " chifeatures = fg.ChiFeatureGenerator(state)\n", 65 | " for chifeature in chifeatures:\n", 66 | " print(chifeature[1])\n", 67 | " \n", 68 | " " 69 | ] 70 | }, 71 | { 72 | "cell_type": "code", 73 | "execution_count": 44, 74 | "metadata": {}, 75 | "outputs": [ 76 | { 77 | "name": "stdout", 78 | "output_type": "stream", 79 | "text": [ 80 | "[0. 0. 1. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.\n", 81 | " 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.]\n", 82 | "[0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.\n", 83 | " 0. 0. 0. 0. 0. 1. 0. 0. 0. 0.]\n", 84 | "[0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.\n", 85 | " 0. 0. 0. 0. 0. 0. 1. 0. 0. 0.]\n", 86 | "[0. 0. 1. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.\n", 87 | " 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.]\n", 88 | "[0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 1. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.\n", 89 | " 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.]\n", 90 | "[0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.\n", 91 | " 0. 0. 0. 0. 0. 0. 1. 0. 0. 0.]\n", 92 | "[0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.\n", 93 | " 0. 0. 0. 0. 0. 0. 0. 0. 1. 0.]\n", 94 | "[0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 1. 0. 0. 0. 0. 0. 0. 0. 0.\n", 95 | " 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.]\n", 96 | "[0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 1. 0. 0. 0. 0. 0. 0. 0.\n", 97 | " 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.]\n", 98 | "[0. 0. 0. 0. 0. 0. 0. 0. 1. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.\n", 99 | " 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.]\n" 100 | ] 101 | } 102 | ], 103 | "source": [ 104 | "with open(\"assist/new_discard_2021.json\") as infile:\n", 105 | " for idx,line in enumerate(infile):\n", 106 | " if idx==10:\n", 107 | " break\n", 108 | " state = json.loads(line)\n", 109 | " dfeatures = fg.DiscardFeatureGenerator(state)\n", 110 | " for dfeature in dfeatures:\n", 111 | " print(dfeature[1])" 112 | ] 113 | }, 114 | { 115 | "cell_type": "code", 116 | "execution_count": null, 117 | "metadata": {}, 118 | "outputs": [], 119 | "source": [] 120 | } 121 | ], 122 | "metadata": { 123 | "kernelspec": { 124 | "display_name": "Python 3", 125 | "language": "python", 126 | "name": "python3" 127 | }, 128 | "language_info": { 129 | "codemirror_mode": { 130 | "name": "ipython", 131 | "version": 3 132 | }, 133 | "file_extension": ".py", 134 | "mimetype": "text/x-python", 135 | "name": "python", 136 | "nbconvert_exporter": "python", 137 | "pygments_lexer": "ipython3", 138 | "version": "3.6.5" 139 | } 140 | }, 141 | "nbformat": 4, 142 | "nbformat_minor": 2 143 | } 144 | -------------------------------------------------------------------------------- /tenhou_env/project/system_testing/fixtures/25.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tenhou_env/project/game/ai/statistics_collector.py: -------------------------------------------------------------------------------- 1 | from mahjong.utils import is_honor, plus_dora, simplify 2 | from utils.decisions_logger import MeldPrint 3 | 4 | 5 | class StatisticsCollector: 6 | @staticmethod 7 | def collect_stat_for_enemy_riichi_hand_cost(tile_136, enemy, main_player): 8 | tile_34 = tile_136 // 4 9 | 10 | riichi_discard = [x for x in enemy.discards if x.riichi_discard] 11 | if riichi_discard: 12 | assert len(riichi_discard) == 1 13 | riichi_discard = riichi_discard[0] 14 | else: 15 | # FIXME: it happens when user called riichi and we are trying to decide to we need to open hand on 16 | # riichi tile or not. We need to process this situation correctly. 17 | riichi_discard = enemy.discards[-1] 18 | 19 | riichi_called_on_step = enemy.discards.index(riichi_discard) + 1 20 | 21 | total_dora_in_game = len(enemy.table.dora_indicators) * 4 + (3 * int(enemy.table.has_aka_dora)) 22 | visible_tiles = enemy.table.revealed_tiles_136 + main_player.closed_hand 23 | visible_dora_tiles = sum( 24 | [plus_dora(x, enemy.table.dora_indicators, add_aka_dora=enemy.table.has_aka_dora) for x in visible_tiles] 25 | ) 26 | live_dora_tiles = total_dora_in_game - visible_dora_tiles 27 | assert live_dora_tiles >= 0, "Live dora tiles can't be less than 0" 28 | 29 | number_of_kan_in_enemy_hand = 0 30 | number_of_dora_in_enemy_kan_sets = 0 31 | number_of_yakuhai_enemy_kan_sets = 0 32 | for meld in enemy.melds: 33 | # if he is in riichi he can only have closed kan 34 | assert meld.type == MeldPrint.KAN and not meld.opened 35 | 36 | number_of_kan_in_enemy_hand += 1 37 | 38 | for tile in meld.tiles: 39 | number_of_dora_in_enemy_kan_sets += plus_dora( 40 | tile, enemy.table.dora_indicators, add_aka_dora=enemy.table.has_aka_dora 41 | ) 42 | 43 | tile_meld_34 = meld.tiles[0] // 4 44 | if tile_meld_34 in enemy.valued_honors: 45 | number_of_yakuhai_enemy_kan_sets += 1 46 | 47 | number_of_other_player_kan_sets = 0 48 | for other_player in enemy.table.players: 49 | if other_player.seat == enemy.seat: 50 | continue 51 | 52 | for meld in other_player.melds: 53 | if meld.type == MeldPrint.KAN or meld.type == MeldPrint.SHOUMINKAN: 54 | number_of_other_player_kan_sets += 1 55 | 56 | tile_category = "" 57 | # additional danger for tiles that could be used for tanyao 58 | if not is_honor(tile_34): 59 | # +1 here to make it more readable 60 | simplified_tile = simplify(tile_34) + 1 61 | 62 | if simplified_tile in [4, 5, 6]: 63 | tile_category = "middle" 64 | 65 | if simplified_tile in [2, 3, 7, 8]: 66 | tile_category = "edge" 67 | 68 | if simplified_tile in [1, 9]: 69 | tile_category = "terminal" 70 | else: 71 | tile_category = "honor" 72 | if tile_34 in enemy.valued_honors: 73 | tile_category = "valuable_honor" 74 | 75 | return { 76 | "is_dealer": enemy.is_dealer and 1 or 0, 77 | "riichi_called_on_step": riichi_called_on_step, 78 | "current_enemy_step": len(enemy.discards), 79 | "wind_number": main_player.table.round_wind_number, 80 | "scores": enemy.scores, 81 | "is_tsumogiri_riichi": riichi_discard.is_tsumogiri and 1 or 0, 82 | "is_oikake_riichi": enemy.is_oikake_riichi and 1 or 0, 83 | "is_oikake_riichi_against_dealer_riichi_threat": enemy.is_oikake_riichi_against_dealer_riichi_threat 84 | and 1 85 | or 0, 86 | "is_riichi_against_open_hand_threat": enemy.is_riichi_against_open_hand_threat and 1 or 0, 87 | "number_of_kan_in_enemy_hand": number_of_kan_in_enemy_hand, 88 | "number_of_dora_in_enemy_kan_sets": number_of_dora_in_enemy_kan_sets, 89 | "number_of_yakuhai_enemy_kan_sets": number_of_yakuhai_enemy_kan_sets, 90 | "number_of_other_player_kan_sets": number_of_other_player_kan_sets, 91 | "live_dora_tiles": live_dora_tiles, 92 | "tile_plus_dora": plus_dora(tile_136, enemy.table.dora_indicators, add_aka_dora=enemy.table.has_aka_dora), 93 | "tile_category": tile_category, 94 | "discards_before_riichi_34": ";".join([str(x.value // 4) for x in enemy.discards[:riichi_called_on_step]]), 95 | } 96 | -------------------------------------------------------------------------------- /run_cloud.sh: -------------------------------------------------------------------------------- 1 | 2 | # Parse command line arguments 3 | unset WORK_DIR 4 | unset TYPE 5 | MAX_DATA_FILES=5 6 | PROJECT=$(gcloud config get-value project || echo $PROJECT) 7 | REGION=us-central1 8 | while [[ $# -gt 0 ]]; do 9 | case $1 in 10 | --training-type) 11 | TYPE=$2 12 | shift 13 | ;; 14 | --work-dir) 15 | WORK_DIR=$2 16 | shift 17 | ;; 18 | --max-data-files) 19 | MAX_DATA_FILES=$2 20 | shift 21 | ;; 22 | --project) 23 | PROJECT=$2 24 | shift 25 | ;; 26 | --region) 27 | REGION=$2 28 | shift 29 | ;; 30 | *) 31 | echo "error: unrecognized argument $1" 32 | exit 1 33 | ;; 34 | esac 35 | shift 36 | done 37 | 38 | if [[ -z $WORK_DIR ]]; then 39 | echo "error: argument --work-dir is required" 40 | exit 1 41 | fi 42 | 43 | if [[ $WORK_DIR != gs://* ]]; then 44 | echo "error: --work-dir must be a Google Cloud Storage path" 45 | echo " example: gs://mahjong-dataset" 46 | exit 1 47 | fi 48 | 49 | if [[ -z $PROJECT ]]; then 50 | echo 'error: --project is required to run in Google Cloud Platform.' 51 | exit 1 52 | fi 53 | 54 | # Wrapper function to print the command being run 55 | function run { 56 | echo "$ $@" 57 | "$@" 58 | } 59 | 60 | 61 | export GOOGLE_APPLICATION_CREDENTIALS="/Users/junlin/key.json" 62 | 63 | echo "Start Processing with Dataflow" 64 | run python pipeline.py \ 65 | --job_dir=$WORK_DIR \ 66 | --cloud=1 67 | 68 | echo "Submit Training Job to AI Platform" 69 | 70 | run gcloud ai-platform jobs submit training $TYPE_model_`date +"%Y%m%d_%H%M"` \ 71 | --package-path trainer/ \ 72 | --module-name trainer.task \ 73 | --region $REGION \ 74 | --python-version 3.7 \ 75 | --runtime-version 2.4 \ 76 | --job-dir $WORK_DIR \ 77 | --config config.yaml \ 78 | --stream-logs \ 79 | -- \ 80 | --model-type="discarded" \ 81 | --cloud-train=1 82 | 83 | # discarded model runner 84 | gcloud ai-platform jobs submit training discard_model_`date +"%Y%m%d_%H%M"` \ 85 | --package-path trainer/ \ 86 | --module-name trainer.task \ 87 | --region us-central1 \ 88 | --python-version 3.7 \ 89 | --runtime-version 2.4 \ 90 | --job-dir "gs://mahjong-dataset/" \ 91 | --config trainer/config.yaml \ 92 | --stream-logs \ 93 | -- \ 94 | --model-type="discarded" \ 95 | --cloud-train=1 \ 96 | 97 | 98 | # discarded model tuner 99 | gcloud ai-platform jobs submit training discard_model_tuner`date +"%Y%m%d_%H%M"` \ 100 | --package-path trainer/ \ 101 | --module-name trainer.task \ 102 | --region us-central1 \ 103 | --python-version 3.7 \ 104 | --runtime-version 2.4 \ 105 | --job-dir "gs://mahjong-bucket/" \ 106 | --config trainer/config.yaml \ 107 | --stream-logs \ 108 | -- \ 109 | --model-type="discarded" \ 110 | --cloud-train=1 \ 111 | --hypertune=1 112 | 113 | # chi model runner 114 | gcloud ai-platform jobs submit training chi_model_`date +"%Y%m%d_%H%M"` \ 115 | --package-path trainer/ \ 116 | --module-name trainer.task \ 117 | --region us-central1 \ 118 | --job-dir "gs://mahjong1/" \ 119 | --config trainer/config.yaml \ 120 | --stream-logs \ 121 | -- \ 122 | --model-type="chi" \ 123 | --cloud-train=1 124 | 125 | # riichi model runner 126 | gcloud ai-platform jobs submit training riichi_model_`date +"%Y%m%d_%H%M"` \ 127 | --package-path trainer/ \ 128 | --module-name trainer.task \ 129 | --region us-central1 \ 130 | --job-dir "gs://mahjong-bucket/" \ 131 | --config trainer/config.yaml \ 132 | --stream-logs \ 133 | -- \ 134 | --model-type="riichi" \ 135 | --cloud-train=1 136 | 137 | # kan model runner 138 | gcloud ai-platform jobs submit training kan_model_`date +"%Y%m%d_%H%M"` \ 139 | --package-path trainer/ \ 140 | --module-name trainer.task \ 141 | --region us-central1 \ 142 | --job-dir "gs://mahjong3/" \ 143 | --config trainer/config.yaml \ 144 | --stream-logs \ 145 | -- \ 146 | --model-type="kan" \ 147 | --cloud-train=1 148 | 149 | # pon model runner 150 | gcloud ai-platform jobs submit training pon_model_`date +"%Y%m%d_%H%M"` \ 151 | --package-path trainer/ \ 152 | --module-name trainer.task \ 153 | --region us-central1 \ 154 | --job-dir "gs://mahjong3/" \ 155 | --config trainer/config.yaml \ 156 | --stream-logs \ 157 | -- \ 158 | --model-type="pon" \ 159 | --cloud-train=1 160 | # Runner for pipeline, replace the # to your information 161 | # Local run only supply --job-type 162 | python pipeline.py --cloud=1 --job-dir=gs://mahjong-dataset \ 163 | --job-type="riichi" --google-app-cred="#" --project="#" --region="#" --runner="DataflowRunner" 164 | 165 | #local ai platform tester 166 | gcloud ai-platform local train \ 167 | --distributed --worker-count 3 \ 168 | --package-path trainer/ \ 169 | --module-name trainer.task \ 170 | -- \ 171 | --model-type="discarded" \ 172 | --cloud-train=1 -------------------------------------------------------------------------------- /tenhou_env/project/statistics/cases/agari_riichi_cost.py: -------------------------------------------------------------------------------- 1 | from statistics.cases.main import MainCase 2 | 3 | 4 | class AgariRiichiCostCase(MainCase): 5 | CSV_HEADER = [ 6 | "is_dealer", 7 | "riichi_called_on_step", 8 | "current_enemy_step", 9 | "wind_number", 10 | "scores", 11 | "is_tsumogiri_riichi", 12 | "is_oikake_riichi", 13 | "is_oikake_riichi_against_dealer_riichi_threat", 14 | "is_riichi_against_open_hand_threat", 15 | "number_of_kan_in_enemy_hand", 16 | "number_of_dora_in_enemy_kan_sets", 17 | "number_of_yakuhai_enemy_kan_sets", 18 | "number_of_other_player_kan_sets", 19 | "live_dora_tiles", 20 | "tile_plus_dora", 21 | "tile_category", 22 | "discards_before_riichi_34", 23 | "predicted_cost", 24 | "lobby", 25 | "log_id", 26 | "win_tile_34", 27 | "original_cost", 28 | ] 29 | 30 | def _filter_rounds(self, log_id, parsed_rounds): 31 | """ 32 | Find rounds where was agari riichi without tsumo and without ippatsu. 33 | """ 34 | results = [] 35 | lobby = None 36 | for round_data in parsed_rounds: 37 | for tag in round_data: 38 | if self.parser.is_start_game_tag(tag): 39 | lobby = self.parser.parse_lobby(tag) 40 | # we don't want to get stat from ippan for now 41 | if lobby == "ippan": 42 | return [] 43 | 44 | # in old logs riichi was called without step attribute 45 | # which makes it is hard to parse 46 | # so let's just skip these logs for now 47 | if self.parser.is_riichi_tag(tag) and "step" not in tag: 48 | return [] 49 | 50 | if not self.parser.is_agari_tag(tag): 51 | continue 52 | 53 | if "yaku=" not in tag: 54 | continue 55 | 56 | yaku_list = [int(x) for x in self.parser.get_attribute_content(tag, "yaku").split(",")[::2]] 57 | 58 | # we are looking for riichi hands only 59 | if 1 not in yaku_list: 60 | continue 61 | 62 | # we don't want to check hand cost for ippatsu or tsumo situations 63 | if 2 in yaku_list or 0 in yaku_list: 64 | continue 65 | 66 | original_cost = int(self.parser.get_attribute_content(tag, "ten").split(",")[1]) 67 | results.append( 68 | { 69 | "lobby": lobby, 70 | "log_id": log_id, 71 | "agari_position": int(self.parser.get_attribute_content(tag, "who")), 72 | "player_position": int(self.parser.get_attribute_content(tag, "fromWho")), 73 | "win_tile_34": int(self.parser.get_attribute_content(tag, "machi")) // 4, 74 | "original_cost": original_cost, 75 | "round_data": round_data, 76 | } 77 | ) 78 | 79 | return results 80 | 81 | def _collect_statistics(self, filtered_result): 82 | """ 83 | Statistics that we want to collect: 84 | - On Riichi. Round step number when riichi was called 85 | - On Riichi. Wind number 86 | - On Riichi. Enemy scores 87 | - On Riichi. Was it tsumogiri riichi or not 88 | - On Riichi. Was it dealer riichi or not 89 | - On Riichi. Was it first riichi or not 90 | - On Riichi. Was it called against dealer riichi threat or not 91 | - On Riichi. Was it called against open hand threat or not (threat == someone opened dora pon) 92 | - On Riichi. Discards before the riichi 93 | - On Agari. Riichi hand cost 94 | - On Agari. Round step number 95 | - On Agari. Number of kan sets in riichi hand 96 | - On Agari. Number of kan sets on the table 97 | - On Agari. Number of live dora 98 | - On Agari. Win tile (34 format) 99 | - On Agari. Win tile category (terminal, edge 2378, middle 456, honor, valuable honor) 100 | - On Agari. Is win tile dora or not 101 | """ 102 | agari_seat = self.reproducer._normalize_position( 103 | filtered_result["agari_position"], filtered_result["player_position"] 104 | ) 105 | 106 | stat = self.reproducer.play_round( 107 | filtered_result["round_data"], 108 | filtered_result["player_position"], 109 | context={ 110 | "action": "agari_riichi_cost", 111 | "agari_seat": agari_seat, 112 | }, 113 | ) 114 | 115 | if not stat: 116 | return None 117 | 118 | del filtered_result["round_data"] 119 | del filtered_result["player_position"] 120 | del filtered_result["agari_position"] 121 | 122 | stat.update(filtered_result) 123 | 124 | return stat 125 | -------------------------------------------------------------------------------- /tenhou_env/project/statistics/calculate_error_rate.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import os 3 | from pathlib import Path 4 | 5 | stats_output_folder = os.path.join(os.path.dirname(os.path.realpath(__file__)), "output") 6 | 7 | CSV_HEADER = [ 8 | "is_dealer", 9 | "riichi_called_on_step", 10 | "current_enemy_step", 11 | "wind_number", 12 | "scores", 13 | "is_tsumogiri_riichi", 14 | "is_oikake_riichi", 15 | "is_oikake_riichi_against_dealer_riichi_threat", 16 | "is_riichi_against_open_hand_threat", 17 | "number_of_kan_in_enemy_hand", 18 | "number_of_dora_in_enemy_kan_sets", 19 | "number_of_yakuhai_enemy_kan_sets", 20 | "number_of_other_player_kan_sets", 21 | "live_dora_tiles", 22 | "tile_plus_dora", 23 | "tile_category", 24 | "discards_before_riichi_34", 25 | "predicted_cost", 26 | "lobby", 27 | "log_id", 28 | "win_tile_34", 29 | "original_cost", 30 | ] 31 | 32 | 33 | def main(): 34 | dealer_csv = os.path.join(stats_output_folder, "dealer_test.csv") 35 | regular_csv = os.path.join(stats_output_folder, "test.csv") 36 | 37 | calculate_errors(dealer_csv, dealer=True) 38 | print("") 39 | calculate_errors(regular_csv, dealer=False) 40 | 41 | 42 | def calculate_errors(csv_file, dealer): 43 | print(Path(csv_file).name) 44 | 45 | with open(csv_file, mode="r") as f: 46 | reader = csv.DictReader(f, fieldnames=CSV_HEADER) 47 | results = list(reader) 48 | total_predictions = len(results) 49 | print(f"Total results: {total_predictions}") 50 | error_borders = [30, 20, 10] 51 | for error_border in error_borders: 52 | correct_predictions = 0 53 | print(f"Error border {error_border}%") 54 | for row in results: 55 | original_cost = int(row["original_cost"]) 56 | predicted_cost = int(row["predicted_cost"]) 57 | 58 | first_border = predicted_cost - round((predicted_cost / 100) * error_border) 59 | second_border = predicted_cost + round((predicted_cost / 100) * error_border) 60 | 61 | if first_border < original_cost < second_border: 62 | correct_predictions += 1 63 | 64 | print(f"Correct predictions: {correct_predictions}, {(correct_predictions / total_predictions) * 100:.2f}%") 65 | 66 | print("Empirical") 67 | correct_predictions = 0 68 | for row in results: 69 | original_cost = int(row["original_cost"]) 70 | predicted_cost = int(row["predicted_cost"]) 71 | if dealer and in_dealer_hand_correctly_predicted(original_cost, predicted_cost): 72 | correct_predictions += 1 73 | if not dealer and in_regular_hand_correctly_predicted(original_cost, predicted_cost): 74 | correct_predictions += 1 75 | print(f"Correct predictions: {correct_predictions}, {(correct_predictions / total_predictions) * 100:.2f}%") 76 | 77 | 78 | def in_dealer_hand_correctly_predicted(original_cost, predicted_cost): 79 | assert original_cost >= 2000 80 | 81 | if original_cost <= 3900: 82 | if predicted_cost <= 3900: 83 | return True 84 | 85 | if original_cost > 3900 and original_cost <= 5800: 86 | if predicted_cost > 3900 and predicted_cost <= 5800: 87 | return True 88 | 89 | if original_cost > 5800 and original_cost <= 7700: 90 | if predicted_cost > 5800 and predicted_cost <= 7700: 91 | return True 92 | 93 | if original_cost > 7700 and original_cost <= 9600: 94 | if predicted_cost > 7700 and predicted_cost <= 9600: 95 | return True 96 | 97 | if original_cost > 9600 and original_cost <= 12000: 98 | if predicted_cost > 9600 and predicted_cost <= 12000: 99 | return True 100 | 101 | error_border = 30 102 | first_border = predicted_cost - round((predicted_cost / 100) * error_border) 103 | second_border = predicted_cost + round((predicted_cost / 100) * error_border) 104 | 105 | if first_border < original_cost < second_border: 106 | return True 107 | 108 | return False 109 | 110 | 111 | def in_regular_hand_correctly_predicted(original_cost, predicted_cost): 112 | assert original_cost >= 1300 113 | 114 | if original_cost <= 2600: 115 | if predicted_cost <= 2600: 116 | return True 117 | 118 | if original_cost > 2600 and original_cost <= 3900: 119 | if predicted_cost > 2600 and predicted_cost <= 3900: 120 | return True 121 | 122 | if original_cost > 3900 and original_cost <= 5200: 123 | if predicted_cost > 3900 and predicted_cost <= 5200: 124 | return True 125 | 126 | if original_cost > 5200 and original_cost <= 8000: 127 | if predicted_cost > 5200 and predicted_cost <= 8000: 128 | return True 129 | 130 | if original_cost > 8000 and original_cost <= 12000: 131 | if predicted_cost > 8000 and predicted_cost <= 12000: 132 | return True 133 | 134 | error_border = 30 135 | first_border = predicted_cost - round((predicted_cost / 100) * error_border) 136 | second_border = predicted_cost + round((predicted_cost / 100) * error_border) 137 | 138 | if first_border < original_cost < second_border: 139 | return True 140 | 141 | return False 142 | 143 | 144 | if __name__ == "__main__": 145 | main() 146 | -------------------------------------------------------------------------------- /tenhou_env/project/game/ai/strategies/common_open_tempai.py: -------------------------------------------------------------------------------- 1 | import utils.decisions_constants as log 2 | from game.ai.strategies.main import BaseStrategy 3 | from mahjong.tile import TilesConverter 4 | from utils.test_helpers import tiles_to_string 5 | 6 | 7 | class CommonOpenTempaiStrategy(BaseStrategy): 8 | min_shanten = 1 9 | 10 | def should_activate_strategy(self, tiles_136, meld_tile=None): 11 | """ 12 | We activate this strategy only when we have a chance to meld for good tempai. 13 | """ 14 | result = super(CommonOpenTempaiStrategy, self).should_activate_strategy(tiles_136) 15 | if not result: 16 | return False 17 | 18 | # we only use this strategy for meld opportunities, if it's a self draw, just skip it 19 | if meld_tile is None: 20 | assert tiles_136 == self.player.tiles 21 | return False 22 | 23 | # only go from 1-shanten to tempai with this strategy 24 | if self.player.ai.shanten != 1: 25 | return False 26 | 27 | tiles_copy = self.player.closed_hand[:] + [meld_tile] 28 | tiles_34 = TilesConverter.to_34_array(tiles_copy) 29 | # we only open for tempai with that strategy 30 | new_shanten = self.player.ai.calculate_shanten_or_get_from_cache(tiles_34, use_chiitoitsu=False) 31 | 32 | # we always activate this strategy if we have a chance to get tempai 33 | # then we will validate meld to see if it's really a good one 34 | return self.player.ai.shanten == 1 and new_shanten == 0 35 | 36 | def is_tile_suitable(self, tile): 37 | """ 38 | All tiles are suitable for formal tempai. 39 | :param tile: 136 tiles format 40 | :return: True 41 | """ 42 | return True 43 | 44 | def validate_meld(self, chosen_meld_dict): 45 | # if we have already opened our hand, let's go by default riles 46 | if self.player.is_open_hand: 47 | return True 48 | 49 | # choose if base method requires us to keep hand closed 50 | if not super(CommonOpenTempaiStrategy, self).validate_meld(chosen_meld_dict): 51 | return False 52 | 53 | selected_tile = chosen_meld_dict["discard_tile"] 54 | logger_context = { 55 | "hand": tiles_to_string(self.player.closed_hand), 56 | "meld": chosen_meld_dict, 57 | "new_shanten": selected_tile.shanten, 58 | "new_ukeire": selected_tile.ukeire, 59 | } 60 | 61 | if selected_tile.shanten != 0: 62 | self.player.logger.debug( 63 | log.MELD_DEBUG, 64 | "Common tempai: for whatever reason we didn't choose discard giving us tempai, so abort melding", 65 | logger_context, 66 | ) 67 | return False 68 | 69 | if not selected_tile.tempai_descriptor: 70 | self.player.logger.debug( 71 | log.MELD_DEBUG, "Common tempai: no tempai descriptor found, so abort melding", logger_context 72 | ) 73 | return False 74 | 75 | if selected_tile.ukeire == 0: 76 | self.player.logger.debug(log.MELD_DEBUG, "Common tempai: 0 ukeire, abort melding", logger_context) 77 | return False 78 | 79 | if selected_tile.tempai_descriptor["hand_cost"]: 80 | hand_cost = selected_tile.tempai_descriptor["hand_cost"] 81 | else: 82 | hand_cost = selected_tile.tempai_descriptor["cost_x_ukeire"] / selected_tile.ukeire 83 | 84 | if hand_cost == 0: 85 | self.player.logger.debug(log.MELD_DEBUG, "Common tempai: hand costs nothing, abort melding", logger_context) 86 | return False 87 | 88 | # maybe we need a special handling due to placement 89 | # we have already checked that our meld is enough, now let's check that maybe we don't need to aim 90 | # for higher costs 91 | enough_cost = 32000 92 | if self.player.ai.placement.is_oorasu: 93 | placement = self.player.ai.placement.get_current_placement() 94 | if placement and placement["place"] == 4: 95 | enough_cost = self.player.ai.placement.get_minimal_cost_needed_considering_west() 96 | 97 | if self.player.round_step <= 6: 98 | if hand_cost >= min(7700, enough_cost): 99 | self.player.logger.debug(log.MELD_DEBUG, "Common tempai: the cost is good, call meld", logger_context) 100 | return True 101 | elif self.player.round_step <= 12: 102 | if self.player.is_dealer: 103 | if hand_cost >= min(5800, enough_cost): 104 | self.player.logger.debug( 105 | log.MELD_DEBUG, 106 | "Common tempai: the cost is ok for dealer and round step, call meld", 107 | logger_context, 108 | ) 109 | return True 110 | else: 111 | if hand_cost >= min(3900, enough_cost): 112 | self.player.logger.debug( 113 | log.MELD_DEBUG, 114 | "Common tempai: the cost is ok for non-dealer and round step, call meld", 115 | logger_context, 116 | ) 117 | return True 118 | else: 119 | self.player.logger.debug( 120 | log.MELD_DEBUG, "Common tempai: taking any tempai in the late round", logger_context 121 | ) 122 | return True 123 | 124 | self.player.logger.debug(log.MELD_DEBUG, "Common tempai: the cost is meh, so abort melding", logger_context) 125 | return False 126 | -------------------------------------------------------------------------------- /tenhou_env/project/system_testing/generate_tests.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from system_testing.cases import ACTION_CRASH, ACTION_DISCARD, ACTION_MELD, SYSTEM_TESTING_CASES 4 | 5 | system_testing_folder = Path(__file__).parent.absolute() 6 | project_folder = Path(__file__).parent.parent.parent.absolute() 7 | 8 | 9 | class TestsGen: 10 | @staticmethod 11 | def generate_documentation(): 12 | tests_file = Path(__file__).parent.absolute() / "test_system.py" 13 | result = [] 14 | 15 | result.append("# WARNING. It is an autogenerated file, don't change it manually.") 16 | 17 | result.append("import pytest") 18 | 19 | # header 20 | result.append( 21 | """import logging 22 | from pathlib import Path 23 | 24 | from mahjong.tile import TilesConverter 25 | from reproducer import TenhouLogReproducer, parse_reproducer_args 26 | 27 | logger = logging.getLogger() 28 | system_testing_folder = Path(__file__).parent.absolute() 29 | 30 | """ 31 | ) 32 | # helper function 33 | result.append("def _run_reproducer(file_name, reproducer_command):") 34 | result.append(TestsGen.indent('log_file_path = system_testing_folder / "fixtures" / file_name')) 35 | result.append( 36 | TestsGen.indent('opts = parse_reproducer_args(reproducer_command.replace("python ", "").split(" "))') 37 | ) 38 | result.append( 39 | TestsGen.indent("reproducer = TenhouLogReproducer(log_id=None, file_path=log_file_path, logger=logger)") 40 | ) 41 | result.append( 42 | TestsGen.indent( 43 | "return reproducer.reproduce(opts.player, opts.wind, opts.honba, " 44 | "context={'action': opts.action, 'needed_tile': opts.tile, 'tile_number_to_stop': opts.n})" 45 | ) 46 | ) 47 | result.append("\n") 48 | 49 | for case in SYSTEM_TESTING_CASES: 50 | index = case["index"] 51 | action = case["action"] 52 | description = case["description"] 53 | reproducer_command = case["reproducer_command"] 54 | 55 | if case.get("skip_reason"): 56 | result.append(f"@pytest.mark.skip('{case['skip_reason']}')") 57 | 58 | # test header 59 | result.append(f"def test_system_case_{index}():") 60 | result.append(TestsGen.indent('"""')) 61 | result.append(TestsGen.indent(f"Case #{index}")) 62 | if description: 63 | result.append(TestsGen.indent(description)) 64 | result.append(TestsGen.indent('"""')) 65 | result.append(TestsGen.indent("")) 66 | 67 | if action == ACTION_DISCARD: 68 | allowed_discards = case["allowed_discards"] 69 | with_riichi = case["with_riichi"] 70 | 71 | # input variables 72 | result.append(TestsGen.indent(f'reproducer_command = "{reproducer_command}"')) 73 | result.append(TestsGen.indent(f"allowed_discards = {allowed_discards}")) 74 | result.append(TestsGen.indent(f"with_riichi = {with_riichi}")) 75 | result.append(TestsGen.indent("")) 76 | 77 | # assert 78 | result.append( 79 | TestsGen.indent(f'result, with_riichi_result = _run_reproducer("{index}.txt", reproducer_command)') 80 | ) 81 | result.append(TestsGen.indent("assert TilesConverter.to_one_line_string([result]) in allowed_discards")) 82 | if with_riichi is not None: 83 | result.append(TestsGen.indent("assert with_riichi == with_riichi_result")) 84 | result.append("\n") 85 | 86 | if action == ACTION_MELD: 87 | meld = case["meld"] 88 | tile_after_meld = case["tile_after_meld"] 89 | 90 | # input variables 91 | result.append(TestsGen.indent(f'reproducer_command = "{reproducer_command}"')) 92 | result.append(TestsGen.indent(f"needed_meld = {meld}")) 93 | if tile_after_meld: 94 | result.append(TestsGen.indent(f'tile_after_meld = "{tile_after_meld}"')) 95 | else: 96 | result.append(TestsGen.indent("tile_after_meld = None")) 97 | result.append(TestsGen.indent("")) 98 | 99 | # assert 100 | result.append( 101 | TestsGen.indent( 102 | f'result_meld, result_tile_after_meld = _run_reproducer("{index}.txt", reproducer_command)' 103 | ) 104 | ) 105 | 106 | if meld: 107 | result.append(TestsGen.indent('assert result_meld.type == needed_meld["type"]')) 108 | result.append( 109 | TestsGen.indent( 110 | 'assert TilesConverter.to_one_line_string(result_meld.tiles) == needed_meld["tiles"]' 111 | ) 112 | ) 113 | result.append( 114 | TestsGen.indent( 115 | "assert TilesConverter.to_one_line_string([result_tile_after_meld.tile_to_discard_136]) " 116 | "== tile_after_meld" 117 | ) 118 | ) 119 | else: 120 | result.append(TestsGen.indent("assert result_meld == needed_meld")) 121 | result.append(TestsGen.indent("assert result_tile_after_meld is None")) 122 | 123 | result.append("\n") 124 | 125 | if action == ACTION_CRASH: 126 | result.append(TestsGen.indent(f'reproducer_command = "{reproducer_command}"')) 127 | 128 | result.append(TestsGen.indent(f'_run_reproducer("{index}.txt", reproducer_command)')) 129 | 130 | tests_file.write_text("\n".join(result)) 131 | 132 | @staticmethod 133 | def indent(string): 134 | return f" {string}" 135 | --------------------------------------------------------------------------------