├── .coveragerc ├── .editorconfig ├── .flake8 ├── .github └── workflows │ └── pythonapp.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── Dockerfile ├── LICENSE.txt ├── Makefile ├── README.md ├── bin └── run.sh ├── doc ├── reproducer.md ├── system_testing.md └── versions.md ├── docker-compose.yml ├── project ├── __init__.py ├── bots_battle.py ├── game │ ├── __init__.py │ ├── ai │ │ ├── __init__.py │ │ ├── configs │ │ │ ├── __init__.py │ │ │ ├── bot_ichihime.py │ │ │ ├── bot_kaavi.py │ │ │ ├── bot_wanjirou.py │ │ │ ├── bot_xenia.py │ │ │ └── default.py │ │ ├── defence │ │ │ ├── __init__.py │ │ │ ├── enemy_analyzer.py │ │ │ ├── main.py │ │ │ ├── tests │ │ │ │ ├── __init__.py │ │ │ │ ├── test_threat.py │ │ │ │ ├── test_threat_and_push_fold_decisions.py │ │ │ │ └── test_tiles_danger.py │ │ │ └── yaku_analyzer │ │ │ │ ├── atodzuke.py │ │ │ │ ├── chinitsu.py │ │ │ │ ├── honitsu.py │ │ │ │ ├── honitsu_analyzer_base.py │ │ │ │ ├── tanyao.py │ │ │ │ ├── toitoi.py │ │ │ │ ├── yaku_analyzer.py │ │ │ │ └── yakuhai.py │ │ ├── discard.py │ │ ├── hand_builder.py │ │ ├── helpers │ │ │ ├── __init__.py │ │ │ ├── defence.py │ │ │ ├── kabe.py │ │ │ ├── possible_forms.py │ │ │ └── suji.py │ │ ├── kan.py │ │ ├── main.py │ │ ├── open_hand.py │ │ ├── placement.py │ │ ├── riichi.py │ │ ├── statistics_collector.py │ │ ├── strategies │ │ │ ├── __init__.py │ │ │ ├── chinitsu.py │ │ │ ├── common_open_tempai.py │ │ │ ├── formal_tempai.py │ │ │ ├── honitsu.py │ │ │ ├── main.py │ │ │ ├── tanyao.py │ │ │ ├── tests │ │ │ │ ├── __init__.py │ │ │ │ ├── test_chiitoitsu.py │ │ │ │ ├── test_chinitsu.py │ │ │ │ ├── test_common_open_tempai.py │ │ │ │ ├── test_formal_tempai.py │ │ │ │ ├── test_honitsu.py │ │ │ │ ├── test_tanyao.py │ │ │ │ └── test_yakuhai.py │ │ │ └── yakuhai.py │ │ └── tests │ │ │ ├── __init__.py │ │ │ ├── test_ai.py │ │ │ ├── test_discards.py │ │ │ ├── test_kan.py │ │ │ ├── test_placement.py │ │ │ └── test_riichi.py │ ├── bots_battle │ │ ├── __init__.py │ │ ├── battle_config.py │ │ ├── game_manager.py │ │ ├── local_client.py │ │ └── replays │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── tenhou.py │ │ │ └── test_tenhou_encoder.py │ ├── client.py │ ├── player.py │ ├── table.py │ └── tests │ │ ├── __init__.py │ │ ├── test_client.py │ │ ├── test_player.py │ │ └── test_table.py ├── main.py ├── reproducer.py ├── run_stat.py ├── settings │ ├── __init__.py │ └── base.py ├── statistics │ ├── __init__.py │ ├── calculate_error_rate.py │ ├── cases │ │ ├── __init__.py │ │ ├── agari_riichi_cost.py │ │ └── main.py │ ├── db.py │ ├── log_parser.py │ └── merge_csv_files.py ├── system.py ├── system_testing │ ├── __init__.py │ ├── cases.py │ ├── fixtures │ │ ├── 1.jpg │ │ ├── 1.txt │ │ ├── 10.jpg │ │ ├── 10.txt │ │ ├── 11.jpg │ │ ├── 11.txt │ │ ├── 12.jpg │ │ ├── 12.txt │ │ ├── 13.jpg │ │ ├── 13.txt │ │ ├── 14.jpg │ │ ├── 14.txt │ │ ├── 15.jpg │ │ ├── 15.txt │ │ ├── 16.jpg │ │ ├── 16.txt │ │ ├── 17.jpg │ │ ├── 17.txt │ │ ├── 18.jpg │ │ ├── 18.txt │ │ ├── 19.jpg │ │ ├── 19.txt │ │ ├── 2.jpg │ │ ├── 2.txt │ │ ├── 20.jpg │ │ ├── 20.txt │ │ ├── 21.txt │ │ ├── 22.txt │ │ ├── 23.txt │ │ ├── 24.txt │ │ ├── 25.txt │ │ ├── 26.jpg │ │ ├── 26.txt │ │ ├── 27.txt │ │ ├── 28.jpg │ │ ├── 28.txt │ │ ├── 29.txt │ │ ├── 3.jpg │ │ ├── 3.txt │ │ ├── 30.jpg │ │ ├── 30.txt │ │ ├── 31.jpg │ │ ├── 31.txt │ │ ├── 32.jpg │ │ ├── 32.txt │ │ ├── 33.jpg │ │ ├── 33.txt │ │ ├── 34.jpg │ │ ├── 34.txt │ │ ├── 35.jpg │ │ ├── 35.txt │ │ ├── 36.jpg │ │ ├── 36.txt │ │ ├── 37.jpg │ │ ├── 37.txt │ │ ├── 38.jpg │ │ ├── 38.txt │ │ ├── 39.jpg │ │ ├── 39.txt │ │ ├── 4.jpg │ │ ├── 4.txt │ │ ├── 40.jpg │ │ ├── 40.txt │ │ ├── 41.jpg │ │ ├── 41.txt │ │ ├── 5.jpg │ │ ├── 5.txt │ │ ├── 6.jpg │ │ ├── 6.txt │ │ ├── 7.jpg │ │ ├── 7.txt │ │ ├── 8.jpg │ │ ├── 8.txt │ │ ├── 9.jpg │ │ └── 9.txt │ ├── generate_documentation.py │ ├── generate_tests.py │ └── test_system.py └── utils │ ├── __init__.py │ ├── cache.py │ ├── decisions_constants.py │ ├── decisions_logger.py │ ├── general.py │ ├── logger.py │ ├── settings_handler.py │ ├── statistics.py │ └── test_helpers.py ├── pyproject.toml ├── pytest.ini └── requirements ├── base.txt ├── dev.txt └── lint.txt /.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 23 | project/run_stat.py -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 -------------------------------------------------------------------------------- /.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@v2 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@v2 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', '3.10', 'pypy-3.7'] 46 | steps: 47 | - uses: actions/checkout@v2 48 | - name: Set up Python ${{ matrix.python-version }} 49 | uses: actions/setup-python@v2 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 -------------------------------------------------------------------------------- /.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 39 | 40 | docker-compose.production.yml 41 | docker-compose.tournament.yml -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM pypy:3.7-7.3.6-slim 2 | 3 | RUN useradd -ms /bin/bash docker-user 4 | 5 | WORKDIR /app/ 6 | 7 | RUN python3 -m pip install pip==21.3 8 | 9 | COPY requirements /requirements 10 | RUN pip install --no-cache-dir -r /requirements/dev.txt 11 | 12 | COPY ./project . 13 | 14 | USER docker-user -------------------------------------------------------------------------------- /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. -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | The project is not maintained anymore, and exists primary for historical reasons and references. 4 | 5 | Bot was tested with Python 3.7+ and PyPy3, we are not supporting Python 2. 6 | 7 | # What do we have here? 8 | 9 | ![Example of bot game](https://cloud.githubusercontent.com/assets/475367/25059936/31b33ac2-21c3-11e7-8cb2-de33d7ba96cb.gif) 10 | 11 | ## Mahjong hands calculation 12 | 13 | You can find it here: https://github.com/MahjongRepository/mahjong 14 | 15 | ## Mahjong bot 16 | 17 | For research purposes we built a simple bot to play riichi mahjong. It can be run locally. 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 | -------------------------------------------------------------------------------- /bin/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | # the cron job will be executed each 5 minutes 4 | # and it will try to find bot process 5 | # if there is no process, it will run it 6 | # example of usage 7 | # */5 * * * * bash /root/bot/bin/run.sh bot_settings_name 8 | 9 | SETTINGS_NAME="$1" 10 | 11 | PID=`ps -eaf | grep "project/main.py -s ${SETTINGS_NAME}" | grep -v grep | awk '{print $2}'` 12 | 13 | if [[ "" = "$PID" ]]; then 14 | /root/bot/env/bin/python /root/bot/project/main.py -s ${SETTINGS_NAME} 15 | else 16 | WORKED_SECONDS=`ps -p "$PID" -o etimes=` 17 | # if process run > 60 minutes, probably it hangs and we need to kill it 18 | if [[ ${WORKED_SECONDS} -gt "3600" ]]; then 19 | kill ${PID} 20 | fi 21 | fi -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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. -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /project/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahjongRepository/tenhou-python-bot/112b08faab08ee862813de06cb5acc5db1c4feb0/project/__init__.py -------------------------------------------------------------------------------- /project/bots_battle.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | import os 4 | import random 5 | from optparse import OptionParser 6 | 7 | import game.bots_battle 8 | from game.bots_battle.battle_config import BattleConfig 9 | from game.bots_battle.game_manager import GameManager 10 | from game.bots_battle.local_client import LocalClient 11 | from tqdm import trange 12 | from utils.logger import DATE_FORMAT, LOG_FORMAT 13 | from utils.settings_handler import settings 14 | 15 | logger = logging.getLogger("game") 16 | 17 | battle_results_folder = os.path.join(os.path.dirname(os.path.realpath(__file__)), "battle_results") 18 | if not os.path.exists(battle_results_folder): 19 | os.mkdir(battle_results_folder) 20 | 21 | 22 | def main(number_of_games, print_logs): 23 | seeds = [] 24 | seed_file = "seeds.txt" 25 | if os.path.exists(seed_file): 26 | with open(seed_file, "r") as f: 27 | seeds = f.read().split("\n") 28 | seeds = [int(x.strip()) for x in seeds if x.strip()] 29 | 30 | replays_directory = os.path.join(battle_results_folder, "replays") 31 | if not os.path.exists(replays_directory): 32 | os.mkdir(replays_directory) 33 | 34 | for i in trange(number_of_games): 35 | if i < len(seeds): 36 | seed_value = seeds[i] 37 | else: 38 | seed_value = random.getrandbits(64) 39 | 40 | replay_name = GameManager.generate_replay_name() 41 | 42 | clients = [LocalClient(BattleConfig.CLIENTS_CONFIGS[x](), print_logs, replay_name, i) for x in range(0, 4)] 43 | manager = GameManager(clients, replays_directory, replay_name) 44 | 45 | try: 46 | game.bots_battle.game_manager.shuffle_seed = lambda: seed_value 47 | manager.play_game() 48 | except Exception as e: 49 | manager.replay.save_failed_log() 50 | logger.error(f"Hanchan seed={seed_value} crashed", exc_info=e) 51 | 52 | 53 | def _set_up_bots_battle_game_logger(): 54 | logs_directory = os.path.join(battle_results_folder, "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 | file_name = f"{datetime.datetime.now().strftime('%Y-%m-%d_%H_%M_%S')}.log" 60 | fh = logging.FileHandler(os.path.join(logs_directory, file_name), encoding="utf-8") 61 | fh.setLevel(logging.DEBUG) 62 | fh.setFormatter(formatter) 63 | 64 | logger = logging.getLogger("game") 65 | logger.setLevel(logging.DEBUG) 66 | logger.addHandler(fh) 67 | 68 | 69 | if __name__ == "__main__": 70 | _set_up_bots_battle_game_logger() 71 | 72 | parser = OptionParser() 73 | parser.add_option( 74 | "-g", 75 | "--games", 76 | type="int", 77 | default=1, 78 | help="Number of games to play", 79 | ) 80 | parser.add_option( 81 | "--logs", 82 | action="store_true", 83 | help="Enable logs for bots, use it only for debug, not for live games", 84 | ) 85 | opts, _ = parser.parse_args() 86 | 87 | settings.FIVE_REDS = True 88 | settings.OPEN_TANYAO = True 89 | settings.PRINT_LOGS = False 90 | 91 | if opts.logs: 92 | settings.PRINT_LOGS = True 93 | 94 | main(opts.games, opts.logs) 95 | -------------------------------------------------------------------------------- /project/game/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahjongRepository/tenhou-python-bot/112b08faab08ee862813de06cb5acc5db1c4feb0/project/game/__init__.py -------------------------------------------------------------------------------- /project/game/ai/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahjongRepository/tenhou-python-bot/112b08faab08ee862813de06cb5acc5db1c4feb0/project/game/ai/__init__.py -------------------------------------------------------------------------------- /project/game/ai/configs/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahjongRepository/tenhou-python-bot/112b08faab08ee862813de06cb5acc5db1c4feb0/project/game/ai/configs/__init__.py -------------------------------------------------------------------------------- /project/game/ai/configs/bot_ichihime.py: -------------------------------------------------------------------------------- 1 | from game.ai.configs.default import BotDefaultConfig 2 | 3 | 4 | class IchihimeConfig(BotDefaultConfig): 5 | name = "Ichihime" 6 | -------------------------------------------------------------------------------- /project/game/ai/configs/bot_kaavi.py: -------------------------------------------------------------------------------- 1 | from game.ai.configs.default import BotDefaultConfig 2 | 3 | 4 | class KaaviConfig(BotDefaultConfig): 5 | name = "Kaavi" 6 | -------------------------------------------------------------------------------- /project/game/ai/configs/bot_wanjirou.py: -------------------------------------------------------------------------------- 1 | from game.ai.configs.default import BotDefaultConfig 2 | 3 | 4 | class WanjirouConfig(BotDefaultConfig): 5 | name = "Wanjirou" 6 | -------------------------------------------------------------------------------- /project/game/ai/configs/bot_xenia.py: -------------------------------------------------------------------------------- 1 | from game.ai.configs.default import BotDefaultConfig 2 | 3 | 4 | class XeniaConfig(BotDefaultConfig): 5 | name = "Xenia" 6 | -------------------------------------------------------------------------------- /project/game/ai/configs/default.py: -------------------------------------------------------------------------------- 1 | from game.ai.open_hand import OpenHandHandler 2 | from game.ai.placement import PlacementHandler 3 | from game.ai.riichi import Riichi 4 | 5 | 6 | class BotDefaultConfig: 7 | # all features that we are testing should starts with FEATURE_ prefix 8 | # with that it will be easier to track these flags usage over the code 9 | FEATURE_DEFENCE_ENABLED = True 10 | 11 | PLACEMENT_HANDLER_CLASS = PlacementHandler 12 | OPEN_HAND_HANDLER_CLASS = OpenHandHandler 13 | RIICHI_HANDLER_CLASS = Riichi 14 | 15 | TUNE_DANGER_BORDER_TEMPAI_VALUE = 0 16 | TUNE_DANGER_BORDER_1_SHANTEN_VALUE = 0 17 | TUNE_DANGER_BORDER_2_SHANTEN_VALUE = 0 18 | -------------------------------------------------------------------------------- /project/game/ai/defence/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahjongRepository/tenhou-python-bot/112b08faab08ee862813de06cb5acc5db1c4feb0/project/game/ai/defence/__init__.py -------------------------------------------------------------------------------- /project/game/ai/defence/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahjongRepository/tenhou-python-bot/112b08faab08ee862813de06cb5acc5db1c4feb0/project/game/ai/defence/tests/__init__.py -------------------------------------------------------------------------------- /project/game/ai/defence/tests/test_threat_and_push_fold_decisions.py: -------------------------------------------------------------------------------- 1 | from game.table import Table 2 | from mahjong.constants import FIVE_RED_MAN 3 | from utils.decisions_logger import MeldPrint 4 | from utils.test_helpers import enemy_called_riichi_helper, find_discard_option, string_to_136_array, string_to_136_tile 5 | 6 | from project.utils.test_helpers import make_meld 7 | 8 | 9 | def test_calculate_our_hand_cost(): 10 | table = _make_table() 11 | player = table.player 12 | enemy_seat = 2 13 | enemy_called_riichi_helper(table, enemy_seat) 14 | table.add_discarded_tile(enemy_seat, string_to_136_tile(pin="9"), True) 15 | 16 | tiles = string_to_136_array(sou="234678", pin="23478", man="22") 17 | tile = string_to_136_tile(honors="2") 18 | player.init_hand(tiles) 19 | player.draw_tile(tile) 20 | 21 | discard_option = find_discard_option(player, honors="2") 22 | assert discard_option.danger.weighted_cost == 6128 23 | 24 | 25 | def test_calculate_our_hand_cost_1_shanten(): 26 | table = _make_table() 27 | player = table.player 28 | enemy_seat = 2 29 | 30 | table.has_open_tanyao = True 31 | table.has_aka_dora = False 32 | 33 | enemy_called_riichi_helper(table, enemy_seat) 34 | 35 | tiles = string_to_136_array(sou="22245677", pin="145", man="67") 36 | 37 | tile = string_to_136_tile(honors="1") 38 | player.init_hand(tiles) 39 | player.add_called_meld(make_meld(MeldPrint.PON, sou="222")) 40 | player.draw_tile(tile) 41 | 42 | discard_option = find_discard_option(player, honors="1") 43 | cost = discard_option.average_second_level_cost 44 | 45 | assert cost == 1500 46 | 47 | table.add_dora_indicator(string_to_136_tile(sou="6")) 48 | discard_option = find_discard_option(player, honors="1") 49 | cost = discard_option.average_second_level_cost 50 | 51 | assert cost == 5850 52 | 53 | table.add_dora_indicator(string_to_136_tile(pin="2")) 54 | discard_option = find_discard_option(player, honors="1") 55 | cost = discard_option.average_second_level_cost 56 | 57 | assert cost == 8737 58 | 59 | 60 | def test_calculate_our_hand_cost_1_shanten_karaten(): 61 | table = _make_table() 62 | player = table.player 63 | enemy_seat = 2 64 | 65 | table.has_open_tanyao = True 66 | table.has_aka_dora = False 67 | 68 | enemy_called_riichi_helper(table, enemy_seat) 69 | 70 | tiles = string_to_136_array(sou="22245677", pin="145", man="67") 71 | 72 | tile = string_to_136_tile(honors="1") 73 | player.init_hand(tiles) 74 | player.add_called_meld(make_meld(MeldPrint.PON, sou="222")) 75 | player.draw_tile(tile) 76 | 77 | # average cost should not change because of less waits 78 | for _ in range(0, 4): 79 | table.add_discarded_tile(1, string_to_136_tile(pin="3"), False) 80 | 81 | discard_option = find_discard_option(player, honors="1") 82 | cost = discard_option.average_second_level_cost 83 | 84 | assert cost == 1500 85 | 86 | # average cost should become 0 for karaten, even if just one of the waits is dead 87 | for _ in range(0, 4): 88 | table.add_discarded_tile(1, string_to_136_tile(pin="6"), False) 89 | 90 | discard_option = find_discard_option(player, honors="1") 91 | cost = discard_option.average_second_level_cost 92 | 93 | assert cost == 0 94 | 95 | # nothing should crash in case all waits are dead as well 96 | for _ in range(0, 4): 97 | table.add_discarded_tile(1, string_to_136_tile(man="5"), False) 98 | table.add_discarded_tile(1, string_to_136_tile(man="8"), False) 99 | 100 | discard_option = find_discard_option(player, honors="1") 101 | cost = discard_option.average_second_level_cost 102 | 103 | assert cost == 0 104 | 105 | 106 | def test_dont_open_bad_hand_if_there_are_expensive_threat(): 107 | table = _make_table() 108 | table.add_dora_indicator(string_to_136_tile(man="4")) 109 | player = table.player 110 | player.round_step = 10 111 | table.has_open_tanyao = True 112 | table.has_aka_dora = True 113 | 114 | enemy_seat = 1 115 | enemy_called_riichi_helper(table, enemy_seat) 116 | table.add_discarded_tile(enemy_seat, string_to_136_tile(honors="4"), True) 117 | 118 | tiles = string_to_136_array(sou="226", pin="2469", man="3344", honors="4") + [FIVE_RED_MAN] 119 | player.init_hand(tiles) 120 | 121 | # cheap enemy tempai, but this meld is garbage, let's not push 122 | tile = string_to_136_array(man="4444")[2] 123 | meld, _ = player.try_to_call_meld(tile, True) 124 | assert meld is None 125 | 126 | # cheap enemy tempai, and good chi, let's take this meld 127 | tile = string_to_136_tile(man="2") 128 | meld, _ = player.try_to_call_meld(tile, True) 129 | assert meld is not None 130 | 131 | table.add_called_meld(enemy_seat, make_meld(MeldPrint.KAN, is_open=False, honors="1111")) 132 | # enemy hand is more expensive now (12000) 133 | # in this case let's not open this hand 134 | tile = string_to_136_tile(man="2") 135 | meld, _ = player.try_to_call_meld(tile, True) 136 | assert meld is None 137 | 138 | 139 | def test_dont_open_bad_hand_if_there_are_multiple_threats(): 140 | table = _make_table() 141 | table.add_dora_indicator(string_to_136_tile(man="4")) 142 | player = table.player 143 | player.round_step = 10 144 | table.has_open_tanyao = True 145 | table.has_aka_dora = True 146 | 147 | enemy_called_riichi_helper(table, 1) 148 | table.add_discarded_tile(1, string_to_136_tile(honors="4"), True) 149 | 150 | enemy_called_riichi_helper(table, 2) 151 | table.add_discarded_tile(2, string_to_136_tile(honors="4"), True) 152 | 153 | tiles = string_to_136_array(sou="22499", pin="27", man="3344", honors="4") + [FIVE_RED_MAN] 154 | player.init_hand(tiles) 155 | tile = string_to_136_tile(man="4") 156 | 157 | # there are multiple threats with (3900+) hands 158 | # let's not push in that case 159 | meld, _ = player.try_to_call_meld(tile, False) 160 | assert meld is None 161 | 162 | 163 | def _make_table(): 164 | table = Table() 165 | table.init_round(1, 0, 0, string_to_136_tile(honors="4"), 0, [250, 250, 250, 250]) 166 | # with that we don't have daburi anymore 167 | table.player.round_step = 1 168 | return table 169 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /project/game/ai/discard.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from game.ai.helpers.defence import TileDangerHandler 4 | from game.ai.strategies.main import BaseStrategy 5 | from mahjong.tile import TilesConverter 6 | from mahjong.utils import is_honor, is_man, is_pin, is_sou, plus_dora, simplify 7 | 8 | 9 | class DiscardOption: 10 | DORA_VALUE = 10000 11 | DORA_FIRST_NEIGHBOUR = 1000 12 | DORA_SECOND_NEIGHBOUR = 100 13 | 14 | UKEIRE_FIRST_FILTER_PERCENTAGE = 20 15 | UKEIRE_SECOND_FILTER_PERCENTAGE = 25 16 | UKEIRE_DANGER_FILTER_PERCENTAGE = 10 17 | 18 | MIN_UKEIRE_DANGER_BORDER = 2 19 | MIN_UKEIRE_TEMPAI_BORDER = 2 20 | MIN_UKEIRE_SHANTEN_1_BORDER = 4 21 | MIN_UKEIRE_SHANTEN_2_BORDER = 8 22 | 23 | player = None 24 | 25 | # in 136 tile format 26 | tile_to_discard_136 = None 27 | # are we calling riichi on this tile or not 28 | with_riichi = None 29 | # array of tiles that will improve our hand 30 | waiting: List[int] = None 31 | # how much tiles will improve our hand 32 | ukeire = None 33 | ukeire_second = None 34 | # number of shanten for that tile 35 | shanten = None 36 | # sometimes we had to force tile to be discarded 37 | had_to_be_discarded = False 38 | # calculated tile value, for sorting 39 | valuation = None 40 | # how danger this tile is 41 | danger = None 42 | # wait to ukeire map 43 | wait_to_ukeire = None 44 | # second level cost approximation for 1-shanten hands 45 | second_level_cost = None 46 | # second level average number of waits approximation for 1-shanten hands 47 | average_second_level_waits = None 48 | # second level average cost approximation for 1-shanten hands 49 | average_second_level_cost = None 50 | # special descriptor for tempai with additional info 51 | tempai_descriptor = None 52 | 53 | def __init__(self, player, tile_to_discard_136, shanten, waiting, ukeire, wait_to_ukeire=None): 54 | self.player = player 55 | self.tile_to_discard_136 = tile_to_discard_136 56 | self.with_riichi = False 57 | self.shanten = shanten 58 | self.waiting = waiting 59 | self.ukeire = ukeire 60 | self.ukeire_second = 0 61 | self.count_of_dora = 0 62 | self.danger = TileDangerHandler() 63 | self.had_to_be_discarded = False 64 | self.wait_to_ukeire = wait_to_ukeire 65 | self.second_level_cost = 0 66 | self.average_second_level_waits = 0 67 | self.average_second_level_cost = 0 68 | self.tempai_descriptor = None 69 | 70 | self.calculate_valuation() 71 | 72 | @property 73 | def tile_to_discard_34(self): 74 | return self.tile_to_discard_136 // 4 75 | 76 | def serialize(self): 77 | data = { 78 | "tile": TilesConverter.to_one_line_string( 79 | [self.tile_to_discard_136], print_aka_dora=self.player.table.has_aka_dora 80 | ), 81 | "shanten": self.shanten, 82 | "ukeire": self.ukeire, 83 | "valuation": self.valuation, 84 | "danger": { 85 | "max_danger": self.danger.get_max_danger(), 86 | "sum_danger": self.danger.get_sum_danger(), 87 | "weighted_danger": self.danger.get_weighted_danger(), 88 | "min_border": self.danger.get_min_danger_border(), 89 | "danger_border": self.danger.danger_border, 90 | "weighted_cost": self.danger.weighted_cost, 91 | "danger_reasons": self.danger.values, 92 | "can_be_used_for_ryanmen": self.danger.can_be_used_for_ryanmen, 93 | }, 94 | } 95 | if self.shanten == 0: 96 | data["with_riichi"] = self.with_riichi 97 | if self.ukeire_second: 98 | data["ukeire2"] = self.ukeire_second 99 | if self.average_second_level_waits: 100 | data["average_second_level_waits"] = self.average_second_level_waits 101 | if self.average_second_level_cost: 102 | data["average_second_level_cost"] = self.average_second_level_cost 103 | if self.had_to_be_discarded: 104 | data["had_to_be_discarded"] = self.had_to_be_discarded 105 | return data 106 | 107 | def calculate_valuation(self): 108 | # base is 100 for ability to mark tiles as not needed (like set value to 50) 109 | value = 100 110 | honored_value = 20 111 | 112 | if is_honor(self.tile_to_discard_34): 113 | if self.tile_to_discard_34 in self.player.valued_honors: 114 | count_of_winds = [x for x in self.player.valued_honors if x == self.tile_to_discard_34] 115 | # for west-west, east-east we had to double tile value 116 | value += honored_value * len(count_of_winds) 117 | else: 118 | # aim for tanyao 119 | if ( 120 | self.player.ai.open_hand_handler.current_strategy 121 | and self.player.ai.open_hand_handler.current_strategy.type == BaseStrategy.TANYAO 122 | ): 123 | suit_tile_grades = [10, 20, 30, 50, 40, 50, 30, 20, 10] 124 | # usual hand 125 | else: 126 | suit_tile_grades = [10, 20, 40, 50, 30, 50, 40, 20, 10] 127 | 128 | simplified_tile = simplify(self.tile_to_discard_34) 129 | value += suit_tile_grades[simplified_tile] 130 | 131 | for indicator in self.player.table.dora_indicators: 132 | indicator_34 = indicator // 4 133 | if is_honor(indicator_34): 134 | continue 135 | 136 | # indicator and tile not from the same suit 137 | if is_sou(indicator_34) and not is_sou(self.tile_to_discard_34): 138 | continue 139 | 140 | # indicator and tile not from the same suit 141 | if is_man(indicator_34) and not is_man(self.tile_to_discard_34): 142 | continue 143 | 144 | # indicator and tile not from the same suit 145 | if is_pin(indicator_34) and not is_pin(self.tile_to_discard_34): 146 | continue 147 | 148 | simplified_indicator = simplify(indicator_34) 149 | simplified_dora = simplified_indicator + 1 150 | # indicator is 9 man 151 | if simplified_dora == 9: 152 | simplified_dora = 0 153 | 154 | # tile so close to the dora 155 | if simplified_tile + 1 == simplified_dora or simplified_tile - 1 == simplified_dora: 156 | value += DiscardOption.DORA_FIRST_NEIGHBOUR 157 | 158 | # tile not far away from dora 159 | if simplified_tile + 2 == simplified_dora or simplified_tile - 2 == simplified_dora: 160 | value += DiscardOption.DORA_SECOND_NEIGHBOUR 161 | 162 | count_of_dora = plus_dora( 163 | self.tile_to_discard_136, self.player.table.dora_indicators, add_aka_dora=self.player.table.has_aka_dora 164 | ) 165 | 166 | self.count_of_dora = count_of_dora 167 | value += count_of_dora * DiscardOption.DORA_VALUE 168 | 169 | if is_honor(self.tile_to_discard_34): 170 | # depends on how much honor tiles were discarded 171 | # we will decrease tile value 172 | discard_percentage = [100, 75, 20, 0, 0] 173 | discarded_tiles = self.player.table.revealed_tiles[self.tile_to_discard_34] 174 | 175 | value = (value * discard_percentage[discarded_tiles]) / 100 176 | 177 | # three honor tiles were discarded, 178 | # so we don't need this tile anymore 179 | if value == 0: 180 | self.had_to_be_discarded = True 181 | 182 | self.valuation = int(value) 183 | -------------------------------------------------------------------------------- /project/game/ai/helpers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahjongRepository/tenhou-python-bot/112b08faab08ee862813de06cb5acc5db1c4feb0/project/game/ai/helpers/__init__.py -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /project/game/ai/helpers/possible_forms.py: -------------------------------------------------------------------------------- 1 | from game.ai.helpers.defence import TileDanger 2 | from mahjong.constants import EAST 3 | from mahjong.tile import TilesConverter 4 | from utils.general import revealed_suits_tiles 5 | 6 | 7 | class PossibleFormsAnalyzer: 8 | POSSIBLE_TANKI = 1 9 | POSSIBLE_SYANPON = 2 10 | POSSIBLE_PENCHAN = 3 11 | POSSIBLE_KANCHAN = 4 12 | POSSIBLE_RYANMEN = 5 13 | POSSIBLE_RYANMEN_SIDES = 6 14 | 15 | def __init__(self, player): 16 | self.player = player 17 | 18 | def calculate_possible_forms(self, safe_tiles): 19 | possible_forms_34 = [None] * 34 20 | 21 | closed_hand_34 = TilesConverter.to_34_array(self.player.closed_hand) 22 | 23 | # first of all let's find suji for suits tiles 24 | suits = revealed_suits_tiles(self.player, closed_hand_34) 25 | for x in range(0, 3): 26 | suit = suits[x] 27 | 28 | for y in range(0, 9): 29 | tile_34_index = y + x * 9 30 | 31 | # we are only interested in tiles that we can discard 32 | if closed_hand_34[tile_34_index] == 0: 33 | continue 34 | 35 | forms_count = self._init_zero_forms_count() 36 | possible_forms_34[tile_34_index] = forms_count 37 | 38 | # that means there are no possible forms for him to wait (we don't consider furiten here, 39 | # because we are defending from enemy taking ron) 40 | if tile_34_index in safe_tiles: 41 | continue 42 | 43 | # tanki 44 | forms_count[self.POSSIBLE_TANKI] = 4 - suit[y] 45 | 46 | # syanpon 47 | if suit[y] == 1: 48 | forms_count[self.POSSIBLE_SYANPON] = 3 49 | if suit[y] == 2: 50 | forms_count[self.POSSIBLE_SYANPON] = 1 51 | else: 52 | forms_count[self.POSSIBLE_SYANPON] = 0 53 | 54 | # penchan 55 | if y == 2: 56 | forms_count[self.POSSIBLE_PENCHAN] = (4 - suit[0]) * (4 - suit[1]) 57 | elif y == 6: 58 | forms_count[self.POSSIBLE_PENCHAN] = (4 - suit[8]) * (4 - suit[7]) 59 | 60 | # kanchan 61 | if 1 <= y <= 7: 62 | tiles_cnt_left = 4 - suit[y - 1] 63 | tiles_cnt_right = 4 - suit[y + 1] 64 | forms_count[self.POSSIBLE_KANCHAN] = tiles_cnt_left * tiles_cnt_right 65 | 66 | # ryanmen 67 | if 0 <= y <= 2: 68 | if not (tile_34_index + 3) in safe_tiles: 69 | forms_right = (4 - suit[y + 1]) * (4 - suit[y + 2]) 70 | if forms_right != 0: 71 | forms_count[self.POSSIBLE_RYANMEN_SIDES] = 1 72 | forms_count[self.POSSIBLE_RYANMEN] = (4 - suit[y + 1]) * (4 - suit[y + 2]) 73 | elif 3 <= y <= 5: 74 | if not (tile_34_index - 3) in safe_tiles: 75 | forms_left = (4 - suit[y - 1]) * (4 - suit[y - 2]) 76 | if forms_left != 0: 77 | forms_count[self.POSSIBLE_RYANMEN_SIDES] += 1 78 | forms_count[self.POSSIBLE_RYANMEN] += forms_left 79 | if not (tile_34_index + 3) in safe_tiles: 80 | forms_right = (4 - suit[y + 1]) * (4 - suit[y + 2]) 81 | if forms_right != 0: 82 | forms_count[self.POSSIBLE_RYANMEN_SIDES] += 1 83 | forms_count[self.POSSIBLE_RYANMEN] += forms_right 84 | else: 85 | if not (tile_34_index - 3) in safe_tiles: 86 | forms_left = (4 - suit[y - 1]) * (4 - suit[y - 2]) 87 | if forms_left != 0: 88 | forms_count[self.POSSIBLE_RYANMEN] = (4 - suit[y - 1]) * (4 - suit[y - 2]) 89 | forms_count[self.POSSIBLE_RYANMEN_SIDES] = 1 90 | 91 | for tile_34_index in range(EAST, 34): 92 | if closed_hand_34[tile_34_index] == 0: 93 | continue 94 | 95 | forms_count = self._init_zero_forms_count() 96 | possible_forms_34[tile_34_index] = forms_count 97 | 98 | total_tiles = self.player.number_of_revealed_tiles(tile_34_index, closed_hand_34) 99 | 100 | # tanki 101 | forms_count[self.POSSIBLE_TANKI] = 4 - total_tiles 102 | 103 | # syanpon 104 | forms_count[self.POSSIBLE_SYANPON] = 3 - total_tiles if total_tiles < 3 else 0 105 | 106 | return possible_forms_34 107 | 108 | @staticmethod 109 | def calculate_possible_forms_total(forms_count): 110 | total = 0 111 | total += forms_count[PossibleFormsAnalyzer.POSSIBLE_TANKI] 112 | total += forms_count[PossibleFormsAnalyzer.POSSIBLE_SYANPON] 113 | total += forms_count[PossibleFormsAnalyzer.POSSIBLE_PENCHAN] 114 | total += forms_count[PossibleFormsAnalyzer.POSSIBLE_KANCHAN] 115 | total += forms_count[PossibleFormsAnalyzer.POSSIBLE_RYANMEN] 116 | return total 117 | 118 | @staticmethod 119 | def calculate_possible_forms_danger(forms_count): 120 | danger = 0 121 | danger += forms_count[PossibleFormsAnalyzer.POSSIBLE_TANKI] * TileDanger.FORM_BONUS_TANKI 122 | danger += forms_count[PossibleFormsAnalyzer.POSSIBLE_SYANPON] * TileDanger.FORM_BONUS_SYANPON 123 | danger += forms_count[PossibleFormsAnalyzer.POSSIBLE_PENCHAN] * TileDanger.FORM_BONUS_PENCHAN 124 | danger += forms_count[PossibleFormsAnalyzer.POSSIBLE_KANCHAN] * TileDanger.FORM_BONUS_KANCHAN 125 | danger += forms_count[PossibleFormsAnalyzer.POSSIBLE_RYANMEN] * TileDanger.FORM_BONUS_RYANMEN 126 | return danger 127 | 128 | def _init_zero_forms_count(self): 129 | forms_count = dict() 130 | forms_count[self.POSSIBLE_TANKI] = 0 131 | forms_count[self.POSSIBLE_SYANPON] = 0 132 | forms_count[self.POSSIBLE_PENCHAN] = 0 133 | forms_count[self.POSSIBLE_KANCHAN] = 0 134 | forms_count[self.POSSIBLE_RYANMEN] = 0 135 | forms_count[self.POSSIBLE_RYANMEN_SIDES] = 0 136 | return forms_count 137 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /project/game/ai/kan.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import utils.decisions_constants as log 4 | from mahjong.tile import TilesConverter 5 | from mahjong.utils import is_pon 6 | from utils.decisions_logger import MeldPrint 7 | 8 | 9 | class Kan: 10 | def __init__(self, player): 11 | self.player = player 12 | 13 | # TODO for better readability need to separate it on three methods: 14 | # should_call_closed_kan, should_call_open_kan, should_call_shouminkan 15 | def should_call_kan(self, tile_136: int, open_kan: bool, from_riichi=False) -> Optional[str]: 16 | """ 17 | Method will decide should we call a kan, or upgrade pon to kan 18 | :return: kan type 19 | """ 20 | 21 | # we can't call kan on the latest tile 22 | if self.player.table.count_of_remaining_tiles <= 1: 23 | return None 24 | 25 | if self.player.config.FEATURE_DEFENCE_ENABLED: 26 | threats = self.player.ai.defence.get_threatening_players() 27 | else: 28 | threats = [] 29 | 30 | if open_kan: 31 | # we don't want to start open our hand from called kan 32 | if not self.player.is_open_hand: 33 | return None 34 | 35 | # there is no sense to call open kan when we are not in tempai 36 | if not self.player.in_tempai: 37 | return None 38 | 39 | # we have a bad wait, rinshan chance is low 40 | if len(self.player.ai.waiting) < 2 or self.player.ai.ukeire < 5: 41 | return None 42 | 43 | # there are threats, open kan is probably a bad idea 44 | if threats: 45 | return None 46 | 47 | tile_34 = tile_136 // 4 48 | tiles_34 = TilesConverter.to_34_array(self.player.tiles) 49 | 50 | # save original hand state 51 | original_tiles = self.player.tiles[:] 52 | 53 | new_shanten = 0 54 | previous_shanten = 0 55 | new_waits_count = 0 56 | previous_waits_count = 0 57 | 58 | # let's check can we upgrade opened pon to the kan 59 | pon_melds = [x for x in self.player.meld_34_tiles if is_pon(x)] 60 | has_shouminkan_candidate = False 61 | for meld in pon_melds: 62 | # tile is equal to our already opened pon 63 | if tile_34 in meld: 64 | has_shouminkan_candidate = True 65 | 66 | closed_hand_34 = TilesConverter.to_34_array(self.player.closed_hand) 67 | previous_shanten, previous_waits_count = self._calculate_shanten_for_kan() 68 | self.player.tiles = original_tiles[:] 69 | 70 | closed_hand_34[tile_34] -= 1 71 | tiles_34[tile_34] -= 1 72 | new_waiting, new_shanten = self.player.ai.hand_builder.calculate_waits( 73 | closed_hand_34, tiles_34, use_chiitoitsu=False 74 | ) 75 | new_waits_count = self.player.ai.hand_builder.count_tiles(new_waiting, closed_hand_34) 76 | 77 | closed_hand_34 = TilesConverter.to_34_array(self.player.closed_hand) 78 | if not open_kan and not has_shouminkan_candidate and closed_hand_34[tile_34] != 4: 79 | return None 80 | 81 | if open_kan and closed_hand_34[tile_34] != 3: 82 | return None 83 | 84 | closed_hand_34 = TilesConverter.to_34_array(self.player.closed_hand) 85 | tiles_34 = TilesConverter.to_34_array(self.player.tiles) 86 | 87 | if not has_shouminkan_candidate: 88 | if open_kan: 89 | # this 4 tiles can only be used in kan, no other options 90 | previous_waiting, previous_shanten = self.player.ai.hand_builder.calculate_waits( 91 | closed_hand_34, tiles_34, use_chiitoitsu=False 92 | ) 93 | previous_waits_count = self.player.ai.hand_builder.count_tiles(previous_waiting, closed_hand_34) 94 | elif from_riichi: 95 | # hand did not change since we last recalculated it, and the only thing we can do is to call kan 96 | previous_waits_count = self.player.ai.ukeire 97 | else: 98 | previous_shanten, previous_waits_count = self._calculate_shanten_for_kan() 99 | self.player.tiles = original_tiles[:] 100 | 101 | closed_hand_34[tile_34] = 0 102 | new_waiting, new_shanten = self.player.ai.hand_builder.calculate_waits( 103 | closed_hand_34, tiles_34, use_chiitoitsu=False 104 | ) 105 | 106 | closed_hand_34[tile_34] = 4 107 | new_waits_count = self.player.ai.hand_builder.count_tiles(new_waiting, closed_hand_34) 108 | 109 | # it is possible that we don't have results here 110 | # when we are in agari state (but without yaku) 111 | if previous_shanten is None: 112 | return None 113 | 114 | # it is not possible to reduce number of shanten by calling a kan 115 | assert new_shanten >= previous_shanten 116 | 117 | # if shanten number is the same, we should only call kan if ukeire didn't become worse 118 | if new_shanten == previous_shanten: 119 | # we cannot improve ukeire by calling kan (not considering the tile we drew from the dead wall) 120 | assert new_waits_count <= previous_waits_count 121 | 122 | if new_waits_count == previous_waits_count: 123 | kan_type = has_shouminkan_candidate and MeldPrint.SHOUMINKAN or MeldPrint.KAN 124 | if kan_type == MeldPrint.SHOUMINKAN: 125 | if threats: 126 | # there are threats and we are not even in tempai - let's not do shouminkan 127 | if not self.player.in_tempai: 128 | return None 129 | 130 | # there are threats and our tempai is weak, let's not do shouminkan 131 | if len(self.player.ai.waiting) < 2 or self.player.ai.ukeire < 3: 132 | return None 133 | else: 134 | # no threats, but too many shanten, let's not do shouminkan 135 | if new_shanten > 2: 136 | return None 137 | 138 | # no threats, and ryanshanten, but ukeire is meh, let's not do shouminkan 139 | if new_shanten == 2: 140 | if self.player.ai.ukeire < 16: 141 | return None 142 | 143 | self.player.logger.debug(log.KAN_DEBUG, f"Open kan type='{kan_type}'") 144 | return kan_type 145 | 146 | return None 147 | 148 | def _calculate_shanten_for_kan(self): 149 | previous_results, previous_shanten = self.player.ai.hand_builder.find_discard_options() 150 | 151 | previous_results = [x for x in previous_results if x.shanten == previous_shanten] 152 | 153 | # it is possible that we don't have results here 154 | # when we are in agari state (but without yaku) 155 | if not previous_results: 156 | return None, None 157 | 158 | previous_waits_cnt = sorted(previous_results, key=lambda x: -x.ukeire)[0].ukeire 159 | 160 | return previous_shanten, previous_waits_cnt 161 | -------------------------------------------------------------------------------- /project/game/ai/open_hand.py: -------------------------------------------------------------------------------- 1 | import utils.decisions_constants as log 2 | from game.ai.strategies.chinitsu import ChinitsuStrategy 3 | from game.ai.strategies.common_open_tempai import CommonOpenTempaiStrategy 4 | from game.ai.strategies.formal_tempai import FormalTempaiStrategy 5 | from game.ai.strategies.honitsu import HonitsuStrategy 6 | from game.ai.strategies.main import BaseStrategy 7 | from game.ai.strategies.tanyao import TanyaoStrategy 8 | from game.ai.strategies.yakuhai import YakuhaiStrategy 9 | from mahjong.shanten import Shanten 10 | from mahjong.tile import TilesConverter 11 | 12 | 13 | class OpenHandHandler: 14 | player = None 15 | current_strategy = None 16 | last_discard_option = None 17 | 18 | def __init__(self, player): 19 | self.player = player 20 | 21 | def determine_strategy(self, tiles_136, meld_tile=None): 22 | # for already opened hand we don't need to give up on selected strategy 23 | if self.player.is_open_hand and self.current_strategy: 24 | return False 25 | 26 | old_strategy = self.current_strategy 27 | self.current_strategy = None 28 | 29 | # order is important, we add strategies with the highest priority first 30 | strategies = [] 31 | 32 | if self.player.table.has_open_tanyao: 33 | strategies.append(TanyaoStrategy(BaseStrategy.TANYAO, self.player)) 34 | 35 | strategies.append(YakuhaiStrategy(BaseStrategy.YAKUHAI, self.player)) 36 | strategies.append(HonitsuStrategy(BaseStrategy.HONITSU, self.player)) 37 | strategies.append(ChinitsuStrategy(BaseStrategy.CHINITSU, self.player)) 38 | 39 | strategies.append(FormalTempaiStrategy(BaseStrategy.FORMAL_TEMPAI, self.player)) 40 | strategies.append(CommonOpenTempaiStrategy(BaseStrategy.COMMON_OPEN_TEMPAI, self.player)) 41 | 42 | for strategy in strategies: 43 | if strategy.should_activate_strategy(tiles_136, meld_tile=meld_tile): 44 | self.current_strategy = strategy 45 | break 46 | 47 | if self.current_strategy and (not old_strategy or self.current_strategy.type != old_strategy.type): 48 | self.player.logger.debug( 49 | log.STRATEGY_ACTIVATE, 50 | context=self.current_strategy, 51 | ) 52 | 53 | if not self.current_strategy and old_strategy: 54 | self.player.logger.debug(log.STRATEGY_DROP, context=old_strategy) 55 | 56 | return self.current_strategy and True or False 57 | 58 | def try_to_call_meld(self, tile_136, is_kamicha_discard): 59 | tiles_136_previous = self.player.tiles[:] 60 | closed_hand_136_previous = self.player.closed_hand[:] 61 | tiles_136 = tiles_136_previous + [tile_136] 62 | self.determine_strategy(tiles_136, meld_tile=tile_136) 63 | 64 | if not self.current_strategy: 65 | self.player.logger.debug(log.MELD_DEBUG, "We don't have active strategy. Abort melding.") 66 | return None, None 67 | 68 | closed_hand_34_previous = TilesConverter.to_34_array(closed_hand_136_previous) 69 | previous_shanten, _ = self.player.ai.hand_builder.calculate_shanten_and_decide_hand_structure( 70 | closed_hand_34_previous 71 | ) 72 | 73 | if previous_shanten == Shanten.AGARI_STATE and not self.current_strategy.can_meld_into_agari(): 74 | return None, None 75 | 76 | meld, discard_option = self.current_strategy.try_to_call_meld(tile_136, is_kamicha_discard, tiles_136) 77 | if discard_option: 78 | self.last_discard_option = discard_option 79 | 80 | self.player.logger.debug( 81 | log.MELD_CALL, 82 | "We decided to open hand", 83 | context=[ 84 | f"Hand: {self.player.format_hand_for_print(tile_136)}", 85 | f"Meld: {meld.serialize()}", 86 | f"Discard after meld: {discard_option.serialize()}", 87 | ], 88 | ) 89 | 90 | return meld, discard_option 91 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /project/game/ai/strategies/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahjongRepository/tenhou-python-bot/112b08faab08ee862813de06cb5acc5db1c4feb0/project/game/ai/strategies/__init__.py -------------------------------------------------------------------------------- /project/game/ai/strategies/chinitsu.py: -------------------------------------------------------------------------------- 1 | from game.ai.strategies.honitsu import HonitsuStrategy 2 | from game.ai.strategies.main import BaseStrategy 3 | from mahjong.tile import TilesConverter 4 | from mahjong.utils import count_tiles_by_suits, is_honor, is_man, is_pin, is_sou, is_tile_strictly_isolated, plus_dora 5 | 6 | 7 | class ChinitsuStrategy(BaseStrategy): 8 | min_shanten = 4 9 | 10 | chosen_suit = None 11 | 12 | dora_count_suitable = 0 13 | dora_count_not_suitable = 0 14 | 15 | def get_open_hand_han(self): 16 | return 5 17 | 18 | def should_activate_strategy(self, tiles_136, meld_tile=None): 19 | """ 20 | We can go for chinitsu strategy if we have prevalence of one suit 21 | """ 22 | 23 | result = super(ChinitsuStrategy, self).should_activate_strategy(tiles_136) 24 | if not result: 25 | return False 26 | 27 | # when making decisions about chinitsu, we should consider 28 | # the state of our own hand, 29 | tiles_34 = TilesConverter.to_34_array(self.player.tiles) 30 | suits = count_tiles_by_suits(tiles_34) 31 | 32 | suits = [x for x in suits if x["name"] != "honor"] 33 | suits = sorted(suits, key=lambda x: x["count"], reverse=True) 34 | suit = suits[0] 35 | 36 | count_of_shuntsu_other_suits = 0 37 | count_of_koutsu_other_suits = 0 38 | 39 | count_of_shuntsu_other_suits += HonitsuStrategy._count_of_shuntsu(tiles_34, suits[1]["function"]) 40 | count_of_shuntsu_other_suits += HonitsuStrategy._count_of_shuntsu(tiles_34, suits[2]["function"]) 41 | 42 | count_of_koutsu_other_suits += HonitsuStrategy._count_of_koutsu(tiles_34, suits[1]["function"]) 43 | count_of_koutsu_other_suits += HonitsuStrategy._count_of_koutsu(tiles_34, suits[2]["function"]) 44 | 45 | # we need to have at least 9 tiles of one suit to fo for chinitsu 46 | if suit["count"] < 9: 47 | return False 48 | 49 | # here we only check doras in different suits, we will deal 50 | # with honors later 51 | self._initialize_chinitsu_dora_count(tiles_136, suit) 52 | 53 | # 3 non-isolated doras in other suits is too much 54 | # to even try 55 | if self.dora_count_not_suitable >= 3: 56 | return False 57 | 58 | if self.dora_count_not_suitable == 2: 59 | # 2 doras in other suits, no doras in our suit 60 | # let's not consider chinitsu 61 | if self.dora_count_suitable == 0: 62 | return False 63 | 64 | # we have 2 doras in other suits and we 65 | # are 1 shanten, let's not rush chinitsu 66 | if self.player.ai.shanten == 1: 67 | return False 68 | 69 | # too late to get rid of doras in other suits 70 | if self.player.round_step > 8: 71 | return False 72 | 73 | # we are almost tempai, chinitsu is slower 74 | if suit["count"] == 9 and self.player.ai.shanten == 1: 75 | return False 76 | 77 | # only 10 tiles by 9th turn is too slow, considering alternative 78 | if suit["count"] == 10 and self.player.ai.shanten == 1 and self.player.round_step > 8: 79 | return False 80 | 81 | # only 11 tiles or less by 12th turn is too slow, considering alternative 82 | if suit["count"] <= 11 and self.player.round_step > 11: 83 | return False 84 | 85 | # if we have a pon of honors, let's not go for chinitsu 86 | honor_pons = len([x for x in range(0, 34) if is_honor(x) and tiles_34[x] >= 3]) 87 | if honor_pons >= 1: 88 | return False 89 | 90 | # if we have a valued pair, let's not go for chinitsu 91 | valued_pairs = len([x for x in self.player.valued_honors if tiles_34[x] == 2]) 92 | if valued_pairs >= 1: 93 | return False 94 | 95 | # if we have a pair of honor doras, let's not go for chinitsu 96 | honor_doras_pairs = len( 97 | [ 98 | x 99 | for x in range(0, 34) 100 | if is_honor(x) and tiles_34[x] == 2 and plus_dora(x * 4, self.player.table.dora_indicators) 101 | ] 102 | ) 103 | if honor_doras_pairs >= 1: 104 | return False 105 | 106 | # if we have a honor pair, we will only throw them away if it's early in the game 107 | # and if we have lots of tiles in our suit 108 | honor_pairs = len([x for x in range(0, 34) if is_honor(x) and tiles_34[x] == 2]) 109 | if honor_pairs >= 2: 110 | return False 111 | if honor_pairs == 1: 112 | if suit["count"] < 11: 113 | return False 114 | if self.player.round_step > 8: 115 | return False 116 | 117 | # if we have a complete set in other suits, we can only throw it away if it's early in the game 118 | if count_of_shuntsu_other_suits + count_of_koutsu_other_suits >= 1: 119 | # too late to throw away chi after 8 step 120 | if self.player.round_step > 8: 121 | return False 122 | 123 | # already 1 shanten, no need to throw away complete set 124 | if self.player.round_step > 5 and self.player.ai.shanten == 1: 125 | return False 126 | 127 | # dora is not isolated and we have a complete set, let's not go for chinitsu 128 | if self.dora_count_not_suitable >= 1: 129 | return False 130 | 131 | self.chosen_suit = suit["function"] 132 | 133 | return True 134 | 135 | def is_tile_suitable(self, tile): 136 | """ 137 | We can use only tiles of chosen suit and honor tiles 138 | :param tile: 136 tiles format 139 | :return: True 140 | """ 141 | tile //= 4 142 | return self.chosen_suit(tile) 143 | 144 | def _initialize_chinitsu_dora_count(self, tiles_136, suit): 145 | tiles_34 = TilesConverter.to_34_array(tiles_136) 146 | 147 | dora_count_man = 0 148 | dora_count_man_not_isolated = 0 149 | dora_count_pin = 0 150 | dora_count_pin_not_isolated = 0 151 | dora_count_sou = 0 152 | dora_count_sou_not_isolated = 0 153 | 154 | for tile_136 in tiles_136: 155 | tile_34 = tile_136 // 4 156 | 157 | dora_count = plus_dora( 158 | tile_136, self.player.table.dora_indicators, add_aka_dora=self.player.table.has_aka_dora 159 | ) 160 | 161 | if is_man(tile_34): 162 | dora_count_man += dora_count 163 | if not is_tile_strictly_isolated(tiles_34, tile_34): 164 | dora_count_man_not_isolated += dora_count 165 | 166 | if is_pin(tile_34): 167 | dora_count_pin += dora_count 168 | if not is_tile_strictly_isolated(tiles_34, tile_34): 169 | dora_count_pin_not_isolated += dora_count 170 | 171 | if is_sou(tile_34): 172 | dora_count_sou += dora_count 173 | if not is_tile_strictly_isolated(tiles_34, tile_34): 174 | dora_count_sou_not_isolated += dora_count 175 | 176 | if suit["name"] == "pin": 177 | self.dora_count_suitable = dora_count_pin 178 | self.dora_count_not_suitable = dora_count_man_not_isolated + dora_count_sou_not_isolated 179 | elif suit["name"] == "sou": 180 | self.dora_count_suitable = dora_count_sou 181 | self.dora_count_not_suitable = dora_count_man_not_isolated + dora_count_pin_not_isolated 182 | elif suit["name"] == "man": 183 | self.dora_count_suitable = dora_count_man 184 | self.dora_count_not_suitable = dora_count_sou_not_isolated + dora_count_pin_not_isolated 185 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /project/game/ai/strategies/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahjongRepository/tenhou-python-bot/112b08faab08ee862813de06cb5acc5db1c4feb0/project/game/ai/strategies/tests/__init__.py -------------------------------------------------------------------------------- /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.open_hand_handler.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 | -------------------------------------------------------------------------------- /project/game/ai/strategies/tests/test_chinitsu.py: -------------------------------------------------------------------------------- 1 | from game.ai.strategies.chinitsu import ChinitsuStrategy 2 | from game.ai.strategies.main import BaseStrategy 3 | from game.table import Table 4 | from utils.decisions_logger import MeldPrint 5 | from utils.test_helpers import make_meld, string_to_136_array, string_to_136_tile, tiles_to_string 6 | 7 | 8 | def test_should_activate_strategy(): 9 | table = Table() 10 | player = table.player 11 | strategy = ChinitsuStrategy(BaseStrategy.CHINITSU, player) 12 | 13 | table.add_dora_indicator(string_to_136_tile(pin="1")) 14 | table.add_dora_indicator(string_to_136_tile(man="1")) 15 | table.add_dora_indicator(string_to_136_tile(sou="8")) 16 | 17 | tiles = string_to_136_array(sou="12355", man="34589", honors="1234") 18 | player.init_hand(tiles) 19 | assert strategy.should_activate_strategy(player.tiles) is False 20 | 21 | tiles = string_to_136_array(sou="12355", man="458", honors="112345") 22 | player.init_hand(tiles) 23 | assert strategy.should_activate_strategy(player.tiles) is False 24 | 25 | # we shouldn't go for chinitsu if we have a valued pair or pon 26 | tiles = string_to_136_array(sou="111222578", man="8", honors="5556") 27 | player.init_hand(tiles) 28 | assert strategy.should_activate_strategy(player.tiles) is False 29 | 30 | tiles = string_to_136_array(sou="1112227788", man="7", honors="556") 31 | player.init_hand(tiles) 32 | assert strategy.should_activate_strategy(player.tiles) is False 33 | 34 | # if we have a pon of non-valued honors, this is not chinitsu 35 | tiles = string_to_136_array(sou="1112224688", honors="2224") 36 | player.init_hand(tiles) 37 | assert strategy.should_activate_strategy(player.tiles) is False 38 | 39 | # if we have just a pair of non-valued tiles, we can go for chinitsu 40 | # if we have 11 chinitsu tiles and it's early 41 | tiles = string_to_136_array(sou="11122234688", honors="224") 42 | player.init_hand(tiles) 43 | assert strategy.should_activate_strategy(player.tiles) is True 44 | 45 | # if we have a complete set with dora, we shouldn't go for chinitsu 46 | tiles = string_to_136_array(sou="1112223688", pin="1239") 47 | player.init_hand(tiles) 48 | assert strategy.should_activate_strategy(player.tiles) is False 49 | 50 | # even if the set may be interpreted as two forms 51 | tiles = string_to_136_array(sou="111223688", pin="23349") 52 | player.init_hand(tiles) 53 | assert strategy.should_activate_strategy(player.tiles) is False 54 | 55 | # even if the set may be interpreted as two forms v2 56 | tiles = string_to_136_array(sou="111223688", pin="23459") 57 | player.init_hand(tiles) 58 | assert strategy.should_activate_strategy(player.tiles) is False 59 | 60 | # if we have a long form with dora, we shouldn't go for chinitsu 61 | tiles = string_to_136_array(sou="111223688", pin="23339") 62 | player.init_hand(tiles) 63 | assert strategy.should_activate_strategy(player.tiles) is False 64 | 65 | # buf it it's just a ryanmen - no problem 66 | tiles = string_to_136_array(sou="1112223688", pin="238", man="9") 67 | player.init_hand(tiles) 68 | assert strategy.should_activate_strategy(player.tiles) is True 69 | 70 | # we have three non-isolated doras in other suits, this is not chinitsu 71 | tiles = string_to_136_array(sou="111223688", man="22", pin="239") 72 | player.init_hand(tiles) 73 | assert strategy.should_activate_strategy(player.tiles) is False 74 | 75 | # we have two non-isolated doras in other suits and no doras in our suit 76 | # this is not chinitsu 77 | tiles = string_to_136_array(sou="111223688", man="24", pin="249") 78 | player.init_hand(tiles) 79 | assert strategy.should_activate_strategy(player.tiles) is False 80 | 81 | # we have two non-isolated doras in other suits and 1 shanten, not chinitsu 82 | tiles = string_to_136_array(sou="111222789", man="23", pin="239") 83 | player.init_hand(tiles) 84 | assert strategy.should_activate_strategy(player.tiles) is False 85 | 86 | # we don't want to open on 9th tile into chinitsu, but it's ok to 87 | # switch to chinitsu if we get in from the wall 88 | tiles = string_to_136_array(sou="11223578", man="57", pin="4669") 89 | player.init_hand(tiles) 90 | # plus one tile to open hand 91 | tiles = string_to_136_array(sou="112223578", man="57", pin="466") 92 | assert strategy.should_activate_strategy(tiles) is False 93 | # but now let's init hand with these tiles, we can now slowly move to chinitsu 94 | tiles = string_to_136_array(sou="112223578", man="57", pin="466") 95 | player.init_hand(tiles) 96 | assert strategy.should_activate_strategy(tiles) is True 97 | 98 | 99 | def test_suitable_tiles(): 100 | table = Table() 101 | player = table.player 102 | strategy = ChinitsuStrategy(BaseStrategy.CHINITSU, player) 103 | 104 | tiles = string_to_136_array(sou="111222479", man="78", honors="12") 105 | player.init_hand(tiles) 106 | assert strategy.should_activate_strategy(player.tiles) is True 107 | 108 | tile = string_to_136_tile(man="1") 109 | assert strategy.is_tile_suitable(tile) is False 110 | 111 | tile = string_to_136_tile(pin="1") 112 | assert strategy.is_tile_suitable(tile) is False 113 | 114 | tile = string_to_136_tile(sou="1") 115 | assert strategy.is_tile_suitable(tile) is True 116 | 117 | tile = string_to_136_tile(honors="1") 118 | assert strategy.is_tile_suitable(tile) is False 119 | 120 | 121 | def test_open_suit_same_shanten(): 122 | table = Table() 123 | player = table.player 124 | player.scores = 25000 125 | table.count_of_remaining_tiles = 100 126 | 127 | tiles = string_to_136_array(man="1134556999", pin="3", sou="78") 128 | player.init_hand(tiles) 129 | 130 | meld = make_meld(MeldPrint.CHI, man="345") 131 | player.add_called_meld(meld) 132 | 133 | strategy = ChinitsuStrategy(BaseStrategy.CHINITSU, player) 134 | assert strategy.should_activate_strategy(player.tiles) is True 135 | 136 | tile = string_to_136_tile(man="1") 137 | meld, _ = player.try_to_call_meld(tile, True) 138 | assert meld is not None 139 | assert tiles_to_string(meld.tiles) == "111m" 140 | 141 | 142 | def test_correct_discard_agari_no_yaku(): 143 | table = Table() 144 | player = table.player 145 | 146 | tiles = string_to_136_array(man="111234677889", sou="1", pin="") 147 | player.init_hand(tiles) 148 | 149 | meld = make_meld(MeldPrint.CHI, man="789") 150 | player.add_called_meld(meld) 151 | 152 | tile = string_to_136_tile(sou="1") 153 | player.draw_tile(tile) 154 | discard, _ = player.discard_tile() 155 | assert tiles_to_string([discard]) == "1s" 156 | 157 | 158 | def test_open_suit_agari_no_yaku(): 159 | table = Table() 160 | player = table.player 161 | player.scores = 25000 162 | table.count_of_remaining_tiles = 100 163 | 164 | tiles = string_to_136_array(man="11123455589", pin="22") 165 | player.init_hand(tiles) 166 | 167 | meld = make_meld(MeldPrint.CHI, man="234") 168 | player.add_called_meld(meld) 169 | 170 | strategy = ChinitsuStrategy(BaseStrategy.CHINITSU, player) 171 | assert strategy.should_activate_strategy(player.tiles) is True 172 | 173 | tile = string_to_136_tile(man="7") 174 | meld, _ = player.try_to_call_meld(tile, True) 175 | assert meld is not None 176 | assert tiles_to_string(meld.tiles) == "789m" 177 | -------------------------------------------------------------------------------- /project/game/ai/strategies/tests/test_common_open_tempai.py: -------------------------------------------------------------------------------- 1 | from game.table import Table 2 | from utils.test_helpers import string_to_136_array, string_to_136_tile, tiles_to_string 3 | 4 | 5 | def test_get_common_tempai_sanshoku(): 6 | table = Table() 7 | 8 | table.add_dora_indicator(string_to_136_tile(man="8")) 9 | 10 | tiles = string_to_136_array(man="13999", sou="123", pin="12899") 11 | table.player.init_hand(tiles) 12 | 13 | tile = string_to_136_tile(pin="3") 14 | meld, _ = table.player.try_to_call_meld(tile, True) 15 | assert meld is not None 16 | assert tiles_to_string(meld.tiles) == "123p" 17 | 18 | 19 | def test_get_common_tempai_honro(): 20 | table = Table() 21 | 22 | tiles = string_to_136_array(man="11999", sou="112", pin="99", honors="333") 23 | table.player.init_hand(tiles) 24 | 25 | tile = string_to_136_tile(pin="9") 26 | meld, _ = table.player.try_to_call_meld(tile, False) 27 | assert meld is not None 28 | assert tiles_to_string(meld.tiles) == "999p" 29 | 30 | 31 | def test_get_common_tempai_and_0_ukeire_crash(): 32 | """ 33 | Checks that we don't have crash anymore when bot tried to open hand with 0 ukeire 34 | :return: 35 | """ 36 | table = Table() 37 | table.add_discarded_tile(1, string_to_136_tile(sou="1"), True) 38 | table.add_discarded_tile(1, string_to_136_tile(sou="1"), True) 39 | table.add_discarded_tile(1, string_to_136_tile(man="1"), True) 40 | table.add_discarded_tile(1, string_to_136_tile(man="1"), True) 41 | 42 | tiles = string_to_136_array(man="11999", sou="116", pin="99", honors="333") 43 | table.player.init_hand(tiles) 44 | 45 | tile = string_to_136_tile(pin="9") 46 | meld, _ = table.player.try_to_call_meld(tile, False) 47 | # no ukeire, no reason to open hand 48 | assert meld is None 49 | 50 | 51 | def test_get_common_tempai_sandoko(): 52 | table = Table() 53 | 54 | table.add_dora_indicator(string_to_136_tile(man="1")) 55 | 56 | tiles = string_to_136_array(man="222", sou="2278", pin="222899") 57 | table.player.init_hand(tiles) 58 | 59 | tile = string_to_136_tile(sou="2") 60 | meld, _ = table.player.try_to_call_meld(tile, False) 61 | assert meld is not None 62 | assert tiles_to_string(meld.tiles) == "222s" 63 | 64 | 65 | def test_get_common_tempai_bad_atodzuke(): 66 | table = Table() 67 | 68 | tiles = string_to_136_array(man="23789", sou="3", pin="99", honors="33444") 69 | table.player.init_hand(tiles) 70 | 71 | tile = string_to_136_tile(pin="9") 72 | meld, _ = table.player.try_to_call_meld(tile, False) 73 | assert meld is None 74 | 75 | 76 | def test_get_common_tempai_no_yaku(): 77 | table = Table() 78 | 79 | tiles = string_to_136_array(man="234999", sou="112", pin="55", honors="333") 80 | table.player.init_hand(tiles) 81 | 82 | tile = string_to_136_tile(pin="9") 83 | meld, _ = table.player.try_to_call_meld(tile, False) 84 | assert meld is None 85 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /project/game/ai/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahjongRepository/tenhou-python-bot/112b08faab08ee862813de06cb5acc5db1c4feb0/project/game/ai/tests/__init__.py -------------------------------------------------------------------------------- /project/game/ai/tests/test_riichi.py: -------------------------------------------------------------------------------- 1 | from game.table import Table 2 | from mahjong.tile import Tile 3 | from utils.test_helpers import enemy_called_riichi_helper, string_to_136_array, string_to_136_tile 4 | 5 | 6 | def test_dont_call_riichi_with_yaku_and_central_tanki_wait(): 7 | table = _make_table() 8 | 9 | tiles = string_to_136_array(sou="234567", pin="234567", man="4") 10 | table.player.init_hand(tiles) 11 | table.player.draw_tile(string_to_136_tile(man="5")) 12 | _, with_riichi = table.player.discard_tile() 13 | 14 | assert with_riichi is False 15 | 16 | 17 | def test_dont_call_riichi_expensive_damaten_with_yaku(): 18 | table = _make_table( 19 | dora_indicators=[ 20 | string_to_136_tile(man="7"), 21 | string_to_136_tile(man="5"), 22 | string_to_136_tile(sou="1"), 23 | ] 24 | ) 25 | 26 | # tanyao pinfu sanshoku dora 4 - this is damaten baiman, let's not riichi it 27 | tiles = string_to_136_array(man="67888", sou="678", pin="34678") 28 | table.player.init_hand(tiles) 29 | table.player.draw_tile(string_to_136_tile(honors="3")) 30 | _, with_riichi = table.player.discard_tile() 31 | assert with_riichi is False 32 | 33 | # let's test lots of doras hand, tanyao dora 8, also damaten baiman 34 | tiles = string_to_136_array(man="666888", sou="22", pin="34678") 35 | table.player.init_hand(tiles) 36 | table.player.draw_tile(string_to_136_tile(honors="3")) 37 | _, with_riichi = table.player.discard_tile() 38 | assert with_riichi is False 39 | 40 | # chuuren 41 | tiles = string_to_136_array(man="1112345678999") 42 | table.player.init_hand(tiles) 43 | table.player.draw_tile(string_to_136_tile(honors="3")) 44 | _, with_riichi = table.player.discard_tile() 45 | assert with_riichi is False 46 | 47 | 48 | def test_riichi_expensive_hand_without_yaku_2(): 49 | table = _make_table( 50 | dora_indicators=[ 51 | string_to_136_tile(man="1"), 52 | string_to_136_tile(sou="1"), 53 | string_to_136_tile(pin="1"), 54 | ] 55 | ) 56 | 57 | tiles = string_to_136_array(man="222", sou="22278", pin="22789") 58 | table.player.init_hand(tiles) 59 | table.player.draw_tile(string_to_136_tile(honors="3")) 60 | _, with_riichi = table.player.discard_tile() 61 | assert with_riichi is True 62 | 63 | 64 | def test_riichi_tanki_honor_without_yaku(): 65 | table = _make_table(dora_indicators=[string_to_136_tile(man="2"), string_to_136_tile(sou="6")]) 66 | 67 | tiles = string_to_136_array(man="345678", sou="789", pin="123", honors="2") 68 | table.player.init_hand(tiles) 69 | table.player.draw_tile(string_to_136_tile(honors="3")) 70 | _, with_riichi = table.player.discard_tile() 71 | assert with_riichi is True 72 | 73 | 74 | def test_riichi_tanki_honor_chiitoitsu(): 75 | table = _make_table() 76 | 77 | tiles = string_to_136_array(man="22336688", sou="99", pin="99", honors="2") 78 | table.player.init_hand(tiles) 79 | table.player.draw_tile(string_to_136_tile(honors="3")) 80 | _, with_riichi = table.player.discard_tile() 81 | assert with_riichi is True 82 | 83 | 84 | def test_always_call_daburi(): 85 | table = _make_table() 86 | table.player.round_step = 0 87 | 88 | tiles = string_to_136_array(sou="234567", pin="234567", man="4") 89 | table.player.init_hand(tiles) 90 | table.player.draw_tile(string_to_136_tile(man="5")) 91 | _, with_riichi = table.player.discard_tile() 92 | 93 | assert with_riichi is True 94 | 95 | 96 | def test_dont_call_karaten_tanki_riichi(): 97 | table = _make_table() 98 | 99 | tiles = string_to_136_array(man="22336688", sou="99", pin="99", honors="2") 100 | table.player.init_hand(tiles) 101 | 102 | for _ in range(0, 3): 103 | table.add_discarded_tile(1, string_to_136_tile(honors="2"), False) 104 | table.add_discarded_tile(1, string_to_136_tile(honors="3"), False) 105 | 106 | table.player.draw_tile(string_to_136_tile(honors="3")) 107 | _, with_riichi = table.player.discard_tile() 108 | assert with_riichi is False 109 | 110 | 111 | def test_dont_call_karaten_ryanmen_riichi(): 112 | table = _make_table( 113 | dora_indicators=[ 114 | string_to_136_tile(man="1"), 115 | string_to_136_tile(sou="1"), 116 | string_to_136_tile(pin="1"), 117 | ] 118 | ) 119 | 120 | tiles = string_to_136_array(man="222", sou="22278", pin="22789") 121 | table.player.init_hand(tiles) 122 | 123 | for _ in range(0, 4): 124 | table.add_discarded_tile(1, string_to_136_tile(sou="6"), False) 125 | table.add_discarded_tile(1, string_to_136_tile(sou="9"), False) 126 | 127 | table.player.draw_tile(string_to_136_tile(honors="3")) 128 | _, with_riichi = table.player.discard_tile() 129 | assert with_riichi is False 130 | 131 | 132 | def test_call_riichi_penchan_with_suji(): 133 | table = _make_table( 134 | dora_indicators=[ 135 | string_to_136_tile(pin="1"), 136 | ] 137 | ) 138 | 139 | tiles = string_to_136_array(sou="11223", pin="234567", man="66") 140 | table.player.init_hand(tiles) 141 | table.player.draw_tile(string_to_136_tile(sou="6")) 142 | _, with_riichi = table.player.discard_tile() 143 | 144 | assert with_riichi is True 145 | 146 | 147 | def test_call_riichi_tanki_with_kabe(): 148 | table = _make_table( 149 | dora_indicators=[ 150 | string_to_136_tile(pin="1"), 151 | ] 152 | ) 153 | 154 | for _ in range(0, 3): 155 | table.add_discarded_tile(1, string_to_136_tile(honors="1"), False) 156 | 157 | for _ in range(0, 4): 158 | table.add_discarded_tile(1, string_to_136_tile(sou="8"), False) 159 | 160 | tiles = string_to_136_array(sou="1119", pin="234567", man="666") 161 | table.player.init_hand(tiles) 162 | table.player.draw_tile(string_to_136_tile(honors="1")) 163 | _, with_riichi = table.player.discard_tile() 164 | 165 | assert with_riichi is True 166 | 167 | 168 | def test_call_riichi_chiitoitsu_with_suji(): 169 | table = _make_table( 170 | dora_indicators=[ 171 | string_to_136_tile(man="1"), 172 | ] 173 | ) 174 | 175 | for _ in range(0, 3): 176 | table.add_discarded_tile(1, string_to_136_tile(honors="3"), False) 177 | 178 | tiles = string_to_136_array(man="22336688", sou="9", pin="99", honors="22") 179 | table.player.init_hand(tiles) 180 | table.player.add_discarded_tile(Tile(string_to_136_tile(sou="6"), True)) 181 | 182 | table.player.draw_tile(string_to_136_tile(honors="3")) 183 | _, with_riichi = table.player.discard_tile() 184 | assert with_riichi is True 185 | 186 | 187 | def test_dont_call_riichi_chiitoitsu_bad_wait(): 188 | table = _make_table( 189 | dora_indicators=[ 190 | string_to_136_tile(man="1"), 191 | ] 192 | ) 193 | 194 | for _ in range(0, 3): 195 | table.add_discarded_tile(1, string_to_136_tile(honors="3"), False) 196 | 197 | tiles = string_to_136_array(man="22336688", sou="4", pin="99", honors="22") 198 | table.player.init_hand(tiles) 199 | 200 | table.player.draw_tile(string_to_136_tile(honors="3")) 201 | _, with_riichi = table.player.discard_tile() 202 | assert with_riichi is False 203 | 204 | 205 | def test_dont_call_pinfu_nomi_chasing_riichi(): 206 | table = _make_table() 207 | enemy_called_riichi_helper(table, 3) 208 | 209 | tiles = string_to_136_array(man="123", sou="234567", pin="2278") 210 | table.player.init_hand(tiles) 211 | table.player.draw_tile(string_to_136_tile(honors="3")) 212 | 213 | # on early stages it is fine to call chasing riichi here 214 | _, with_riichi = table.player.discard_tile() 215 | assert with_riichi is True 216 | 217 | table.player.round_step = 9 218 | table.player.draw_tile(string_to_136_tile(honors="3")) 219 | 220 | # on late stage let's save riichi stick 221 | _, with_riichi = table.player.discard_tile() 222 | assert with_riichi is False 223 | 224 | 225 | def _make_table(dora_indicators=None) -> Table: 226 | table = Table() 227 | 228 | table.count_of_remaining_tiles = 60 229 | table.player.scores = 25000 230 | 231 | # with that we don't have daburi anymore 232 | table.player.round_step = 1 233 | 234 | # with that we are not dealer anymore 235 | table.player.seat = 1 236 | 237 | if dora_indicators: 238 | for x in dora_indicators: 239 | table.add_dora_indicator(x) 240 | 241 | return table 242 | -------------------------------------------------------------------------------- /project/game/bots_battle/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahjongRepository/tenhou-python-bot/112b08faab08ee862813de06cb5acc5db1c4feb0/project/game/bots_battle/__init__.py -------------------------------------------------------------------------------- /project/game/bots_battle/battle_config.py: -------------------------------------------------------------------------------- 1 | from game.ai.configs.bot_ichihime import IchihimeConfig 2 | from game.ai.configs.bot_kaavi import KaaviConfig 3 | from game.ai.configs.bot_wanjirou import WanjirouConfig 4 | from game.ai.configs.bot_xenia import XeniaConfig 5 | 6 | 7 | class BattleConfig: 8 | CLIENTS_CONFIGS = [ 9 | IchihimeConfig, 10 | KaaviConfig, 11 | WanjirouConfig, 12 | XeniaConfig, 13 | ] 14 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /project/game/bots_battle/replays/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /project/game/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahjongRepository/tenhou-python-bot/112b08faab08ee862813de06cb5acc5db1c4feb0/project/game/tests/__init__.py -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /project/game/tests/test_table.py: -------------------------------------------------------------------------------- 1 | from game.table import Table 2 | from mahjong.constants import EAST, FIVE_RED_MAN, FIVE_RED_PIN, FIVE_RED_SOU, NORTH, SOUTH, WEST 3 | from utils.test_helpers import string_to_136_tile 4 | 5 | 6 | def test_init_hand(): 7 | table = Table() 8 | tiles = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13] 9 | table.player.init_hand(tiles) 10 | 11 | assert len(table.player.tiles) == 13 12 | 13 | 14 | def test_init_round(): 15 | table = Table() 16 | 17 | round_wind_number = 4 18 | count_of_honba_sticks = 2 19 | count_of_riichi_sticks = 3 20 | dora_indicator = 126 21 | dealer = 3 22 | scores = [250, 250, 250, 250] 23 | 24 | table.init_round(round_wind_number, count_of_honba_sticks, count_of_riichi_sticks, dora_indicator, dealer, scores) 25 | 26 | assert table.round_wind_number == round_wind_number 27 | assert table.count_of_honba_sticks == count_of_honba_sticks 28 | assert table.count_of_riichi_sticks == count_of_riichi_sticks 29 | assert table.dora_indicators[0] == dora_indicator 30 | assert table.get_player(dealer).is_dealer is True 31 | assert table.get_player(dealer).scores == 25000 32 | 33 | dealer = 2 34 | table.player.in_tempai = True 35 | table.player.in_riichi = True 36 | table.init_round(round_wind_number, count_of_honba_sticks, count_of_riichi_sticks, dora_indicator, dealer, scores) 37 | 38 | # test that we reinit round properly 39 | assert table.get_player(3).is_dealer is False 40 | assert table.player.in_tempai is False 41 | assert table.player.in_riichi is False 42 | assert table.get_player(dealer).is_dealer is True 43 | 44 | 45 | def test_set_scores(): 46 | table = Table() 47 | table.init_round(0, 0, 0, 0, 0, []) 48 | scores = [230, 110, 55, 405] 49 | 50 | table.set_players_scores(scores) 51 | 52 | assert table.get_player(0).scores == 23000 53 | assert table.get_player(1).scores == 11000 54 | assert table.get_player(2).scores == 5500 55 | assert table.get_player(3).scores == 40500 56 | 57 | 58 | def test_set_scores_and_uma(): 59 | table = Table() 60 | table.init_round(0, 0, 0, 0, 0, []) 61 | scores = [230, 110, 55, 405] 62 | uma = [-17, 3, 48, -34] 63 | 64 | table.set_players_scores(scores, uma) 65 | 66 | assert table.get_player(0).scores == 23000 67 | assert table.get_player(0).uma == (-17) 68 | assert table.get_player(1).scores == 11000 69 | assert table.get_player(1).uma == 3 70 | assert table.get_player(2).scores == 5500 71 | assert table.get_player(2).uma == 48 72 | assert table.get_player(3).scores == 40500 73 | assert table.get_player(3).uma == (-34) 74 | 75 | 76 | def test_set_scores_and_recalculate_player_position(): 77 | table = Table() 78 | table.init_round(0, 0, 0, 0, 0, []) 79 | 80 | assert table.get_player(0).first_seat == 0 81 | assert table.get_player(1).first_seat == 1 82 | assert table.get_player(2).first_seat == 2 83 | assert table.get_player(3).first_seat == 3 84 | 85 | scores = [230, 110, 55, 405] 86 | table.set_players_scores(scores) 87 | 88 | assert table.get_player(0).position == 2 89 | assert table.get_player(1).position == 3 90 | assert table.get_player(2).position == 4 91 | assert table.get_player(3).position == 1 92 | 93 | scores = [110, 110, 405, 405] 94 | table.set_players_scores(scores) 95 | 96 | assert table.get_player(0).position == 3 97 | assert table.get_player(1).position == 4 98 | assert table.get_player(2).position == 1 99 | assert table.get_player(3).position == 2 100 | 101 | 102 | def test_set_names_and_ranks(): 103 | table = Table() 104 | table.init_round(0, 0, 0, 0, 0, []) 105 | 106 | values = [ 107 | {"name": "NoName", "rank": "新人"}, 108 | {"name": "o2o2", "rank": "3級"}, 109 | {"name": "shimmmmm", "rank": "三段"}, 110 | {"name": "川海老", "rank": "9級"}, 111 | ] 112 | 113 | table.set_players_names_and_ranks(values) 114 | 115 | assert table.get_player(0).name == "NoName" 116 | assert table.get_player(0).rank == "新人" 117 | assert table.get_player(3).name == "川海老" 118 | assert table.get_player(3).rank == "9級" 119 | 120 | 121 | def test_is_dora(): 122 | table = Table() 123 | table.init_round(0, 0, 0, 0, 0, []) 124 | 125 | table.dora_indicators = [string_to_136_tile(sou="1")] 126 | assert table.is_dora(string_to_136_tile(sou="2")) 127 | 128 | table.dora_indicators = [string_to_136_tile(sou="9")] 129 | assert table.is_dora(string_to_136_tile(sou="1")) 130 | 131 | table.dora_indicators = [string_to_136_tile(pin="9")] 132 | assert table.is_dora(string_to_136_tile(pin="1")) 133 | 134 | table.dora_indicators = [string_to_136_tile(man="9")] 135 | assert table.is_dora(string_to_136_tile(man="1")) 136 | 137 | table.dora_indicators = [string_to_136_tile(man="5")] 138 | assert table.is_dora(string_to_136_tile(man="6")) 139 | 140 | table.dora_indicators = [string_to_136_tile(honors="1")] 141 | assert table.is_dora(string_to_136_tile(honors="2")) 142 | 143 | table.dora_indicators = [string_to_136_tile(honors="2")] 144 | assert table.is_dora(string_to_136_tile(honors="3")) 145 | 146 | table.dora_indicators = [string_to_136_tile(honors="3")] 147 | assert table.is_dora(string_to_136_tile(honors="4")) 148 | 149 | table.dora_indicators = [string_to_136_tile(honors="4")] 150 | assert table.is_dora(string_to_136_tile(honors="1")) 151 | 152 | table.dora_indicators = [string_to_136_tile(honors="5")] 153 | assert table.is_dora(string_to_136_tile(honors="6")) 154 | 155 | table.dora_indicators = [string_to_136_tile(honors="6")] 156 | assert table.is_dora(string_to_136_tile(honors="7")) 157 | 158 | table.dora_indicators = [string_to_136_tile(honors="7")] 159 | assert table.is_dora(string_to_136_tile(honors="5")) 160 | 161 | table.dora_indicators = [string_to_136_tile(pin="1")] 162 | assert not table.is_dora(string_to_136_tile(sou="2")) 163 | 164 | table.has_open_tanyao = True 165 | 166 | # red five man 167 | assert table.is_dora(FIVE_RED_MAN) 168 | 169 | # red five pin 170 | assert table.is_dora(FIVE_RED_PIN) 171 | 172 | # red five sou 173 | assert table.is_dora(FIVE_RED_SOU) 174 | 175 | 176 | def test_round_wind(): 177 | table = Table() 178 | 179 | table.init_round(0, 0, 0, 0, 0, []) 180 | assert table.round_wind_tile == EAST 181 | 182 | table.init_round(3, 0, 0, 0, 0, []) 183 | assert table.round_wind_tile == EAST 184 | 185 | table.init_round(7, 0, 0, 0, 0, []) 186 | assert table.round_wind_tile == SOUTH 187 | 188 | table.init_round(11, 0, 0, 0, 0, []) 189 | assert table.round_wind_tile == WEST 190 | 191 | table.init_round(12, 0, 0, 0, 0, []) 192 | assert table.round_wind_tile == NORTH 193 | -------------------------------------------------------------------------------- /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 utils.settings_handler import settings 8 | 9 | 10 | def parse_args_and_set_up_settings(): 11 | parser = OptionParser() 12 | 13 | parser.add_option( 14 | "-u", 15 | "--user_id", 16 | type="string", 17 | default=settings.USER_ID, 18 | help="Tenhou's user id. Example: IDXXXXXXXX-XXXXXXXX. Default is {0}".format(settings.USER_ID), 19 | ) 20 | parser.add_option( 21 | "-g", 22 | "--game_type", 23 | type="string", 24 | default=settings.GAME_TYPE, 25 | help="The game type in Tenhou.net. Examples: 1 or 9. Default is {0}".format(settings.GAME_TYPE), 26 | ) 27 | parser.add_option( 28 | "-l", 29 | "--lobby", 30 | type="string", 31 | default=settings.LOBBY, 32 | help="Lobby to play. Default is {0}".format(settings.LOBBY), 33 | ) 34 | parser.add_option( 35 | "-t", 36 | "--timeout", 37 | type="int", 38 | default=settings.WAITING_GAME_TIMEOUT_MINUTES, 39 | help="How much minutes bot will looking for a game. " 40 | "If game is not started in timeout, script will be ended. " 41 | "Default is {0}".format(settings.WAITING_GAME_TIMEOUT_MINUTES), 42 | ) 43 | parser.add_option( 44 | "-c", 45 | "--championship", 46 | type="string", 47 | help="Tournament lobby to play.", 48 | ) 49 | parser.add_option( 50 | "-s", 51 | "--settings", 52 | type="string", 53 | default=None, 54 | help="Settings file name (without path, just file name without extension)", 55 | ) 56 | 57 | opts, _ = parser.parse_args() 58 | 59 | settings.USER_ID = opts.user_id 60 | settings.GAME_TYPE = opts.game_type 61 | settings.LOBBY = opts.lobby 62 | settings.WAITING_GAME_TIMEOUT_MINUTES = opts.timeout 63 | 64 | if opts.settings: 65 | module = importlib.import_module(f"settings.{opts.settings}") 66 | for key, value in vars(module).items(): 67 | # let's use only upper case settings 68 | if key.isupper(): 69 | settings.__setattr__(key, value) 70 | 71 | if opts.championship: 72 | settings.IS_TOURNAMENT = True 73 | settings.LOBBY = opts.championship 74 | 75 | 76 | def main(): 77 | parse_args_and_set_up_settings() 78 | 79 | if settings.SENTRY_URL: 80 | import sentry_sdk 81 | 82 | sentry_sdk.init( 83 | settings.SENTRY_URL, 84 | traces_sample_rate=1.0, 85 | ) 86 | 87 | 88 | if __name__ == "__main__": 89 | main() 90 | -------------------------------------------------------------------------------- /project/run_stat.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from optparse import OptionParser 4 | from statistics.cases.agari_riichi_cost import AgariRiichiCostCase 5 | 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 | -------------------------------------------------------------------------------- /project/settings/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahjongRepository/tenhou-python-bot/112b08faab08ee862813de06cb5acc5db1c4feb0/project/settings/__init__.py -------------------------------------------------------------------------------- /project/settings/base.py: -------------------------------------------------------------------------------- 1 | TENHOU_HOST = "133.242.10.78" 2 | TENHOU_PORT = 10080 3 | 4 | USER_ID = "NoName" 5 | 6 | LOBBY = "0" 7 | WAITING_GAME_TIMEOUT_MINUTES = 10 8 | 9 | # in tournament mode bot is not trying to search the game 10 | # it just sitting in the lobby and waiting for the game start 11 | IS_TOURNAMENT = False 12 | 13 | STAT_SERVER_URL = "" 14 | STAT_TOKEN = "" 15 | PAPERTRAIL_HOST_AND_PORT = "" 16 | SENTRY_URL = "" 17 | 18 | LOG_PREFIX = "" 19 | 20 | PRINT_LOGS = True 21 | 22 | TOURNAMENT_API_TOKEN = None 23 | TOURNAMENT_API_URL = None 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 | -------------------------------------------------------------------------------- /project/statistics/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahjongRepository/tenhou-python-bot/112b08faab08ee862813de06cb5acc5db1c4feb0/project/statistics/__init__.py -------------------------------------------------------------------------------- /project/statistics/calculate_error_rate.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import json 3 | import os 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 | "han", 30 | "fu", 31 | "original_cost", 32 | ] 33 | 34 | 35 | def main(): 36 | data_csv = os.path.join(stats_output_folder, "combined_test.csv") 37 | calculate_errors(data_csv) 38 | 39 | 40 | def calculate_errors(csv_file): 41 | data = {} 42 | 43 | with open(csv_file, mode="r") as f: 44 | reader = csv.DictReader(f, fieldnames=CSV_HEADER) 45 | 46 | results = list(reader) 47 | total_predictions = len(results) 48 | 49 | error_borders = [30, 20, 10] 50 | for error_border in error_borders: 51 | correct_predictions = 0 52 | for row in results: 53 | original_cost = int(row["original_cost"]) 54 | predicted_cost = int(row["predicted_cost"]) 55 | 56 | if error_border_predicted(original_cost, predicted_cost, error_border): 57 | correct_predictions += 1 58 | 59 | data[f"{error_border}% border"] = (correct_predictions / total_predictions) * 100 60 | 61 | correct_predictions = 0 62 | for row in results: 63 | original_cost = int(row["original_cost"]) 64 | predicted_cost = int(row["predicted_cost"]) 65 | is_dealer = bool(int(row["is_dealer"])) 66 | 67 | if is_dealer and is_dealer_hand_correctly_predicted(original_cost, predicted_cost): 68 | correct_predictions += 1 69 | 70 | if not is_dealer and is_regular_hand_correctly_predicted(original_cost, predicted_cost): 71 | correct_predictions += 1 72 | 73 | data["empirical"] = (correct_predictions / total_predictions) * 100 74 | 75 | print("Correct % of predictions:") 76 | print(json.dumps(data, indent=2)) 77 | 78 | 79 | def error_border_predicted(original_cost, predicted_cost, border_percentage): 80 | first_border = predicted_cost - round((predicted_cost / 100) * border_percentage) 81 | second_border = predicted_cost + round((predicted_cost / 100) * border_percentage) 82 | 83 | if first_border < original_cost < second_border: 84 | return True 85 | 86 | return False 87 | 88 | 89 | def is_dealer_hand_correctly_predicted(original_cost, predicted_cost): 90 | assert original_cost >= 2000 91 | 92 | if original_cost <= 3900 and predicted_cost <= 5800: 93 | return True 94 | 95 | if 3900 < original_cost <= 5800 and predicted_cost <= 7700: 96 | return True 97 | 98 | if 5800 < original_cost <= 7700 and 3900 <= predicted_cost <= 12000: 99 | return True 100 | 101 | if 7700 < original_cost <= 12000 and 5800 <= predicted_cost <= 18000: 102 | return True 103 | 104 | if 12000 < original_cost <= 18000 and 7700 <= predicted_cost <= 24000: 105 | return True 106 | 107 | if original_cost > 18000 and predicted_cost > 12000: 108 | return True 109 | 110 | return False 111 | 112 | 113 | def is_regular_hand_correctly_predicted(original_cost, predicted_cost): 114 | assert original_cost >= 1300 115 | 116 | if original_cost <= 2600 and predicted_cost <= 3900: 117 | return True 118 | 119 | if 2600 < original_cost <= 3900 and predicted_cost <= 5200: 120 | return True 121 | 122 | if 3900 < original_cost <= 5200 and 2600 <= predicted_cost <= 8000: 123 | return True 124 | 125 | if 5200 < original_cost <= 8000 and 3900 <= predicted_cost <= 12000: 126 | return True 127 | 128 | if 8000 < original_cost <= 12000 and 5200 <= predicted_cost <= 16000: 129 | return True 130 | 131 | if original_cost > 12000 and predicted_cost > 8000: 132 | return True 133 | 134 | return False 135 | 136 | 137 | if __name__ == "__main__": 138 | main() 139 | -------------------------------------------------------------------------------- /project/statistics/cases/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahjongRepository/tenhou-python-bot/112b08faab08ee862813de06cb5acc5db1c4feb0/project/statistics/cases/__init__.py -------------------------------------------------------------------------------- /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_temp = [int(x) for x in self.parser.get_attribute_content(tag, "yaku").split(",")] 57 | yaku_list = yaku_temp[::2] 58 | han = sum(yaku_temp[1::2]) 59 | 60 | # we are looking for riichi hands only 61 | if 1 not in yaku_list: 62 | continue 63 | 64 | # we don't want to check hand cost for ippatsu or tsumo situations 65 | if 2 in yaku_list or 0 in yaku_list: 66 | continue 67 | 68 | fu = int(self.parser.get_attribute_content(tag, "ten").split(",")[0]) 69 | original_cost = int(self.parser.get_attribute_content(tag, "ten").split(",")[1]) 70 | results.append( 71 | { 72 | "lobby": lobby, 73 | "log_id": log_id, 74 | "agari_position": int(self.parser.get_attribute_content(tag, "who")), 75 | "player_position": int(self.parser.get_attribute_content(tag, "fromWho")), 76 | "win_tile_34": int(self.parser.get_attribute_content(tag, "machi")) // 4, 77 | "han": han, 78 | "fu": fu, 79 | "original_cost": original_cost, 80 | "round_data": round_data, 81 | } 82 | ) 83 | 84 | return results 85 | 86 | def _collect_statistics(self, filtered_result): 87 | """ 88 | Statistics that we want to collect: 89 | - On Riichi. Round step number when riichi was called 90 | - On Riichi. Wind number 91 | - On Riichi. Enemy scores 92 | - On Riichi. Was it tsumogiri riichi or not 93 | - On Riichi. Was it dealer riichi or not 94 | - On Riichi. Was it first riichi or not 95 | - On Riichi. Was it called against dealer riichi threat or not 96 | - On Riichi. Was it called against open hand threat or not (threat == someone opened dora pon) 97 | - On Riichi. Discards before the riichi 98 | - On Agari. Riichi hand cost 99 | - On Agari. Riichi hand han 100 | - On Agari. Riichi hand fu 101 | - On Agari. Round step number 102 | - On Agari. Number of kan sets in riichi hand 103 | - On Agari. Number of kan sets on the table 104 | - On Agari. Number of live dora 105 | - On Agari. Win tile (34 format) 106 | - On Agari. Win tile category (terminal, edge 2378, middle 456, honor, valuable honor) 107 | - On Agari. Is win tile dora or not 108 | """ 109 | agari_seat = self.reproducer._normalize_position( 110 | filtered_result["agari_position"], filtered_result["player_position"] 111 | ) 112 | 113 | stat = self.reproducer.play_round( 114 | filtered_result["round_data"], 115 | filtered_result["player_position"], 116 | context={ 117 | "action": "agari_riichi_cost", 118 | "agari_seat": agari_seat, 119 | }, 120 | ) 121 | 122 | if not stat: 123 | return None 124 | 125 | del filtered_result["round_data"] 126 | del filtered_result["player_position"] 127 | del filtered_result["agari_position"] 128 | 129 | stat.update(filtered_result) 130 | 131 | return stat 132 | -------------------------------------------------------------------------------- /project/statistics/cases/main.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import logging 3 | import os 4 | from pathlib import Path 5 | from statistics.db import load_logs_from_db 6 | from statistics.log_parser import LogParser 7 | 8 | from reproducer import TenhouLogReproducer 9 | from tqdm import tqdm 10 | 11 | logger = logging.getLogger("stat") 12 | 13 | 14 | class MainCase: 15 | def __init__(self, db_path: str, stats_output_folder: str): 16 | self.db_path = db_path 17 | self.stats_output_folder = stats_output_folder 18 | 19 | self.parser = LogParser() 20 | self.reproducer = TenhouLogReproducer(None, None, logging.getLogger()) 21 | 22 | def prepare_statistics(self): 23 | logger.info("Loading all logs from DB and unarchiving them...") 24 | 25 | limit = 10000000000000 26 | logs = load_logs_from_db(self.db_path, limit, offset=0) 27 | logger.info(f"Loaded {len(logs)} logs") 28 | 29 | results = [] 30 | for log in tqdm(logs, position=1, desc="Parsing xml to tag arrays..."): 31 | parsed_rounds = self.parser.split_log_to_game_rounds(log["log_content"]) 32 | results.extend(self._filter_rounds(log["log_id"], parsed_rounds)) 33 | 34 | collected_statistics = [] 35 | for filtered_result in tqdm(results, position=0, desc="Processing tag arrays..."): 36 | try: 37 | result = self._collect_statistics(filtered_result) 38 | if result: 39 | collected_statistics.append(result) 40 | except Exception: 41 | logger.error(f"Error in statistics calculation for {filtered_result['log_id']}") 42 | 43 | csv_file_name = f"{Path(self.db_path).name}.csv" 44 | data_csv = os.path.join(self.stats_output_folder, csv_file_name) 45 | 46 | if collected_statistics: 47 | with open(data_csv, "w") as csv_file: 48 | writer = csv.DictWriter(csv_file, fieldnames=collected_statistics[0].keys()) 49 | writer.writeheader() 50 | for data in collected_statistics: 51 | writer.writerow(data) 52 | 53 | def _filter_rounds(self, log_id, parsed_rounds): 54 | return [] 55 | 56 | def _collect_statistics(self, filtered_result): 57 | return {} 58 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 " -------------------------------------------------------------------------------- /project/system_testing/fixtures/14.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahjongRepository/tenhou-python-bot/112b08faab08ee862813de06cb5acc5db1c4feb0/project/system_testing/fixtures/14.jpg -------------------------------------------------------------------------------- /project/system_testing/fixtures/15.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahjongRepository/tenhou-python-bot/112b08faab08ee862813de06cb5acc5db1c4feb0/project/system_testing/fixtures/15.jpg -------------------------------------------------------------------------------- /project/system_testing/fixtures/16.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahjongRepository/tenhou-python-bot/112b08faab08ee862813de06cb5acc5db1c4feb0/project/system_testing/fixtures/16.jpg -------------------------------------------------------------------------------- /project/system_testing/fixtures/17.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahjongRepository/tenhou-python-bot/112b08faab08ee862813de06cb5acc5db1c4feb0/project/system_testing/fixtures/17.jpg -------------------------------------------------------------------------------- /project/system_testing/fixtures/18.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahjongRepository/tenhou-python-bot/112b08faab08ee862813de06cb5acc5db1c4feb0/project/system_testing/fixtures/18.jpg -------------------------------------------------------------------------------- /project/system_testing/fixtures/19.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahjongRepository/tenhou-python-bot/112b08faab08ee862813de06cb5acc5db1c4feb0/project/system_testing/fixtures/19.jpg -------------------------------------------------------------------------------- /project/system_testing/fixtures/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahjongRepository/tenhou-python-bot/112b08faab08ee862813de06cb5acc5db1c4feb0/project/system_testing/fixtures/2.jpg -------------------------------------------------------------------------------- /project/system_testing/fixtures/20.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahjongRepository/tenhou-python-bot/112b08faab08ee862813de06cb5acc5db1c4feb0/project/system_testing/fixtures/20.jpg -------------------------------------------------------------------------------- /project/system_testing/fixtures/23.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /project/system_testing/fixtures/25.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /project/system_testing/fixtures/26.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahjongRepository/tenhou-python-bot/112b08faab08ee862813de06cb5acc5db1c4feb0/project/system_testing/fixtures/26.jpg -------------------------------------------------------------------------------- /project/system_testing/fixtures/28.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahjongRepository/tenhou-python-bot/112b08faab08ee862813de06cb5acc5db1c4feb0/project/system_testing/fixtures/28.jpg -------------------------------------------------------------------------------- /project/system_testing/fixtures/3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahjongRepository/tenhou-python-bot/112b08faab08ee862813de06cb5acc5db1c4feb0/project/system_testing/fixtures/3.jpg -------------------------------------------------------------------------------- /project/system_testing/fixtures/30.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahjongRepository/tenhou-python-bot/112b08faab08ee862813de06cb5acc5db1c4feb0/project/system_testing/fixtures/30.jpg -------------------------------------------------------------------------------- /project/system_testing/fixtures/31.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahjongRepository/tenhou-python-bot/112b08faab08ee862813de06cb5acc5db1c4feb0/project/system_testing/fixtures/31.jpg -------------------------------------------------------------------------------- /project/system_testing/fixtures/32.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahjongRepository/tenhou-python-bot/112b08faab08ee862813de06cb5acc5db1c4feb0/project/system_testing/fixtures/32.jpg -------------------------------------------------------------------------------- /project/system_testing/fixtures/33.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahjongRepository/tenhou-python-bot/112b08faab08ee862813de06cb5acc5db1c4feb0/project/system_testing/fixtures/33.jpg -------------------------------------------------------------------------------- /project/system_testing/fixtures/34.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahjongRepository/tenhou-python-bot/112b08faab08ee862813de06cb5acc5db1c4feb0/project/system_testing/fixtures/34.jpg -------------------------------------------------------------------------------- /project/system_testing/fixtures/35.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahjongRepository/tenhou-python-bot/112b08faab08ee862813de06cb5acc5db1c4feb0/project/system_testing/fixtures/35.jpg -------------------------------------------------------------------------------- /project/system_testing/fixtures/36.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahjongRepository/tenhou-python-bot/112b08faab08ee862813de06cb5acc5db1c4feb0/project/system_testing/fixtures/36.jpg -------------------------------------------------------------------------------- /project/system_testing/fixtures/37.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahjongRepository/tenhou-python-bot/112b08faab08ee862813de06cb5acc5db1c4feb0/project/system_testing/fixtures/37.jpg -------------------------------------------------------------------------------- /project/system_testing/fixtures/38.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahjongRepository/tenhou-python-bot/112b08faab08ee862813de06cb5acc5db1c4feb0/project/system_testing/fixtures/38.jpg -------------------------------------------------------------------------------- /project/system_testing/fixtures/39.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahjongRepository/tenhou-python-bot/112b08faab08ee862813de06cb5acc5db1c4feb0/project/system_testing/fixtures/39.jpg -------------------------------------------------------------------------------- /project/system_testing/fixtures/4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahjongRepository/tenhou-python-bot/112b08faab08ee862813de06cb5acc5db1c4feb0/project/system_testing/fixtures/4.jpg -------------------------------------------------------------------------------- /project/system_testing/fixtures/40.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahjongRepository/tenhou-python-bot/112b08faab08ee862813de06cb5acc5db1c4feb0/project/system_testing/fixtures/40.jpg -------------------------------------------------------------------------------- /project/system_testing/fixtures/41.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahjongRepository/tenhou-python-bot/112b08faab08ee862813de06cb5acc5db1c4feb0/project/system_testing/fixtures/41.jpg -------------------------------------------------------------------------------- /project/system_testing/fixtures/5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahjongRepository/tenhou-python-bot/112b08faab08ee862813de06cb5acc5db1c4feb0/project/system_testing/fixtures/5.jpg -------------------------------------------------------------------------------- /project/system_testing/fixtures/6.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahjongRepository/tenhou-python-bot/112b08faab08ee862813de06cb5acc5db1c4feb0/project/system_testing/fixtures/6.jpg -------------------------------------------------------------------------------- /project/system_testing/fixtures/7.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahjongRepository/tenhou-python-bot/112b08faab08ee862813de06cb5acc5db1c4feb0/project/system_testing/fixtures/7.jpg -------------------------------------------------------------------------------- /project/system_testing/fixtures/8.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahjongRepository/tenhou-python-bot/112b08faab08ee862813de06cb5acc5db1c4feb0/project/system_testing/fixtures/8.jpg -------------------------------------------------------------------------------- /project/system_testing/fixtures/9.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahjongRepository/tenhou-python-bot/112b08faab08ee862813de06cb5acc5db1c4feb0/project/system_testing/fixtures/9.jpg -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /project/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahjongRepository/tenhou-python-bot/112b08faab08ee862813de06cb5acc5db1c4feb0/project/utils/__init__.py -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /project/utils/general.py: -------------------------------------------------------------------------------- 1 | import random 2 | import string 3 | from typing import List 4 | 5 | from mahjong.constants import EAST 6 | from mahjong.utils import is_honor, is_man, is_pin, is_sou, simplify 7 | 8 | 9 | # TODO move to mahjong lib 10 | def is_sangenpai(tile_34): 11 | return tile_34 >= 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | python_files = test_*.py 3 | log_format = %(asctime)s %(levelname)s %(message)s 4 | log_date_format = . 5 | -------------------------------------------------------------------------------- /requirements/base.txt: -------------------------------------------------------------------------------- 1 | # our core library 2 | mahjong==1.2.0.dev7 3 | 4 | # to send information about games to statistics server 5 | requests==2.26.0 6 | 7 | # to capture crash logs 8 | sentry-sdk==1.4.3 9 | -------------------------------------------------------------------------------- /requirements/dev.txt: -------------------------------------------------------------------------------- 1 | -r ./base.txt 2 | 3 | # for bots battle 4 | tqdm==4.62.3 5 | 6 | # for unit tests 7 | pytest==6.2.5 8 | pytest-xdist==2.4.0 9 | pytest-cov==3.0.0 10 | -------------------------------------------------------------------------------- /requirements/lint.txt: -------------------------------------------------------------------------------- 1 | -r ./dev.txt 2 | 3 | # for code formatting and linting 4 | black==21.9b0 5 | isort==5.9.3 6 | flake8==4.0.1 7 | flake8-bugbear==21.9.2 8 | flake8-print==4.0.0 9 | flake8-simplify==0.14.2 10 | --------------------------------------------------------------------------------