├── mahjong ├── py.typed ├── __init__.py ├── hand_calculating │ ├── __init__.py │ ├── yaku_list │ │ ├── yakuman │ │ │ ├── chiihou.py │ │ │ ├── suuankou_tanki.py │ │ │ ├── sashikomi.py │ │ │ ├── renhou_yakuman.py │ │ │ ├── daburu_chuuren_poutou.py │ │ │ ├── daburu_kokushi.py │ │ │ ├── tenhou.py │ │ │ ├── daichisei.py │ │ │ ├── suukantsu.py │ │ │ ├── chinroto.py │ │ │ ├── ryuisou.py │ │ │ ├── daisangen.py │ │ │ ├── tsuisou.py │ │ │ ├── paarenchan.py │ │ │ ├── suuankou.py │ │ │ ├── kokushi.py │ │ │ ├── daisuushi.py │ │ │ ├── shosuushi.py │ │ │ ├── __init__.py │ │ │ ├── chuuren_poutou.py │ │ │ └── daisharin.py │ │ ├── open_riichi.py │ │ ├── riichi.py │ │ ├── yakuhai_place.py │ │ ├── yakuhai_round.py │ │ ├── pinfu.py │ │ ├── dora.py │ │ ├── chiitoitsu.py │ │ ├── nagashi_mangan.py │ │ ├── tsumo.py │ │ ├── chankan.py │ │ ├── daburu_open_riichi.py │ │ ├── renhou.py │ │ ├── haitei.py │ │ ├── houtei.py │ │ ├── ippatsu.py │ │ ├── rinshan.py │ │ ├── daburu_riichi.py │ │ ├── aka_dora.py │ │ ├── chun.py │ │ ├── haku.py │ │ ├── hatsu.py │ │ ├── toitoi.py │ │ ├── sankantsu.py │ │ ├── honroto.py │ │ ├── tanyao.py │ │ ├── east.py │ │ ├── west.py │ │ ├── north.py │ │ ├── south.py │ │ ├── iipeiko.py │ │ ├── ryanpeiko.py │ │ ├── shosangen.py │ │ ├── chinitsu.py │ │ ├── honitsu.py │ │ ├── junchan.py │ │ ├── chantai.py │ │ ├── sanshoku.py │ │ ├── ittsu.py │ │ ├── sanshoku_douko.py │ │ ├── sanankou.py │ │ └── __init__.py │ ├── hand_response.py │ ├── yaku.py │ ├── yaku_config.py │ ├── hand_config.py │ ├── fu.py │ ├── divider.py │ └── scores.py ├── constants.py ├── meld.py ├── agari.py ├── utils.py └── tile.py ├── .coveragerc ├── tests ├── __init__.py ├── hand_calculating │ ├── __init__.py │ ├── tests_hand_dividing.py │ ├── tests_hand_response_error.py │ ├── tests_scores_calculation.py │ └── tests_aotenjou.py ├── tests_agari.py ├── tests_tiles_converter.py ├── utils_for_tests.py ├── tests_utils.py └── tests_shanten.py ├── MANIFEST.in ├── .editorconfig ├── .gitignore ├── Makefile ├── LICENSE.txt ├── .github └── workflows │ └── lint_and_test.yml ├── README.md ├── pyproject.toml ├── CHANGELOG.md └── doc └── examples.py /mahjong/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mahjong/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | skip_covered = true 3 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | exclude tests/* 2 | recursive-exclude tests * -------------------------------------------------------------------------------- /tests/hand_calculating/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | *.iml 3 | .vscode 4 | 5 | env 6 | .venv 7 | 8 | *.py[cod] 9 | __pycache__ 10 | .DS_Store 11 | 12 | MANIFEST 13 | dist/* 14 | mahjong.egg-info 15 | build/* 16 | 17 | temp 18 | temp.py 19 | 20 | .coverage 21 | htmlcov 22 | 23 | uv.lock 24 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | format: 2 | uv run ruff format 3 | 4 | lint: 5 | uv run ruff check 6 | 7 | .PHONY: tests 8 | tests: 9 | uv run pytest --cov=mahjong --cov-report=term --cov-report=html 10 | 11 | check: format lint tests 12 | 13 | build-package: 14 | rm -rf build dist mahjong.egg-info 15 | uv build 16 | 17 | # make build-and-release token=your_pypi_token 18 | build-and-release: build-package 19 | uv publish --token $(token) 20 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/yakuman/chiihou.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | 3 | from mahjong.hand_calculating.yaku import Yaku 4 | 5 | 6 | class Chiihou(Yaku): 7 | def set_attributes(self) -> None: 8 | self.tenhou_id = 38 9 | self.name = "Chiihou" 10 | 11 | self.han_open = None 12 | self.han_closed = 13 13 | 14 | self.is_yakuman = True 15 | 16 | def is_condition_met(self, hand: Collection[Sequence[int]], *args) -> bool: 17 | return True 18 | -------------------------------------------------------------------------------- /mahjong/constants.py: -------------------------------------------------------------------------------- 1 | # 1 and 9 2 | TERMINAL_INDICES = [0, 8, 9, 17, 18, 26] 3 | 4 | # dragons and winds 5 | EAST = 27 6 | SOUTH = 28 7 | WEST = 29 8 | NORTH = 30 9 | HAKU = 31 10 | HATSU = 32 11 | CHUN = 33 12 | 13 | WINDS = [EAST, SOUTH, WEST, NORTH] 14 | HONOR_INDICES = WINDS + [HAKU, HATSU, CHUN] 15 | 16 | FIVE_RED_MAN = 16 17 | FIVE_RED_PIN = 52 18 | FIVE_RED_SOU = 88 19 | 20 | AKA_DORA_LIST = [FIVE_RED_MAN, FIVE_RED_PIN, FIVE_RED_SOU] 21 | 22 | DISPLAY_WINDS = {EAST: "East", SOUTH: "South", WEST: "West", NORTH: "North"} 23 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/open_riichi.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Optional 3 | 4 | from mahjong.hand_calculating.yaku import Yaku 5 | 6 | 7 | class OpenRiichi(Yaku): 8 | def __init__(self, yaku_id: Optional[int]) -> None: 9 | super(OpenRiichi, self).__init__(yaku_id) 10 | 11 | def set_attributes(self) -> None: 12 | self.name = "Open Riichi" 13 | 14 | self.han_open = None 15 | self.han_closed = 2 16 | 17 | self.is_yakuman = False 18 | 19 | def is_condition_met(self, hand: Collection[Sequence[int]], *args) -> bool: 20 | return True 21 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/riichi.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Optional 3 | 4 | from mahjong.hand_calculating.yaku import Yaku 5 | 6 | 7 | class Riichi(Yaku): 8 | def __init__(self, yaku_id: Optional[int] = None) -> None: 9 | super(Riichi, self).__init__(yaku_id) 10 | 11 | def set_attributes(self) -> None: 12 | self.tenhou_id = 1 13 | 14 | self.name = "Riichi" 15 | 16 | self.han_open = None 17 | self.han_closed = 1 18 | 19 | self.is_yakuman = False 20 | 21 | def is_condition_met(self, hand: Collection[Sequence[int]], *args) -> bool: 22 | return True 23 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/yakuhai_place.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Optional 3 | 4 | from mahjong.hand_calculating.yaku import Yaku 5 | 6 | 7 | class YakuhaiOfPlace(Yaku): 8 | def __init__(self, yaku_id: Optional[int] = None) -> None: 9 | super(YakuhaiOfPlace, self).__init__(yaku_id) 10 | 11 | def set_attributes(self) -> None: 12 | self.tenhou_id = 10 13 | 14 | self.name = "Yakuhai (wind of place)" 15 | 16 | self.han_open = 1 17 | self.han_closed = 1 18 | 19 | self.is_yakuman = False 20 | 21 | def is_condition_met(self, hand: Collection[Sequence[int]], *args) -> bool: 22 | return True 23 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/yakuhai_round.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Optional 3 | 4 | from mahjong.hand_calculating.yaku import Yaku 5 | 6 | 7 | class YakuhaiOfRound(Yaku): 8 | def __init__(self, yaku_id: Optional[int] = None) -> None: 9 | super(YakuhaiOfRound, self).__init__(yaku_id) 10 | 11 | def set_attributes(self) -> None: 12 | self.tenhou_id = 11 13 | 14 | self.name = "Yakuhai (wind of round)" 15 | 16 | self.han_open = 1 17 | self.han_closed = 1 18 | 19 | self.is_yakuman = False 20 | 21 | def is_condition_met(self, hand: Collection[Sequence[int]], *args) -> bool: 22 | return True 23 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/yakuman/suuankou_tanki.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Optional 3 | 4 | from mahjong.hand_calculating.yaku import Yaku 5 | 6 | 7 | class SuuankouTanki(Yaku): 8 | def __init__(self, yaku_id: Optional[int] = None) -> None: 9 | super(SuuankouTanki, self).__init__(yaku_id) 10 | 11 | def set_attributes(self) -> None: 12 | self.tenhou_id = 40 13 | 14 | self.name = "Suu Ankou Tanki" 15 | 16 | self.han_open = None 17 | self.han_closed = 26 18 | 19 | self.is_yakuman = True 20 | 21 | def is_condition_met(self, hand: Collection[Sequence[int]], *args) -> bool: 22 | return True 23 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/pinfu.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Optional 3 | 4 | from mahjong.hand_calculating.yaku import Yaku 5 | 6 | 7 | class Pinfu(Yaku): 8 | """ 9 | Yaku situation 10 | """ 11 | 12 | def __init__(self, yaku_id: Optional[int] = None) -> None: 13 | super(Pinfu, self).__init__(yaku_id) 14 | 15 | def set_attributes(self) -> None: 16 | self.tenhou_id = 7 17 | 18 | self.name = "Pinfu" 19 | 20 | self.han_open = None 21 | self.han_closed = 1 22 | 23 | self.is_yakuman = False 24 | 25 | def is_condition_met(self, hand: Collection[Sequence[int]], *args) -> bool: 26 | return True 27 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/dora.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Optional 3 | 4 | from mahjong.hand_calculating.yaku import Yaku 5 | 6 | 7 | class Dora(Yaku): 8 | def __init__(self, yaku_id: Optional[int] = None) -> None: 9 | super(Dora, self).__init__(yaku_id) 10 | 11 | def set_attributes(self) -> None: 12 | self.tenhou_id = 52 13 | 14 | self.name = "Dora" 15 | 16 | self.han_open = 1 17 | self.han_closed = 1 18 | 19 | self.is_yakuman = False 20 | 21 | def is_condition_met(self, hand: Collection[Sequence[int]], *args) -> bool: 22 | return True 23 | 24 | def __str__(self) -> str: 25 | return "Dora {}".format(self.han_closed) 26 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/chiitoitsu.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Optional 3 | 4 | from mahjong.hand_calculating.yaku import Yaku 5 | 6 | 7 | class Chiitoitsu(Yaku): 8 | """ 9 | Hand contains only pairs 10 | """ 11 | 12 | def __init__(self, yaku_id: Optional[int] = None) -> None: 13 | super(Chiitoitsu, self).__init__(yaku_id) 14 | 15 | def set_attributes(self) -> None: 16 | self.tenhou_id = 22 17 | 18 | self.name = "Chiitoitsu" 19 | 20 | self.han_open = None 21 | self.han_closed = 2 22 | 23 | self.is_yakuman = False 24 | 25 | def is_condition_met(self, hand: Collection[Sequence[int]], *args) -> bool: 26 | return len(hand) == 7 27 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/yakuman/sashikomi.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Optional 3 | 4 | from mahjong.hand_calculating.yaku import Yaku 5 | 6 | 7 | class Sashikomi(Yaku): 8 | """ 9 | Yaku situation 10 | """ 11 | 12 | def __init__(self, yaku_id: Optional[int]) -> None: 13 | super(Sashikomi, self).__init__(yaku_id) 14 | 15 | def set_attributes(self) -> None: 16 | self.name = "Sashikomi" 17 | 18 | self.han_open = None 19 | self.han_closed = 13 20 | 21 | self.is_yakuman = True 22 | 23 | def is_condition_met(self, hand: Collection[Sequence[int]], *args) -> bool: 24 | # was it here or not is controlling by superior code 25 | return True 26 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/nagashi_mangan.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Optional 3 | 4 | from mahjong.hand_calculating.yaku import Yaku 5 | 6 | 7 | class NagashiMangan(Yaku): 8 | """ 9 | Yaku situation 10 | """ 11 | 12 | def __init__(self, yaku_id: Optional[int] = None) -> None: 13 | super(NagashiMangan, self).__init__(yaku_id) 14 | 15 | def set_attributes(self) -> None: 16 | self.name = "Nagashi Mangan" 17 | 18 | self.han_open = 5 19 | self.han_closed = 5 20 | 21 | self.is_yakuman = False 22 | 23 | def is_condition_met(self, hand: Collection[Sequence[int]], *args) -> bool: 24 | # was it here or not is controlling by superior code 25 | return True 26 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/tsumo.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Optional 3 | 4 | from mahjong.hand_calculating.yaku import Yaku 5 | 6 | 7 | class Tsumo(Yaku): 8 | """ 9 | Yaku situation 10 | """ 11 | 12 | def __init__(self, yaku_id: Optional[int] = None) -> None: 13 | super(Tsumo, self).__init__(yaku_id) 14 | 15 | def set_attributes(self) -> None: 16 | self.tenhou_id = 0 17 | self.name = "Menzen Tsumo" 18 | 19 | self.han_open = None 20 | self.han_closed = 1 21 | 22 | self.is_yakuman = False 23 | 24 | def is_condition_met(self, hand: Collection[Sequence[int]], *args) -> bool: 25 | # was it here or not is controlling by superior code 26 | return True 27 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/chankan.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Optional 3 | 4 | from mahjong.hand_calculating.yaku import Yaku 5 | 6 | 7 | class Chankan(Yaku): 8 | """ 9 | Yaku situation 10 | """ 11 | 12 | def __init__(self, yaku_id: Optional[int] = None) -> None: 13 | super(Chankan, self).__init__(yaku_id) 14 | 15 | def set_attributes(self) -> None: 16 | self.tenhou_id = 3 17 | 18 | self.name = "Chankan" 19 | 20 | self.han_open = 1 21 | self.han_closed = 1 22 | 23 | self.is_yakuman = False 24 | 25 | def is_condition_met(self, hand: Collection[Sequence[int]], *args) -> bool: 26 | # was it here or not is controlling by superior code 27 | return True 28 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/daburu_open_riichi.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Optional 3 | 4 | from mahjong.hand_calculating.yaku import Yaku 5 | 6 | 7 | class DaburuOpenRiichi(Yaku): 8 | """ 9 | Yaku situation 10 | """ 11 | 12 | def __init__(self, yaku_id: Optional[int]) -> None: 13 | super(DaburuOpenRiichi, self).__init__(yaku_id) 14 | 15 | def set_attributes(self) -> None: 16 | self.name = "Double Open Riichi" 17 | 18 | self.han_open = None 19 | self.han_closed = 3 20 | 21 | self.is_yakuman = False 22 | 23 | def is_condition_met(self, hand: Collection[Sequence[int]], *args) -> bool: 24 | # was it here or not is controlling by superior code 25 | return True 26 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/renhou.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Optional 3 | 4 | from mahjong.hand_calculating.yaku import Yaku 5 | 6 | 7 | class Renhou(Yaku): 8 | """ 9 | Yaku situation 10 | """ 11 | 12 | def __init__(self, yaku_id: Optional[int] = None) -> None: 13 | super(Renhou, self).__init__(yaku_id) 14 | 15 | def set_attributes(self) -> None: 16 | self.tenhou_id = 36 17 | 18 | self.name = "Renhou" 19 | 20 | self.han_open = None 21 | self.han_closed = 5 22 | 23 | self.is_yakuman = False 24 | 25 | def is_condition_met(self, hand: Collection[Sequence[int]], *args) -> bool: 26 | # was it here or not is controlling by superior code 27 | return True 28 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/yakuman/renhou_yakuman.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Optional 3 | 4 | from mahjong.hand_calculating.yaku import Yaku 5 | 6 | 7 | class RenhouYakuman(Yaku): 8 | """ 9 | Yaku situation 10 | """ 11 | 12 | def __init__(self, yaku_id: Optional[int] = None) -> None: 13 | super(RenhouYakuman, self).__init__(yaku_id) 14 | 15 | def set_attributes(self) -> None: 16 | self.name = "Renhou (yakuman)" 17 | 18 | self.han_open = None 19 | self.han_closed = 13 20 | 21 | self.is_yakuman = True 22 | 23 | def is_condition_met(self, hand: Collection[Sequence[int]], *args) -> bool: 24 | # was it here or not is controlling by superior code 25 | return True 26 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/haitei.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Optional 3 | 4 | from mahjong.hand_calculating.yaku import Yaku 5 | 6 | 7 | class Haitei(Yaku): 8 | """ 9 | Yaku situation 10 | """ 11 | 12 | def __init__(self, yaku_id: Optional[int] = None) -> None: 13 | super(Haitei, self).__init__(yaku_id) 14 | 15 | def set_attributes(self) -> None: 16 | self.tenhou_id = 5 17 | 18 | self.name = "Haitei Raoyue" 19 | 20 | self.han_open = 1 21 | self.han_closed = 1 22 | 23 | self.is_yakuman = False 24 | 25 | def is_condition_met(self, hand: Collection[Sequence[int]], *args) -> bool: 26 | # was it here or not is controlling by superior code 27 | return True 28 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/houtei.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Optional 3 | 4 | from mahjong.hand_calculating.yaku import Yaku 5 | 6 | 7 | class Houtei(Yaku): 8 | """ 9 | Yaku situation 10 | """ 11 | 12 | def __init__(self, yaku_id: Optional[int] = None) -> None: 13 | super(Houtei, self).__init__(yaku_id) 14 | 15 | def set_attributes(self) -> None: 16 | self.tenhou_id = 6 17 | 18 | self.name = "Houtei Raoyui" 19 | 20 | self.han_open = 1 21 | self.han_closed = 1 22 | 23 | self.is_yakuman = False 24 | 25 | def is_condition_met(self, hand: Collection[Sequence[int]], *args) -> bool: 26 | # was it here or not is controlling by superior code 27 | return True 28 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/ippatsu.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Optional 3 | 4 | from mahjong.hand_calculating.yaku import Yaku 5 | 6 | 7 | class Ippatsu(Yaku): 8 | """ 9 | Yaku situation 10 | """ 11 | 12 | def __init__(self, yaku_id: Optional[int] = None) -> None: 13 | super(Ippatsu, self).__init__(yaku_id) 14 | 15 | def set_attributes(self) -> None: 16 | self.tenhou_id = 2 17 | 18 | self.name = "Ippatsu" 19 | 20 | self.han_open = None 21 | self.han_closed = 1 22 | 23 | self.is_yakuman = False 24 | 25 | def is_condition_met(self, hand: Collection[Sequence[int]], *args) -> bool: 26 | # was it here or not is controlling by superior code 27 | return True 28 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/rinshan.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Optional 3 | 4 | from mahjong.hand_calculating.yaku import Yaku 5 | 6 | 7 | class Rinshan(Yaku): 8 | """ 9 | Yaku situation 10 | """ 11 | 12 | def __init__(self, yaku_id: Optional[int] = None) -> None: 13 | super(Rinshan, self).__init__(yaku_id) 14 | 15 | def set_attributes(self) -> None: 16 | self.tenhou_id = 4 17 | 18 | self.name = "Rinshan Kaihou" 19 | 20 | self.han_open = 1 21 | self.han_closed = 1 22 | 23 | self.is_yakuman = False 24 | 25 | def is_condition_met(self, hand: Collection[Sequence[int]], *args) -> bool: 26 | # was it here or not is controlling by superior code 27 | return True 28 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/yakuman/daburu_chuuren_poutou.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Optional 3 | 4 | from mahjong.hand_calculating.yaku import Yaku 5 | 6 | 7 | class DaburuChuurenPoutou(Yaku): 8 | def __init__(self, yaku_id: Optional[int] = None) -> None: 9 | super(DaburuChuurenPoutou, self).__init__(yaku_id) 10 | 11 | def set_attributes(self) -> None: 12 | self.tenhou_id = 46 13 | 14 | self.name = "Daburu Chuuren Poutou" 15 | 16 | self.han_open = None 17 | self.han_closed = 26 18 | 19 | self.is_yakuman = True 20 | 21 | def is_condition_met(self, hand: Collection[Sequence[int]], *args) -> bool: 22 | # was it here or not is controlling by superior code 23 | return True 24 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/yakuman/daburu_kokushi.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Optional 3 | 4 | from mahjong.hand_calculating.yaku import Yaku 5 | 6 | 7 | class DaburuKokushiMusou(Yaku): 8 | def __init__(self, yaku_id: Optional[int] = None) -> None: 9 | super(DaburuKokushiMusou, self).__init__(yaku_id) 10 | 11 | def set_attributes(self) -> None: 12 | self.tenhou_id = 48 13 | 14 | self.name = "Kokushi Musou Juusanmen Matchi" 15 | 16 | self.han_open = None 17 | self.han_closed = 26 18 | 19 | self.is_yakuman = True 20 | 21 | def is_condition_met(self, hand: Collection[Sequence[int]], *args) -> bool: 22 | # was it here or not is controlling by superior code 23 | return True 24 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/yakuman/tenhou.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Optional 3 | 4 | from mahjong.hand_calculating.yaku import Yaku 5 | 6 | 7 | class Tenhou(Yaku): 8 | """ 9 | Yaku situation 10 | """ 11 | 12 | def __init__(self, yaku_id: Optional[int] = None) -> None: 13 | super(Tenhou, self).__init__(yaku_id) 14 | 15 | def set_attributes(self) -> None: 16 | self.tenhou_id = 37 17 | 18 | self.name = "Tenhou" 19 | 20 | self.han_open = None 21 | self.han_closed = 13 22 | 23 | self.is_yakuman = True 24 | 25 | def is_condition_met(self, hand: Collection[Sequence[int]], *args) -> bool: 26 | # was it here or not is controlling by superior code 27 | return True 28 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/daburu_riichi.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Optional 3 | 4 | from mahjong.hand_calculating.yaku import Yaku 5 | 6 | 7 | class DaburuRiichi(Yaku): 8 | """ 9 | Yaku situation 10 | """ 11 | 12 | def __init__(self, yaku_id: Optional[int] = None) -> None: 13 | super(DaburuRiichi, self).__init__(yaku_id) 14 | 15 | def set_attributes(self) -> None: 16 | self.tenhou_id = 21 17 | 18 | self.name = "Double Riichi" 19 | 20 | self.han_open = None 21 | self.han_closed = 2 22 | 23 | self.is_yakuman = False 24 | 25 | def is_condition_met(self, hand: Collection[Sequence[int]], *args) -> bool: 26 | # was it here or not is controlling by superior code 27 | return True 28 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/aka_dora.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Optional 3 | 4 | from mahjong.hand_calculating.yaku import Yaku 5 | 6 | 7 | class AkaDora(Yaku): 8 | """ 9 | Red five 10 | """ 11 | 12 | def __init__(self, yaku_id: Optional[int] = None) -> None: 13 | super(AkaDora, self).__init__(yaku_id) 14 | 15 | def set_attributes(self) -> None: 16 | self.tenhou_id = 54 17 | 18 | self.name = "Aka Dora" 19 | 20 | self.han_open = 1 21 | self.han_closed = 1 22 | 23 | self.is_yakuman = False 24 | 25 | def is_condition_met(self, hand: Collection[Sequence[int]], *args) -> bool: 26 | return True 27 | 28 | def __str__(self) -> str: 29 | return "Aka Dora {}".format(self.han_closed) 30 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/chun.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Optional 3 | 4 | from mahjong.constants import CHUN 5 | from mahjong.hand_calculating.yaku import Yaku 6 | from mahjong.utils import is_pon_or_kan 7 | 8 | 9 | class Chun(Yaku): 10 | """ 11 | Pon of red dragons 12 | """ 13 | 14 | def __init__(self, yaku_id: Optional[int] = None) -> None: 15 | super(Chun, self).__init__(yaku_id) 16 | 17 | def set_attributes(self) -> None: 18 | self.tenhou_id = 20 19 | 20 | self.name = "Yakuhai (chun)" 21 | 22 | self.han_open = 1 23 | self.han_closed = 1 24 | 25 | self.is_yakuman = False 26 | 27 | def is_condition_met(self, hand: Collection[Sequence[int]], *args) -> bool: 28 | return len([x for x in hand if is_pon_or_kan(x) and x[0] == CHUN]) == 1 29 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/haku.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Optional 3 | 4 | from mahjong.constants import HAKU 5 | from mahjong.hand_calculating.yaku import Yaku 6 | from mahjong.utils import is_pon_or_kan 7 | 8 | 9 | class Haku(Yaku): 10 | """ 11 | Pon of white dragons 12 | """ 13 | 14 | def __init__(self, yaku_id: Optional[int] = None) -> None: 15 | super(Haku, self).__init__(yaku_id) 16 | 17 | def set_attributes(self) -> None: 18 | self.tenhou_id = 18 19 | 20 | self.name = "Yakuhai (haku)" 21 | 22 | self.han_open = 1 23 | self.han_closed = 1 24 | 25 | self.is_yakuman = False 26 | 27 | def is_condition_met(self, hand: Collection[Sequence[int]], *args) -> bool: 28 | return len([x for x in hand if is_pon_or_kan(x) and x[0] == HAKU]) == 1 29 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/hatsu.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Optional 3 | 4 | from mahjong.constants import HATSU 5 | from mahjong.hand_calculating.yaku import Yaku 6 | from mahjong.utils import is_pon_or_kan 7 | 8 | 9 | class Hatsu(Yaku): 10 | """ 11 | Pon of green dragons 12 | """ 13 | 14 | def __init__(self, yaku_id: Optional[int] = None) -> None: 15 | super(Hatsu, self).__init__(yaku_id) 16 | 17 | def set_attributes(self) -> None: 18 | self.tenhou_id = 19 19 | 20 | self.name = "Yakuhai (hatsu)" 21 | 22 | self.han_open = 1 23 | self.han_closed = 1 24 | 25 | self.is_yakuman = False 26 | 27 | def is_condition_met(self, hand: Collection[Sequence[int]], *args) -> bool: 28 | return len([x for x in hand if is_pon_or_kan(x) and x[0] == HATSU]) == 1 29 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/yakuman/daichisei.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from itertools import chain 3 | from typing import Optional 4 | 5 | from mahjong.constants import HONOR_INDICES 6 | from mahjong.hand_calculating.yaku import Yaku 7 | 8 | 9 | class Daichisei(Yaku): 10 | """ 11 | Yaku situation 12 | """ 13 | 14 | def __init__(self, yaku_id: Optional[int]) -> None: 15 | super(Daichisei, self).__init__(yaku_id) 16 | 17 | def set_attributes(self) -> None: 18 | self.name = "Daichisei" 19 | 20 | self.han_open = None 21 | self.han_closed = 13 22 | 23 | self.is_yakuman = True 24 | 25 | def is_condition_met(self, hand: Collection[Sequence[int]], *args) -> bool: 26 | indices = chain.from_iterable(hand) 27 | return all(x in HONOR_INDICES for x in indices) and len(hand) == 7 28 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/toitoi.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Optional 3 | 4 | from mahjong.hand_calculating.yaku import Yaku 5 | from mahjong.utils import is_pon_or_kan 6 | 7 | 8 | class Toitoi(Yaku): 9 | """ 10 | The hand consists of all pon sets (and of course a pair), no sequences. 11 | """ 12 | 13 | def __init__(self, yaku_id: Optional[int] = None) -> None: 14 | super(Toitoi, self).__init__(yaku_id) 15 | 16 | def set_attributes(self) -> None: 17 | self.tenhou_id = 28 18 | self.name = "Toitoi" 19 | 20 | self.han_open = 2 21 | self.han_closed = 2 22 | 23 | self.is_yakuman = False 24 | 25 | def is_condition_met(self, hand: Collection[Sequence[int]], *args) -> bool: 26 | count_of_pon = len([i for i in hand if is_pon_or_kan(i)]) 27 | return count_of_pon == 4 28 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/sankantsu.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Optional 3 | 4 | from mahjong.hand_calculating.yaku import Yaku 5 | from mahjong.meld import Meld 6 | 7 | 8 | class SanKantsu(Yaku): 9 | """ 10 | The hand with three kan sets 11 | """ 12 | 13 | def __init__(self, yaku_id: Optional[int] = None) -> None: 14 | super(SanKantsu, self).__init__(yaku_id) 15 | 16 | def set_attributes(self) -> None: 17 | self.tenhou_id = 27 18 | 19 | self.name = "San Kantsu" 20 | 21 | self.han_open = 2 22 | self.han_closed = 2 23 | 24 | self.is_yakuman = False 25 | 26 | def is_condition_met(self, hand: Collection[Sequence[int]], melds: Collection[Meld], *args) -> bool: 27 | kan_sets = [x for x in melds if x.type == Meld.KAN or x.type == Meld.SHOUMINKAN] 28 | return len(kan_sets) == 3 29 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/yakuman/suukantsu.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Optional 3 | 4 | from mahjong.hand_calculating.yaku import Yaku 5 | from mahjong.meld import Meld 6 | 7 | 8 | class Suukantsu(Yaku): 9 | """ 10 | The hand with four kan sets 11 | """ 12 | 13 | def __init__(self, yaku_id: Optional[int] = None) -> None: 14 | super(Suukantsu, self).__init__(yaku_id) 15 | 16 | def set_attributes(self) -> None: 17 | self.tenhou_id = 51 18 | 19 | self.name = "Suu Kantsu" 20 | 21 | self.han_open = 13 22 | self.han_closed = 13 23 | 24 | self.is_yakuman = True 25 | 26 | def is_condition_met(self, hand: Collection[Sequence[int]], melds: Collection[Meld], *args) -> bool: 27 | kan_sets = [x for x in melds if x.type == Meld.KAN or x.type == Meld.SHOUMINKAN] 28 | return len(kan_sets) == 4 29 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/honroto.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from itertools import chain 3 | from typing import Optional 4 | 5 | from mahjong.constants import HONOR_INDICES, TERMINAL_INDICES 6 | from mahjong.hand_calculating.yaku import Yaku 7 | 8 | 9 | class Honroto(Yaku): 10 | """ 11 | All tiles are terminals or honours 12 | """ 13 | 14 | def __init__(self, yaku_id: Optional[int] = None) -> None: 15 | super(Honroto, self).__init__(yaku_id) 16 | 17 | def set_attributes(self) -> None: 18 | self.tenhou_id = 31 19 | 20 | self.name = "Honroutou" 21 | 22 | self.han_open = 2 23 | self.han_closed = 2 24 | 25 | self.is_yakuman = False 26 | 27 | def is_condition_met(self, hand: Collection[Sequence[int]], *args) -> bool: 28 | indices = chain.from_iterable(hand) 29 | result = HONOR_INDICES + TERMINAL_INDICES 30 | return all(x in result for x in indices) 31 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/tanyao.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from itertools import chain 3 | from typing import Optional 4 | 5 | from mahjong.constants import HONOR_INDICES, TERMINAL_INDICES 6 | from mahjong.hand_calculating.yaku import Yaku 7 | 8 | 9 | class Tanyao(Yaku): 10 | """ 11 | Hand without 1, 9, dragons and winds 12 | """ 13 | 14 | def __init__(self, yaku_id: Optional[int] = None) -> None: 15 | super(Tanyao, self).__init__(yaku_id) 16 | 17 | def set_attributes(self) -> None: 18 | self.tenhou_id = 8 19 | 20 | self.name = "Tanyao" 21 | 22 | self.han_open = 1 23 | self.han_closed = 1 24 | 25 | self.is_yakuman = False 26 | 27 | def is_condition_met(self, hand: Collection[Sequence[int]], *args) -> bool: 28 | indices = chain.from_iterable(hand) 29 | result = TERMINAL_INDICES + HONOR_INDICES 30 | return not any(x in result for x in indices) 31 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/yakuman/chinroto.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from itertools import chain 3 | from typing import Optional 4 | 5 | from mahjong.constants import TERMINAL_INDICES 6 | from mahjong.hand_calculating.yaku import Yaku 7 | 8 | 9 | class Chinroutou(Yaku): 10 | def __init__(self, yaku_id: Optional[int] = None) -> None: 11 | super(Chinroutou, self).__init__(yaku_id) 12 | 13 | def set_attributes(self) -> None: 14 | self.tenhou_id = 44 15 | 16 | self.name = "Chinroutou" 17 | 18 | self.han_open = 13 19 | self.han_closed = 13 20 | 21 | self.is_yakuman = True 22 | 23 | def is_condition_met(self, hand: Collection[Sequence[int]], *args) -> bool: 24 | """ 25 | Hand composed entirely of terminal tiles. 26 | :param hand: list of hand's sets 27 | :return: boolean 28 | """ 29 | indices = chain.from_iterable(hand) 30 | return all(x in TERMINAL_INDICES for x in indices) 31 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/yakuman/ryuisou.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from itertools import chain 3 | from typing import Optional 4 | 5 | from mahjong.constants import HATSU 6 | from mahjong.hand_calculating.yaku import Yaku 7 | 8 | 9 | class Ryuuiisou(Yaku): 10 | """ 11 | Hand composed entirely of green tiles. Green tiles are: green dragons and 2, 3, 4, 6 and 8 of sou. 12 | """ 13 | 14 | def __init__(self, yaku_id: Optional[int] = None) -> None: 15 | super(Ryuuiisou, self).__init__(yaku_id) 16 | 17 | def set_attributes(self) -> None: 18 | self.tenhou_id = 43 19 | 20 | self.name = "Ryuuiisou" 21 | 22 | self.han_open = 13 23 | self.han_closed = 13 24 | 25 | self.is_yakuman = True 26 | 27 | def is_condition_met(self, hand: Collection[Sequence[int]], *args) -> bool: 28 | green_indices = [19, 20, 21, 23, 25, HATSU] 29 | indices = chain.from_iterable(hand) 30 | return all(x in green_indices for x in indices) 31 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/yakuman/daisangen.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Optional 3 | 4 | from mahjong.constants import CHUN, HAKU, HATSU 5 | from mahjong.hand_calculating.yaku import Yaku 6 | from mahjong.utils import is_pon_or_kan 7 | 8 | 9 | class Daisangen(Yaku): 10 | """ 11 | The hand contains three sets of dragons 12 | """ 13 | 14 | def __init__(self, yaku_id: Optional[int] = None) -> None: 15 | super(Daisangen, self).__init__(yaku_id) 16 | 17 | def set_attributes(self) -> None: 18 | self.tenhou_id = 39 19 | 20 | self.name = "Daisangen" 21 | 22 | self.han_open = 13 23 | self.han_closed = 13 24 | 25 | self.is_yakuman = True 26 | 27 | def is_condition_met(self, hand: Collection[Sequence[int]], *args) -> bool: 28 | count_of_dragon_pon_sets = 0 29 | for item in hand: 30 | if is_pon_or_kan(item) and item[0] in [CHUN, HAKU, HATSU]: 31 | count_of_dragon_pon_sets += 1 32 | return count_of_dragon_pon_sets == 3 33 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/yakuman/tsuisou.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from itertools import chain 3 | from typing import Optional 4 | 5 | from mahjong.constants import HONOR_INDICES 6 | from mahjong.hand_calculating.yaku import Yaku 7 | 8 | 9 | class Tsuuiisou(Yaku): 10 | """ 11 | Hand composed entirely of honour tiles 12 | """ 13 | 14 | def __init__(self, yaku_id: Optional[int] = None) -> None: 15 | super(Tsuuiisou, self).__init__(yaku_id) 16 | 17 | def set_attributes(self) -> None: 18 | self.tenhou_id = 42 19 | 20 | self.name = "Tsuu Iisou" 21 | 22 | self.han_open = 13 23 | self.han_closed = 13 24 | 25 | self.is_yakuman = True 26 | 27 | def is_condition_met(self, hand: Collection[Sequence[int]], *args) -> bool: 28 | """ 29 | Hand composed entirely of honour tiles. 30 | :param hand: list of hand's sets 31 | :return: boolean 32 | """ 33 | indices = chain.from_iterable(hand) 34 | return all(x in HONOR_INDICES for x in indices) 35 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/yakuman/paarenchan.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Optional 3 | 4 | from mahjong.hand_calculating.yaku import Yaku 5 | 6 | 7 | class Paarenchan(Yaku): 8 | """ 9 | Yaku situation 10 | """ 11 | 12 | def __init__(self, yaku_id: Optional[int]) -> None: 13 | super(Paarenchan, self).__init__(yaku_id) 14 | 15 | def set_attributes(self) -> None: 16 | self.tenhou_id = 37 17 | 18 | self.name = "Paarenchan" 19 | 20 | self.han_open = 13 21 | self.han_closed = 13 22 | self.count = 0 23 | 24 | self.is_yakuman = True 25 | 26 | def set_paarenchan_count(self, count: int) -> None: 27 | self.han_open = 13 * count 28 | self.han_closed = 13 * count 29 | self.count = count 30 | 31 | def is_condition_met(self, hand: Collection[Sequence[int]], *args) -> bool: 32 | # was it here or not is controlling by superior code 33 | return True 34 | 35 | def __str__(self) -> str: 36 | return "Paarenchan {}".format(self.count) 37 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [2017] [Alexey Lisikhin] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/east.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Optional 3 | 4 | from mahjong.constants import EAST 5 | from mahjong.hand_calculating.yaku import Yaku 6 | from mahjong.utils import is_pon_or_kan 7 | 8 | 9 | class YakuhaiEast(Yaku): 10 | """ 11 | Pon of east winds 12 | """ 13 | 14 | def __init__(self, yaku_id: Optional[int] = None) -> None: 15 | super(YakuhaiEast, self).__init__(yaku_id) 16 | 17 | def set_attributes(self) -> None: 18 | self.tenhou_id = 10 19 | 20 | self.name = "Yakuhai (east)" 21 | 22 | self.han_open = 1 23 | self.han_closed = 1 24 | 25 | self.is_yakuman = False 26 | 27 | def is_condition_met(self, hand: Collection[Sequence[int]], player_wind: int, round_wind: int, *args) -> bool: 28 | if len([x for x in hand if is_pon_or_kan(x) and x[0] == player_wind]) == 1 and player_wind == EAST: 29 | return True 30 | 31 | if len([x for x in hand if is_pon_or_kan(x) and x[0] == round_wind]) == 1 and round_wind == EAST: 32 | return True 33 | 34 | return False 35 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/west.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Optional 3 | 4 | from mahjong.constants import WEST 5 | from mahjong.hand_calculating.yaku import Yaku 6 | from mahjong.utils import is_pon_or_kan 7 | 8 | 9 | class YakuhaiWest(Yaku): 10 | """ 11 | Pon of west winds 12 | """ 13 | 14 | def __init__(self, yaku_id: Optional[int] = None) -> None: 15 | super(YakuhaiWest, self).__init__(yaku_id) 16 | 17 | def set_attributes(self) -> None: 18 | self.tenhou_id = 10 19 | 20 | self.name = "Yakuhai (west)" 21 | 22 | self.han_open = 1 23 | self.han_closed = 1 24 | 25 | self.is_yakuman = False 26 | 27 | def is_condition_met(self, hand: Collection[Sequence[int]], player_wind: int, round_wind: int, *args) -> bool: 28 | if len([x for x in hand if is_pon_or_kan(x) and x[0] == player_wind]) == 1 and player_wind == WEST: 29 | return True 30 | 31 | if len([x for x in hand if is_pon_or_kan(x) and x[0] == round_wind]) == 1 and round_wind == WEST: 32 | return True 33 | 34 | return False 35 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/north.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Optional 3 | 4 | from mahjong.constants import NORTH 5 | from mahjong.hand_calculating.yaku import Yaku 6 | from mahjong.utils import is_pon_or_kan 7 | 8 | 9 | class YakuhaiNorth(Yaku): 10 | """ 11 | Pon of north winds 12 | """ 13 | 14 | def __init__(self, yaku_id: Optional[int] = None) -> None: 15 | super(YakuhaiNorth, self).__init__(yaku_id) 16 | 17 | def set_attributes(self) -> None: 18 | self.tenhou_id = 10 19 | 20 | self.name = "Yakuhai (north)" 21 | 22 | self.han_open = 1 23 | self.han_closed = 1 24 | 25 | self.is_yakuman = False 26 | 27 | def is_condition_met(self, hand: Collection[Sequence[int]], player_wind: int, round_wind: int, *args) -> bool: 28 | if len([x for x in hand if is_pon_or_kan(x) and x[0] == player_wind]) == 1 and player_wind == NORTH: 29 | return True 30 | 31 | if len([x for x in hand if is_pon_or_kan(x) and x[0] == round_wind]) == 1 and round_wind == NORTH: 32 | return True 33 | 34 | return False 35 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/south.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Optional 3 | 4 | from mahjong.constants import SOUTH 5 | from mahjong.hand_calculating.yaku import Yaku 6 | from mahjong.utils import is_pon_or_kan 7 | 8 | 9 | class YakuhaiSouth(Yaku): 10 | """ 11 | Pon of south winds 12 | """ 13 | 14 | def __init__(self, yaku_id: Optional[int] = None) -> None: 15 | super(YakuhaiSouth, self).__init__(yaku_id) 16 | 17 | def set_attributes(self) -> None: 18 | self.tenhou_id = 10 19 | 20 | self.name = "Yakuhai (south)" 21 | 22 | self.han_open = 1 23 | self.han_closed = 1 24 | 25 | self.is_yakuman = False 26 | 27 | def is_condition_met(self, hand: Collection[Sequence[int]], player_wind: int, round_wind: int, *args) -> bool: 28 | if len([x for x in hand if is_pon_or_kan(x) and x[0] == player_wind]) == 1 and player_wind == SOUTH: 29 | return True 30 | 31 | if len([x for x in hand if is_pon_or_kan(x) and x[0] == round_wind]) == 1 and round_wind == SOUTH: 32 | return True 33 | 34 | return False 35 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/iipeiko.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Optional 3 | 4 | from mahjong.hand_calculating.yaku import Yaku 5 | from mahjong.utils import is_chi 6 | 7 | 8 | class Iipeiko(Yaku): 9 | """ 10 | Hand with two identical chi 11 | """ 12 | 13 | def __init__(self, yaku_id: Optional[int] = None) -> None: 14 | super(Iipeiko, self).__init__(yaku_id) 15 | 16 | def set_attributes(self) -> None: 17 | self.tenhou_id = 9 18 | 19 | self.name = "Iipeiko" 20 | 21 | self.han_open = None 22 | self.han_closed = 1 23 | 24 | self.is_yakuman = False 25 | 26 | def is_condition_met(self, hand: Collection[Sequence[int]], *args) -> bool: 27 | chi_sets = [i for i in hand if is_chi(i)] 28 | 29 | count_of_identical_chi = 0 30 | for x in chi_sets: 31 | count = 0 32 | for y in chi_sets: 33 | if x == y: 34 | count += 1 35 | if count > count_of_identical_chi: 36 | count_of_identical_chi = count 37 | 38 | return count_of_identical_chi >= 2 39 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/ryanpeiko.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Optional 3 | 4 | from mahjong.hand_calculating.yaku import Yaku 5 | from mahjong.utils import is_chi 6 | 7 | 8 | class Ryanpeikou(Yaku): 9 | """ 10 | The hand contains two different Iipeikou’s 11 | """ 12 | 13 | def __init__(self, yaku_id: Optional[int] = None) -> None: 14 | super(Ryanpeikou, self).__init__(yaku_id) 15 | 16 | def set_attributes(self) -> None: 17 | self.tenhou_id = 32 18 | 19 | self.name = "Ryanpeikou" 20 | 21 | self.han_open = None 22 | self.han_closed = 3 23 | 24 | self.is_yakuman = False 25 | 26 | def is_condition_met(self, hand: Collection[Sequence[int]], *args) -> bool: 27 | chi_sets = [i for i in hand if is_chi(i)] 28 | count_of_identical_chi = [] 29 | for x in chi_sets: 30 | count = 0 31 | for y in chi_sets: 32 | if x == y: 33 | count += 1 34 | count_of_identical_chi.append(count) 35 | 36 | return len([x for x in count_of_identical_chi if x >= 2]) == 4 37 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/shosangen.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Optional 3 | 4 | from mahjong.constants import CHUN, HAKU, HATSU 5 | from mahjong.hand_calculating.yaku import Yaku 6 | from mahjong.utils import is_pair, is_pon_or_kan 7 | 8 | 9 | class Shosangen(Yaku): 10 | """ 11 | Hand with two dragon pon sets and one dragon pair 12 | """ 13 | 14 | def __init__(self, yaku_id: Optional[int] = None) -> None: 15 | super(Shosangen, self).__init__(yaku_id) 16 | 17 | def set_attributes(self) -> None: 18 | self.tenhou_id = 30 19 | 20 | self.name = "Shou Sangen" 21 | 22 | self.han_open = 2 23 | self.han_closed = 2 24 | 25 | self.is_yakuman = False 26 | 27 | def is_condition_met(self, hand: Collection[Sequence[int]], *args) -> bool: 28 | dragons = [CHUN, HAKU, HATSU] 29 | count_of_conditions = 0 30 | for item in hand: 31 | # dragon pon or pair 32 | if (is_pair(item) or is_pon_or_kan(item)) and item[0] in dragons: 33 | count_of_conditions += 1 34 | 35 | return count_of_conditions == 3 36 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/yakuman/suuankou.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Optional 3 | 4 | from mahjong.hand_calculating.yaku import Yaku 5 | from mahjong.utils import is_pon_or_kan 6 | 7 | 8 | class Suuankou(Yaku): 9 | """ 10 | Four closed pon sets 11 | """ 12 | 13 | def __init__(self, yaku_id: Optional[int] = None) -> None: 14 | super(Suuankou, self).__init__(yaku_id) 15 | 16 | def set_attributes(self) -> None: 17 | self.tenhou_id = 41 18 | 19 | self.name = "Suu Ankou" 20 | 21 | self.han_open = None 22 | self.han_closed = 13 23 | 24 | self.is_yakuman = True 25 | 26 | def is_condition_met(self, hand: Collection[Sequence[int]], win_tile: int, is_tsumo: bool) -> bool: 27 | win_tile //= 4 28 | closed_hand = [] 29 | for item in hand: 30 | # if we do the ron on syanpon wait our pon will be consider as open 31 | if is_pon_or_kan(item) and win_tile in item and not is_tsumo: 32 | continue 33 | 34 | closed_hand.append(item) 35 | 36 | count_of_pon = len([i for i in closed_hand if is_pon_or_kan(i)]) 37 | return count_of_pon == 4 38 | -------------------------------------------------------------------------------- /.github/workflows/lint_and_test.yml: -------------------------------------------------------------------------------- 1 | name: Linters and tests 2 | 3 | on: 4 | push: 5 | paths: 6 | - mahjong/** 7 | branches: 8 | - master 9 | 10 | pull_request: 11 | branches: 12 | - master 13 | 14 | jobs: 15 | lint: 16 | runs-on: ubuntu-latest 17 | timeout-minutes: 10 18 | steps: 19 | - uses: actions/checkout@v5 20 | 21 | - name: Install the latest version of uv 22 | uses: astral-sh/setup-uv@v7 23 | with: 24 | python-version: "3.9" 25 | 26 | - name: Install libs 27 | run: uv sync 28 | 29 | - name: Lint files 30 | run: make lint 31 | 32 | tests: 33 | needs: lint 34 | runs-on: ubuntu-latest 35 | timeout-minutes: 10 36 | strategy: 37 | matrix: 38 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14", "pypy3.9", "pypy3.10", "pypy3.11"] 39 | steps: 40 | - uses: actions/checkout@v5 41 | 42 | - name: Install the latest version of uv and set the python version 43 | uses: astral-sh/setup-uv@v7 44 | with: 45 | python-version: ${{ matrix.python-version }} 46 | 47 | - name: Test with python ${{ matrix.python-version }} 48 | run: make tests 49 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/yakuman/kokushi.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Optional 3 | 4 | from mahjong.hand_calculating.yaku import Yaku 5 | 6 | 7 | class KokushiMusou(Yaku): 8 | """ 9 | A hand composed of one of each of the terminals and honour tiles plus 10 | any tile that matches anything else in the hand. 11 | """ 12 | 13 | def __init__(self, yaku_id: Optional[int] = None) -> None: 14 | super(KokushiMusou, self).__init__(yaku_id) 15 | 16 | def set_attributes(self) -> None: 17 | self.tenhou_id = 47 18 | 19 | self.name = "Kokushi Musou" 20 | 21 | self.han_open = None 22 | self.han_closed = 13 23 | 24 | self.is_yakuman = True 25 | 26 | def is_condition_met(self, hand: Optional[Collection[Sequence[int]]], tiles_34: Sequence[int], *args) -> bool: 27 | if ( 28 | tiles_34[0] 29 | * tiles_34[8] 30 | * tiles_34[9] 31 | * tiles_34[17] 32 | * tiles_34[18] 33 | * tiles_34[26] 34 | * tiles_34[27] 35 | * tiles_34[28] 36 | * tiles_34[29] 37 | * tiles_34[30] 38 | * tiles_34[31] 39 | * tiles_34[32] 40 | * tiles_34[33] 41 | == 2 42 | ): 43 | return True 44 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/yakuman/daisuushi.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Optional 3 | 4 | from mahjong.constants import EAST, NORTH, SOUTH, WEST 5 | from mahjong.hand_calculating.yaku import Yaku 6 | from mahjong.utils import is_pon_or_kan 7 | 8 | 9 | class DaiSuushii(Yaku): 10 | """ 11 | The hand contains four sets of winds 12 | """ 13 | 14 | def __init__(self, yaku_id: Optional[int] = None) -> None: 15 | super(DaiSuushii, self).__init__(yaku_id) 16 | 17 | def set_attributes(self) -> None: 18 | self.tenhou_id = 49 19 | 20 | self.name = "Dai Suushii" 21 | 22 | self.han_open = 26 23 | self.han_closed = 26 24 | 25 | self.is_yakuman = True 26 | 27 | def is_condition_met(self, hand: Collection[Sequence[int]], *args) -> bool: 28 | """ 29 | The hand contains four sets of winds 30 | :param hand: list of hand's sets 31 | :return: boolean 32 | """ 33 | pon_sets = [x for x in hand if is_pon_or_kan(x)] 34 | if len(pon_sets) != 4: 35 | return False 36 | 37 | count_wind_sets = 0 38 | winds = [EAST, SOUTH, WEST, NORTH] 39 | for item in pon_sets: 40 | if is_pon_or_kan(item) and item[0] in winds: 41 | count_wind_sets += 1 42 | 43 | return count_wind_sets == 4 44 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/yakuman/shosuushi.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Optional 3 | 4 | from mahjong.constants import EAST, NORTH, SOUTH, WEST 5 | from mahjong.hand_calculating.yaku import Yaku 6 | from mahjong.utils import is_pair, is_pon_or_kan 7 | 8 | 9 | class Shousuushii(Yaku): 10 | """ 11 | The hand contains three sets of winds and a pair of the remaining wind 12 | """ 13 | 14 | def __init__(self, yaku_id: Optional[int] = None) -> None: 15 | super(Shousuushii, self).__init__(yaku_id) 16 | 17 | def set_attributes(self) -> None: 18 | self.tenhou_id = 50 19 | 20 | self.name = "Shousuushii" 21 | 22 | self.han_open = 13 23 | self.han_closed = 13 24 | 25 | self.is_yakuman = True 26 | 27 | def is_condition_met(self, hand: Collection[Sequence[int]], *args) -> bool: 28 | pon_sets = [x for x in hand if is_pon_or_kan(x)] 29 | if len(pon_sets) < 3: 30 | return False 31 | 32 | count_of_wind_sets = 0 33 | wind_pair = 0 34 | winds = [EAST, SOUTH, WEST, NORTH] 35 | for item in hand: 36 | if is_pon_or_kan(item) and item[0] in winds: 37 | count_of_wind_sets += 1 38 | 39 | if is_pair(item) and item[0] in winds: 40 | wind_pair += 1 41 | 42 | return count_of_wind_sets == 3 and wind_pair == 1 43 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/chinitsu.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Optional 3 | 4 | from mahjong.constants import HONOR_INDICES 5 | from mahjong.hand_calculating.yaku import Yaku 6 | from mahjong.utils import is_man, is_pin, is_sou 7 | 8 | 9 | class Chinitsu(Yaku): 10 | """ 11 | The hand contains tiles only from a single suit 12 | """ 13 | 14 | def __init__(self, yaku_id: Optional[int] = None) -> None: 15 | super(Chinitsu, self).__init__(yaku_id) 16 | 17 | def set_attributes(self) -> None: 18 | self.tenhou_id = 35 19 | 20 | self.name = "Chinitsu" 21 | 22 | self.han_open = 5 23 | self.han_closed = 6 24 | 25 | self.is_yakuman = False 26 | 27 | def is_condition_met(self, hand: Collection[Sequence[int]], *args) -> bool: 28 | honor_sets = 0 29 | sou_sets = 0 30 | pin_sets = 0 31 | man_sets = 0 32 | for item in hand: 33 | if item[0] in HONOR_INDICES: 34 | honor_sets += 1 35 | 36 | if is_sou(item[0]): 37 | sou_sets += 1 38 | elif is_pin(item[0]): 39 | pin_sets += 1 40 | elif is_man(item[0]): 41 | man_sets += 1 42 | 43 | sets = [sou_sets, pin_sets, man_sets] 44 | only_one_suit = len([x for x in sets if x != 0]) == 1 45 | 46 | return only_one_suit and honor_sets == 0 47 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/honitsu.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Optional 3 | 4 | from mahjong.constants import HONOR_INDICES 5 | from mahjong.hand_calculating.yaku import Yaku 6 | from mahjong.utils import is_man, is_pin, is_sou 7 | 8 | 9 | class Honitsu(Yaku): 10 | """ 11 | The hand contains tiles from a single suit plus honours 12 | """ 13 | 14 | def __init__(self, yaku_id: Optional[int] = None) -> None: 15 | super(Honitsu, self).__init__(yaku_id) 16 | 17 | def set_attributes(self) -> None: 18 | self.tenhou_id = 34 19 | self.name = "Honitsu" 20 | 21 | self.han_open = 2 22 | self.han_closed = 3 23 | 24 | self.is_yakuman = False 25 | 26 | def is_condition_met(self, hand: Collection[Sequence[int]], *args) -> bool: 27 | honor_sets = 0 28 | sou_sets = 0 29 | pin_sets = 0 30 | man_sets = 0 31 | for item in hand: 32 | if item[0] in HONOR_INDICES: 33 | honor_sets += 1 34 | 35 | if is_sou(item[0]): 36 | sou_sets += 1 37 | elif is_pin(item[0]): 38 | pin_sets += 1 39 | elif is_man(item[0]): 40 | man_sets += 1 41 | 42 | sets = [sou_sets, pin_sets, man_sets] 43 | only_one_suit = len([x for x in sets if x != 0]) == 1 44 | 45 | return only_one_suit and honor_sets != 0 46 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/junchan.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Optional 3 | 4 | from mahjong.constants import TERMINAL_INDICES 5 | from mahjong.hand_calculating.yaku import Yaku 6 | from mahjong.utils import is_chi 7 | 8 | 9 | class Junchan(Yaku): 10 | """ 11 | Every set must have at least one terminal, and the pair must be of 12 | a terminal tile. Must contain at least one sequence (123 or 789). 13 | Honours are not allowed 14 | """ 15 | 16 | def __init__(self, yaku_id: Optional[int] = None) -> None: 17 | super(Junchan, self).__init__(yaku_id) 18 | 19 | def set_attributes(self) -> None: 20 | self.tenhou_id = 33 21 | 22 | self.name = "Junchan" 23 | 24 | self.han_open = 2 25 | self.han_closed = 3 26 | 27 | self.is_yakuman = False 28 | 29 | def is_condition_met(self, hand: Collection[Sequence[int]], *args) -> bool: 30 | def tile_in_indices(item_set: Sequence[int], indices_array: list[int]) -> bool: 31 | for x in item_set: 32 | if x in indices_array: 33 | return True 34 | return False 35 | 36 | terminal_sets = 0 37 | count_of_chi = 0 38 | for item in hand: 39 | if is_chi(item): 40 | count_of_chi += 1 41 | 42 | if tile_in_indices(item, TERMINAL_INDICES): 43 | terminal_sets += 1 44 | 45 | if count_of_chi == 0: 46 | return False 47 | 48 | return terminal_sets == 5 49 | -------------------------------------------------------------------------------- /mahjong/meld.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | from collections.abc import Sequence 3 | from typing import Optional 4 | 5 | from mahjong.tile import TilesConverter 6 | 7 | 8 | class Meld: 9 | CHI = "chi" 10 | PON = "pon" 11 | KAN = "kan" 12 | SHOUMINKAN = "shouminkan" 13 | NUKI = "nuki" 14 | 15 | who = None 16 | tiles = None 17 | type = None 18 | from_who = None 19 | called_tile = None 20 | # we need it to distinguish opened and closed kan 21 | opened = True 22 | 23 | def __init__( 24 | self, 25 | meld_type: Optional[str] = None, 26 | tiles: Optional[Sequence[int]] = None, 27 | opened: bool = True, 28 | called_tile: Optional[int] = None, 29 | who: Optional[int] = None, 30 | from_who: Optional[int] = None, 31 | ) -> None: 32 | self.type = meld_type 33 | self.tiles = list(tiles) if tiles else [] 34 | self.opened = opened 35 | self.called_tile = called_tile 36 | self.who = who 37 | self.from_who = from_who 38 | 39 | def __str__(self) -> str: 40 | return "Type: {}, Tiles: {} {}".format(self.type, TilesConverter.to_one_line_string(self.tiles), self.tiles) 41 | 42 | # for calls in array 43 | def __repr__(self) -> str: 44 | return self.__str__() 45 | 46 | @property 47 | def tiles_34(self) -> list[int]: 48 | return [x // 4 for x in self.tiles] 49 | 50 | @property 51 | def CHANKAN(self) -> str: 52 | warnings.warn("Use .SHOUMINKAN attribute instead of .CHANKAN attribute", DeprecationWarning, stacklevel=2) 53 | return self.SHOUMINKAN 54 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/hand_response.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection 2 | from typing import Optional 3 | 4 | from mahjong.hand_calculating.yaku import Yaku 5 | 6 | 7 | class HandResponse: 8 | cost = None 9 | han = None 10 | fu = None 11 | fu_details = None 12 | yaku = None 13 | error = None 14 | is_open_hand = False 15 | 16 | def __init__( 17 | self, 18 | cost: Optional[dict] = None, 19 | han: Optional[int] = None, 20 | fu: Optional[int] = None, 21 | yaku: Optional[Collection[Yaku]] = None, 22 | error: Optional[str] = None, 23 | fu_details: Optional[dict] = None, 24 | is_open_hand: bool = False, 25 | ) -> None: 26 | """ 27 | :param cost: dict 28 | :param han: int 29 | :param fu: int 30 | :param yaku: list 31 | :param error: str 32 | :param fu_details: dict 33 | """ 34 | self.cost = cost 35 | self.han = han 36 | self.fu = fu 37 | self.error = error 38 | self.is_open_hand = is_open_hand # adding this field for yaku reporting 39 | 40 | if fu_details: 41 | self.fu_details = sorted(fu_details, key=lambda x: x["fu"], reverse=True) 42 | else: 43 | self.fu_details = None 44 | 45 | if yaku: 46 | self.yaku = sorted(yaku, key=lambda x: x.yaku_id) 47 | else: 48 | self.yaku = None 49 | 50 | def __str__(self) -> str: 51 | if self.error: 52 | return self.error 53 | else: 54 | return "{} han, {} fu".format(self.han, self.fu) 55 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | from collections.abc import Collection, Sequence 3 | from typing import Optional 4 | 5 | 6 | class Yaku: 7 | yaku_id: Optional[int] = None 8 | tenhou_id: Optional[int] = None 9 | name: Optional[str] = None 10 | han_open: Optional[int] = None 11 | han_closed: Optional[int] = None 12 | is_yakuman: Optional[bool] = None 13 | 14 | def __init__(self, yaku_id: Optional[int] = None) -> None: 15 | self.tenhou_id = None 16 | self.yaku_id = yaku_id 17 | 18 | self.set_attributes() 19 | 20 | def __str__(self) -> str: 21 | return self.name 22 | 23 | def __repr__(self) -> str: 24 | # for calls in array 25 | return self.__str__() 26 | 27 | def is_condition_met(self, hand: Collection[Sequence[int]], *args) -> bool: 28 | """ 29 | Is this yaku exists in the hand? 30 | :param: hand 31 | :param: args: some yaku requires additional attributes 32 | :return: boolean 33 | """ 34 | raise NotImplementedError 35 | 36 | def set_attributes(self) -> None: 37 | """ 38 | Set id, name, han related to the yaku 39 | """ 40 | raise NotImplementedError 41 | 42 | @property 43 | def english(self) -> str: 44 | warnings.warn("Use .name attribute instead of .english attribute", DeprecationWarning, stacklevel=2) 45 | return self.name 46 | 47 | @property 48 | def japanese(self) -> str: 49 | warnings.warn("Use .name attribute instead of .japanese attribute", DeprecationWarning, stacklevel=2) 50 | return self.name 51 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/yakuman/__init__.py: -------------------------------------------------------------------------------- 1 | from mahjong.hand_calculating.yaku_list.yakuman.chiihou import Chiihou 2 | from mahjong.hand_calculating.yaku_list.yakuman.chinroto import Chinroutou 3 | from mahjong.hand_calculating.yaku_list.yakuman.chuuren_poutou import ChuurenPoutou 4 | from mahjong.hand_calculating.yaku_list.yakuman.daburu_chuuren_poutou import DaburuChuurenPoutou 5 | from mahjong.hand_calculating.yaku_list.yakuman.daburu_kokushi import DaburuKokushiMusou 6 | from mahjong.hand_calculating.yaku_list.yakuman.daichisei import Daichisei 7 | from mahjong.hand_calculating.yaku_list.yakuman.daisangen import Daisangen 8 | from mahjong.hand_calculating.yaku_list.yakuman.daisharin import Daisharin 9 | from mahjong.hand_calculating.yaku_list.yakuman.daisuushi import DaiSuushii 10 | from mahjong.hand_calculating.yaku_list.yakuman.kokushi import KokushiMusou 11 | from mahjong.hand_calculating.yaku_list.yakuman.paarenchan import Paarenchan 12 | from mahjong.hand_calculating.yaku_list.yakuman.renhou_yakuman import RenhouYakuman 13 | from mahjong.hand_calculating.yaku_list.yakuman.ryuisou import Ryuuiisou 14 | from mahjong.hand_calculating.yaku_list.yakuman.sashikomi import Sashikomi 15 | from mahjong.hand_calculating.yaku_list.yakuman.shosuushi import Shousuushii 16 | from mahjong.hand_calculating.yaku_list.yakuman.suuankou import Suuankou 17 | from mahjong.hand_calculating.yaku_list.yakuman.suuankou_tanki import SuuankouTanki 18 | from mahjong.hand_calculating.yaku_list.yakuman.suukantsu import Suukantsu 19 | from mahjong.hand_calculating.yaku_list.yakuman.tenhou import Tenhou 20 | from mahjong.hand_calculating.yaku_list.yakuman.tsuisou import Tsuuiisou 21 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/chantai.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Optional 3 | 4 | from mahjong.constants import HONOR_INDICES, TERMINAL_INDICES 5 | from mahjong.hand_calculating.yaku import Yaku 6 | from mahjong.utils import is_chi 7 | 8 | 9 | class Chantai(Yaku): 10 | """ 11 | Every set must have at least one terminal or honour tile, and the pair must be of 12 | a terminal or honour tile. Must contain at least one sequence (123 or 789) 13 | """ 14 | 15 | def __init__(self, yaku_id: Optional[int] = None) -> None: 16 | super(Chantai, self).__init__(yaku_id) 17 | 18 | def set_attributes(self) -> None: 19 | self.tenhou_id = 23 20 | 21 | self.name = "Chantai" 22 | 23 | self.han_open = 1 24 | self.han_closed = 2 25 | 26 | self.is_yakuman = False 27 | 28 | def is_condition_met(self, hand: Collection[Sequence[int]], *args) -> bool: 29 | def tile_in_indices(item_set: Sequence[int], indices_array: list[int]) -> bool: 30 | for x in item_set: 31 | if x in indices_array: 32 | return True 33 | return False 34 | 35 | honor_sets = 0 36 | terminal_sets = 0 37 | count_of_chi = 0 38 | for item in hand: 39 | if is_chi(item): 40 | count_of_chi += 1 41 | 42 | if tile_in_indices(item, TERMINAL_INDICES): 43 | terminal_sets += 1 44 | 45 | if tile_in_indices(item, HONOR_INDICES): 46 | honor_sets += 1 47 | 48 | if count_of_chi == 0: 49 | return False 50 | 51 | return terminal_sets + honor_sets == 5 and terminal_sets != 0 and honor_sets != 0 52 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/sanshoku.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Optional 3 | 4 | from mahjong.hand_calculating.yaku import Yaku 5 | from mahjong.utils import is_chi, is_man, is_pin, is_sou, simplify 6 | 7 | 8 | class Sanshoku(Yaku): 9 | """ 10 | The same chi in three suits 11 | """ 12 | 13 | def __init__(self, yaku_id: Optional[int] = None) -> None: 14 | super(Sanshoku, self).__init__(yaku_id) 15 | 16 | def set_attributes(self) -> None: 17 | self.tenhou_id = 25 18 | 19 | self.name = "Sanshoku Doujun" 20 | 21 | self.han_open = 1 22 | self.han_closed = 2 23 | 24 | self.is_yakuman = False 25 | 26 | def is_condition_met(self, hand: Collection[Sequence[int]], *args) -> bool: 27 | chi_sets = [i for i in hand if is_chi(i)] 28 | if len(chi_sets) < 3: 29 | return False 30 | 31 | sou_chi = [] 32 | pin_chi = [] 33 | man_chi = [] 34 | for item in chi_sets: 35 | if is_sou(item[0]): 36 | sou_chi.append(item) 37 | elif is_pin(item[0]): 38 | pin_chi.append(item) 39 | elif is_man(item[0]): 40 | man_chi.append(item) 41 | 42 | for sou_item in sou_chi: 43 | for pin_item in pin_chi: 44 | for man_item in man_chi: 45 | # cast tile indices to 0..8 representation 46 | sou_item = [simplify(x) for x in sou_item] 47 | pin_item = [simplify(x) for x in pin_item] 48 | man_item = [simplify(x) for x in man_item] 49 | if sou_item == pin_item == man_item: 50 | return True 51 | return False 52 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/ittsu.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Optional 3 | 4 | from mahjong.hand_calculating.yaku import Yaku 5 | from mahjong.utils import is_chi, is_man, is_pin, is_sou, simplify 6 | 7 | 8 | class Ittsu(Yaku): 9 | """ 10 | Three sets of same suit: 1-2-3, 4-5-6, 7-8-9 11 | """ 12 | 13 | def __init__(self, yaku_id: Optional[int] = None) -> None: 14 | super(Ittsu, self).__init__(yaku_id) 15 | 16 | def set_attributes(self) -> None: 17 | self.tenhou_id = 24 18 | 19 | self.name = "Ittsu" 20 | 21 | self.han_open = 1 22 | self.han_closed = 2 23 | 24 | self.is_yakuman = False 25 | 26 | def is_condition_met(self, hand: Collection[Sequence[int]], *args) -> bool: 27 | chi_sets = [i for i in hand if is_chi(i)] 28 | if len(chi_sets) < 3: 29 | return False 30 | 31 | sou_chi = [] 32 | pin_chi = [] 33 | man_chi = [] 34 | for item in chi_sets: 35 | if is_sou(item[0]): 36 | sou_chi.append(item) 37 | elif is_pin(item[0]): 38 | pin_chi.append(item) 39 | elif is_man(item[0]): 40 | man_chi.append(item) 41 | 42 | sets = [sou_chi, pin_chi, man_chi] 43 | 44 | for suit_item in sets: 45 | if len(suit_item) < 3: 46 | continue 47 | 48 | casted_sets = [] 49 | 50 | for set_item in suit_item: 51 | # cast tiles indices to 0..8 representation 52 | casted_sets.append([simplify(set_item[0]), simplify(set_item[1]), simplify(set_item[2])]) 53 | 54 | if [0, 1, 2] in casted_sets and [3, 4, 5] in casted_sets and [6, 7, 8] in casted_sets: 55 | return True 56 | 57 | return False 58 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/sanshoku_douko.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Optional 3 | 4 | from mahjong.hand_calculating.yaku import Yaku 5 | from mahjong.utils import is_man, is_pin, is_pon_or_kan, is_sou, simplify 6 | 7 | 8 | class SanshokuDoukou(Yaku): 9 | """ 10 | Three pon sets consisting of the same numbers in all three suits 11 | """ 12 | 13 | def __init__(self, yaku_id: Optional[int] = None) -> None: 14 | super(SanshokuDoukou, self).__init__(yaku_id) 15 | 16 | def set_attributes(self) -> None: 17 | self.tenhou_id = 26 18 | 19 | self.name = "Sanshoku Doukou" 20 | 21 | self.han_open = 2 22 | self.han_closed = 2 23 | 24 | self.is_yakuman = False 25 | 26 | def is_condition_met(self, hand: Collection[Sequence[int]], *args) -> bool: 27 | pon_sets = [i for i in hand if is_pon_or_kan(i)] 28 | if len(pon_sets) < 3: 29 | return False 30 | 31 | sou_pon: list[Collection[int]] = [] 32 | pin_pon: list[Collection[int]] = [] 33 | man_pon: list[Collection[int]] = [] 34 | for item in pon_sets: 35 | if is_sou(item[0]): 36 | sou_pon.append(item) 37 | elif is_pin(item[0]): 38 | pin_pon.append(item) 39 | elif is_man(item[0]): 40 | man_pon.append(item) 41 | 42 | for sou_item in sou_pon: 43 | for pin_item in pin_pon: 44 | for man_item in man_pon: 45 | # cast tile indices to 1..9 representation 46 | sou_item = {simplify(x) for x in sou_item} 47 | pin_item = {simplify(x) for x in pin_item} 48 | man_item = {simplify(x) for x in man_item} 49 | if sou_item == pin_item == man_item: 50 | return True 51 | return False 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mahjong 2 | 3 | [![PyPI](https://img.shields.io/pypi/v/mahjong.svg)](https://pypi.python.org/pypi/mahjong) 4 | [![License](https://img.shields.io/pypi/l/mahjong.svg)](https://pypi.python.org/pypi/mahjong) 5 | [![Supported Python versions](https://img.shields.io/pypi/pyversions/mahjong.svg)](https://pypi.python.org/pypi/mahjong) 6 | [![PyPI Downloads](https://img.shields.io/pypi/dm/mahjong.svg?label=PyPI%20downloads)](https://pypi.org/project/mahjong/) 7 | [![Linters and tests](https://github.com/MahjongRepository/mahjong/actions/workflows/lint_and_test.yml/badge.svg)](https://github.com/MahjongRepository/mahjong/actions/workflows/lint_and_test.yml) 8 | 9 | This library can calculate hand cost (han, fu with details, yaku, and scores) for riichi mahjong (Japanese version). 10 | 11 | Also calculating of shanten is supported. 12 | 13 | The code was validated on tenhou.net phoenix replays in total on **11,120,125 hands**. 14 | 15 | So, we can say that our hand calculator works the same way that tenhou.net hand calculation. 16 | 17 | ## How to install 18 | 19 | ```bash 20 | pip install mahjong 21 | ``` 22 | 23 | ## Supported rules and usage examples 24 | 25 | You can find usage examples and information about all supported rules variations in the [wiki](https://github.com/MahjongRepository/mahjong/wiki) 26 | 27 | ## Local development setup 28 | 29 | To set up the project locally for development: 30 | 31 | 1. Clone the repository: 32 | 33 | ```bash 34 | git clone https://github.com/MahjongRepository/mahjong.git 35 | cd mahjong 36 | ``` 37 | 38 | 2. Setup env using [uv](https://github.com/astral-sh/uv): 39 | 40 | ```bash 41 | uv sync 42 | ``` 43 | 44 | 3. Run tests to verify setup: 45 | 46 | ```bash 47 | make tests 48 | # Or directly: 49 | uv run pytest 50 | ``` 51 | 52 | 4. Run full checks before committing: 53 | 54 | ```bash 55 | make check # Runs format, lint, and tests 56 | ``` 57 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/sanankou.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Optional 3 | 4 | from mahjong.hand_calculating.yaku import Yaku 5 | from mahjong.meld import Meld 6 | from mahjong.utils import is_chi, is_pon_or_kan 7 | 8 | 9 | class Sanankou(Yaku): 10 | """ 11 | Three closed pon sets, the other sets need not to be closed 12 | """ 13 | 14 | def __init__(self, yaku_id: Optional[int] = None) -> None: 15 | super(Sanankou, self).__init__(yaku_id) 16 | 17 | def set_attributes(self) -> None: 18 | self.tenhou_id = 29 19 | 20 | self.name = "San Ankou" 21 | 22 | self.han_open = 2 23 | self.han_closed = 2 24 | 25 | self.is_yakuman = False 26 | 27 | def is_condition_met( 28 | self, 29 | hand: Collection[Sequence[int]], 30 | win_tile: int, 31 | melds: Collection[Meld], 32 | is_tsumo: bool, 33 | ) -> bool: 34 | """ 35 | Three closed pon sets, the other sets need not to be closed 36 | :param hand: list of hand's sets 37 | :param win_tile: 136 tiles format 38 | :param melds: list Meld objects 39 | :param is_tsumo: 40 | :return: true|false 41 | """ 42 | win_tile //= 4 43 | 44 | open_sets = [x.tiles_34 for x in melds if x.opened] 45 | 46 | chi_sets = [x for x in hand if (is_chi(x) and win_tile in x and x not in open_sets)] 47 | pon_sets = [x for x in hand if is_pon_or_kan(x)] 48 | 49 | closed_pon_sets = [] 50 | for item in pon_sets: 51 | if item in open_sets: 52 | continue 53 | 54 | # if we do the ron on syanpon wait our pon will be consider as open 55 | # and it is not 789999 set 56 | if win_tile in item and not is_tsumo and not len(chi_sets): 57 | continue 58 | 59 | closed_pon_sets.append(item) 60 | 61 | return len(closed_pon_sets) == 3 62 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "mahjong" 3 | version = "1.4.0" 4 | description = "Mahjong hands calculation" 5 | authors = [ 6 | { name = "Alexey Lisikhin", email = "alexey@nihisil.com" }, 7 | ] 8 | license = "MIT" 9 | license-files = ["LICENSE.txt"] 10 | readme = "README.md" 11 | requires-python = ">= 3.9" 12 | classifiers = [ 13 | "Development Status :: 5 - Production/Stable", 14 | "Environment :: Console", 15 | "Intended Audience :: Developers", 16 | "Programming Language :: Python", 17 | "Programming Language :: Python :: 3.9", 18 | "Programming Language :: Python :: 3.10", 19 | "Programming Language :: Python :: 3.11", 20 | "Programming Language :: Python :: 3.12", 21 | "Programming Language :: Python :: 3.13", 22 | "Programming Language :: Python :: 3.14", 23 | ] 24 | 25 | [project.urls] 26 | Homepage = "https://github.com/MahjongRepository/mahjong" 27 | 28 | [build-system] 29 | requires = ["setuptools >= 77.0.3"] 30 | build-backend = "setuptools.build_meta" 31 | 32 | [tool.setuptools] 33 | packages = [ 34 | "mahjong", 35 | "mahjong.hand_calculating", 36 | "mahjong.hand_calculating.yaku_list", 37 | "mahjong.hand_calculating.yaku_list.yakuman", 38 | ] 39 | 40 | [tool.setuptools.package-data] 41 | mahjong = ["py.typed"] 42 | 43 | [dependency-groups] 44 | dev = [ 45 | { include-group = "lint" }, 46 | { include-group = "test" }, 47 | ] 48 | lint = [ 49 | "ruff>=0.11.6,<0.12", 50 | ] 51 | test = [ 52 | "pytest>=8.3.5,<9", 53 | "pytest-cov>=6.1.1,<7", 54 | ] 55 | 56 | [tool.ruff] 57 | target-version = "py39" 58 | line-length = 120 59 | extend-exclude = [ 60 | "build", 61 | 62 | # Project related excludes 63 | "migrations", 64 | ] 65 | 66 | [tool.ruff.lint] 67 | select = ["ANN", "B", "C", "E", "F", "I", "W"] 68 | ignore = ["ANN002", "ANN003", "E203", "E266", "E501", "C901"] 69 | 70 | [tool.ruff.lint.per-file-ignores] 71 | "__init__.py" = ["F401"] 72 | 73 | [tool.pytest.ini_options] 74 | python_files = "tests_*.py" 75 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/yakuman/chuuren_poutou.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from itertools import chain 3 | from typing import Optional 4 | 5 | from mahjong.hand_calculating.yaku import Yaku 6 | from mahjong.utils import is_man, is_pin, is_sou, simplify 7 | 8 | 9 | class ChuurenPoutou(Yaku): 10 | """ 11 | The hand contains 1-1-1-2-3-4-5-6-7-8-9-9-9 of one suit, plus any other tile of the same suit. 12 | """ 13 | 14 | def __init__(self, yaku_id: Optional[int] = None) -> None: 15 | super(ChuurenPoutou, self).__init__(yaku_id) 16 | 17 | def set_attributes(self) -> None: 18 | self.tenhou_id = 45 19 | 20 | self.name = "Chuuren Poutou" 21 | 22 | self.han_open = None 23 | self.han_closed = 13 24 | 25 | self.is_yakuman = True 26 | 27 | def is_condition_met(self, hand: Collection[Sequence[int]], *args) -> bool: 28 | sou_sets = 0 29 | pin_sets = 0 30 | man_sets = 0 31 | honor_sets = 0 32 | for item in hand: 33 | if is_sou(item[0]): 34 | sou_sets += 1 35 | elif is_pin(item[0]): 36 | pin_sets += 1 37 | elif is_man(item[0]): 38 | man_sets += 1 39 | else: 40 | honor_sets += 1 41 | 42 | sets = [sou_sets, pin_sets, man_sets] 43 | only_one_suit = len([x for x in sets if x != 0]) == 1 44 | if not only_one_suit or honor_sets > 0: 45 | return False 46 | 47 | indices = list(chain.from_iterable(hand)) 48 | # cast tile indices to 0..8 representation 49 | indices = [simplify(x) for x in indices] 50 | 51 | # 1-1-1 52 | if len([x for x in indices if x == 0]) < 3: 53 | return False 54 | 55 | # 9-9-9 56 | if len([x for x in indices if x == 8]) < 3: 57 | return False 58 | 59 | # 1-2-3-4-5-6-7-8-9 and one tile to any of them 60 | indices.remove(0) 61 | indices.remove(0) 62 | indices.remove(8) 63 | indices.remove(8) 64 | for x in range(0, 9): 65 | if x in indices: 66 | indices.remove(x) 67 | 68 | if len(indices) == 1: 69 | return True 70 | 71 | return False 72 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/yakuman/daisharin.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from itertools import chain 3 | from typing import Optional 4 | 5 | from mahjong.hand_calculating.yaku import Yaku 6 | from mahjong.utils import is_man, is_pin, is_sou, simplify 7 | 8 | 9 | class Daisharin(Yaku): 10 | """ 11 | Optional yakuman 12 | 13 | The hand contains 2-2 3-3 4-4 5-5 6-6 7-7 8-8 of one pin suit 14 | 15 | Optionally can be of any suit 16 | """ 17 | 18 | def __init__(self, yaku_id: Optional[int] = None) -> None: 19 | super(Daisharin, self).__init__(yaku_id) 20 | 21 | def set_attributes(self) -> None: 22 | self.set_pin() 23 | 24 | self.han_open = None 25 | self.han_closed = 13 26 | 27 | self.is_yakuman = True 28 | 29 | def set_pin(self) -> None: 30 | self.name = "Daisharin" 31 | 32 | def set_man(self) -> None: 33 | self.name = "Daisuurin" 34 | 35 | def set_sou(self) -> None: 36 | self.name = "Daichikurin" 37 | 38 | def rename(self, hand: Sequence[Sequence[int]]) -> None: 39 | # rename this yakuman depending on tiles used 40 | if is_sou(hand[0][0]): 41 | self.set_sou() 42 | elif is_pin(hand[0][0]): 43 | self.set_pin() 44 | else: 45 | self.set_man() 46 | 47 | def is_condition_met(self, hand: Collection[Sequence[int]], allow_other_sets: bool, *args) -> bool: 48 | sou_sets = 0 49 | pin_sets = 0 50 | man_sets = 0 51 | honor_sets = 0 52 | for item in hand: 53 | if is_sou(item[0]): 54 | sou_sets += 1 55 | elif is_pin(item[0]): 56 | pin_sets += 1 57 | elif is_man(item[0]): 58 | man_sets += 1 59 | else: 60 | honor_sets += 1 61 | 62 | sets = [sou_sets, pin_sets, man_sets] 63 | only_one_suit = len([x for x in sets if x != 0]) == 1 64 | if not only_one_suit or honor_sets > 0: 65 | return False 66 | 67 | if not allow_other_sets and pin_sets == 0: 68 | # if we are not allowing other sets than pins 69 | return False 70 | 71 | indices = list(chain.from_iterable(hand)) 72 | # cast tile indices to 0..8 representation 73 | indices = [simplify(x) for x in indices] 74 | 75 | # check for pairs 76 | for x in range(1, 8): 77 | if len([y for y in indices if y == x]) != 2: 78 | return False 79 | 80 | return True 81 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_list/__init__.py: -------------------------------------------------------------------------------- 1 | from mahjong.hand_calculating.yaku_list.aka_dora import AkaDora 2 | from mahjong.hand_calculating.yaku_list.chankan import Chankan 3 | from mahjong.hand_calculating.yaku_list.chantai import Chantai 4 | from mahjong.hand_calculating.yaku_list.chiitoitsu import Chiitoitsu 5 | from mahjong.hand_calculating.yaku_list.chinitsu import Chinitsu 6 | from mahjong.hand_calculating.yaku_list.chun import Chun 7 | from mahjong.hand_calculating.yaku_list.daburu_open_riichi import DaburuOpenRiichi 8 | from mahjong.hand_calculating.yaku_list.daburu_riichi import DaburuRiichi 9 | from mahjong.hand_calculating.yaku_list.dora import Dora 10 | from mahjong.hand_calculating.yaku_list.east import YakuhaiEast 11 | from mahjong.hand_calculating.yaku_list.haitei import Haitei 12 | from mahjong.hand_calculating.yaku_list.haku import Haku 13 | from mahjong.hand_calculating.yaku_list.hatsu import Hatsu 14 | from mahjong.hand_calculating.yaku_list.honitsu import Honitsu 15 | from mahjong.hand_calculating.yaku_list.honroto import Honroto 16 | from mahjong.hand_calculating.yaku_list.houtei import Houtei 17 | from mahjong.hand_calculating.yaku_list.iipeiko import Iipeiko 18 | from mahjong.hand_calculating.yaku_list.ippatsu import Ippatsu 19 | from mahjong.hand_calculating.yaku_list.ittsu import Ittsu 20 | from mahjong.hand_calculating.yaku_list.junchan import Junchan 21 | from mahjong.hand_calculating.yaku_list.nagashi_mangan import NagashiMangan 22 | from mahjong.hand_calculating.yaku_list.north import YakuhaiNorth 23 | from mahjong.hand_calculating.yaku_list.open_riichi import OpenRiichi 24 | from mahjong.hand_calculating.yaku_list.pinfu import Pinfu 25 | from mahjong.hand_calculating.yaku_list.renhou import Renhou 26 | from mahjong.hand_calculating.yaku_list.riichi import Riichi 27 | from mahjong.hand_calculating.yaku_list.rinshan import Rinshan 28 | from mahjong.hand_calculating.yaku_list.ryanpeiko import Ryanpeikou 29 | from mahjong.hand_calculating.yaku_list.sanankou import Sanankou 30 | from mahjong.hand_calculating.yaku_list.sankantsu import SanKantsu 31 | from mahjong.hand_calculating.yaku_list.sanshoku import Sanshoku 32 | from mahjong.hand_calculating.yaku_list.sanshoku_douko import SanshokuDoukou 33 | from mahjong.hand_calculating.yaku_list.shosangen import Shosangen 34 | from mahjong.hand_calculating.yaku_list.south import YakuhaiSouth 35 | from mahjong.hand_calculating.yaku_list.tanyao import Tanyao 36 | from mahjong.hand_calculating.yaku_list.toitoi import Toitoi 37 | from mahjong.hand_calculating.yaku_list.tsumo import Tsumo 38 | from mahjong.hand_calculating.yaku_list.west import YakuhaiWest 39 | from mahjong.hand_calculating.yaku_list.yakuhai_place import YakuhaiOfPlace 40 | from mahjong.hand_calculating.yaku_list.yakuhai_round import YakuhaiOfRound 41 | -------------------------------------------------------------------------------- /tests/tests_agari.py: -------------------------------------------------------------------------------- 1 | from mahjong.agari import Agari 2 | from mahjong.tile import TilesConverter 3 | from tests.utils_for_tests import _string_to_open_34_set 4 | 5 | 6 | def test_is_agari() -> None: 7 | agari = Agari() 8 | 9 | tiles = TilesConverter.string_to_34_array(sou="123456789", pin="123", man="33") 10 | assert agari.is_agari(tiles) 11 | 12 | tiles = TilesConverter.string_to_34_array(sou="123456789", pin="11123") 13 | assert agari.is_agari(tiles) 14 | 15 | tiles = TilesConverter.string_to_34_array(sou="123456789", honors="11777") 16 | assert agari.is_agari(tiles) 17 | 18 | tiles = TilesConverter.string_to_34_array(sou="12345556778899") 19 | assert agari.is_agari(tiles) 20 | 21 | tiles = TilesConverter.string_to_34_array(sou="11123456788999") 22 | assert agari.is_agari(tiles) 23 | 24 | tiles = TilesConverter.string_to_34_array(sou="233334", pin="789", man="345", honors="55") 25 | assert agari.is_agari(tiles) 26 | 27 | 28 | def test_is_not_agari() -> None: 29 | agari = Agari() 30 | 31 | tiles = TilesConverter.string_to_34_array(sou="123456789", pin="12345") 32 | assert not agari.is_agari(tiles) 33 | 34 | tiles = TilesConverter.string_to_34_array(sou="111222444", pin="11145") 35 | assert not agari.is_agari(tiles) 36 | 37 | tiles = TilesConverter.string_to_34_array(sou="11122233356888") 38 | assert not agari.is_agari(tiles) 39 | 40 | 41 | def test_is_chitoitsu_agari() -> None: 42 | agari = Agari() 43 | 44 | tiles = TilesConverter.string_to_34_array(sou="1133557799", pin="1199") 45 | assert agari.is_agari(tiles) 46 | 47 | tiles = TilesConverter.string_to_34_array(sou="2244", pin="1199", man="11", honors="2277") 48 | assert agari.is_agari(tiles) 49 | 50 | tiles = TilesConverter.string_to_34_array(man="11223344556677") 51 | assert agari.is_agari(tiles) 52 | 53 | 54 | def test_is_kokushi_musou_agari() -> None: 55 | agari = Agari() 56 | 57 | tiles = TilesConverter.string_to_34_array(sou="19", pin="19", man="199", honors="1234567") 58 | assert agari.is_agari(tiles) 59 | 60 | tiles = TilesConverter.string_to_34_array(sou="19", pin="19", man="19", honors="11234567") 61 | assert agari.is_agari(tiles) 62 | 63 | tiles = TilesConverter.string_to_34_array(sou="19", pin="19", man="19", honors="12345677") 64 | assert agari.is_agari(tiles) 65 | 66 | tiles = TilesConverter.string_to_34_array(sou="129", pin="19", man="19", honors="1234567") 67 | assert not agari.is_agari(tiles) 68 | 69 | tiles = TilesConverter.string_to_34_array(sou="19", pin="19", man="19", honors="11134567") 70 | assert not agari.is_agari(tiles) 71 | 72 | 73 | def test_is_agari_and_open_hand() -> None: 74 | agari = Agari() 75 | 76 | tiles = TilesConverter.string_to_34_array(sou="23455567", pin="222", man="345") 77 | melds = [ 78 | _string_to_open_34_set(man="345"), 79 | _string_to_open_34_set(sou="555"), 80 | ] 81 | assert not agari.is_agari(tiles, melds) 82 | -------------------------------------------------------------------------------- /tests/tests_tiles_converter.py: -------------------------------------------------------------------------------- 1 | from mahjong.constants import FIVE_RED_PIN 2 | from mahjong.tile import TilesConverter 3 | 4 | 5 | def test_convert_to_one_line_string() -> None: 6 | tiles = [0, 1, 34, 35, 36, 37, 70, 71, 72, 73, 106, 107, 108, 109, 133, 134] 7 | result = TilesConverter.to_one_line_string(tiles) 8 | assert "1199m1199p1199s1177z" == result 9 | 10 | 11 | def test_convert_to_one_line_string_with_aka_dora() -> None: 12 | tiles = [1, 16, 13, 46, 5, 13, 24, 34, 134, 124] 13 | result = TilesConverter.to_one_line_string(tiles, print_aka_dora=False) 14 | assert "1244579m3p57z" == result 15 | result = TilesConverter.to_one_line_string(tiles, print_aka_dora=True) 16 | assert "1244079m3p57z" == result 17 | 18 | 19 | def test_convert_to_34_array() -> None: 20 | tiles = [0, 34, 35, 36, 37, 70, 71, 72, 73, 106, 107, 108, 109, 134] 21 | result = TilesConverter.to_34_array(tiles) 22 | assert result[0] == 1 23 | assert result[8] == 2 24 | assert result[9] == 2 25 | assert result[17] == 2 26 | assert result[18] == 2 27 | assert result[26] == 2 28 | assert result[27] == 2 29 | assert result[33] == 1 30 | assert sum(result) == 14 31 | 32 | 33 | def test_convert_to_136_array() -> None: 34 | tiles = [0, 32, 33, 36, 37, 68, 69, 72, 73, 104, 105, 108, 109, 132] 35 | result = TilesConverter.to_34_array(tiles) 36 | result = TilesConverter.to_136_array(result) 37 | assert result == tiles 38 | 39 | 40 | def test_convert_string_to_136_array() -> None: 41 | tiles = TilesConverter.string_to_136_array(sou="19", pin="19", man="19", honors="1234567") 42 | 43 | assert [0, 32, 36, 68, 72, 104, 108, 112, 116, 120, 124, 128, 132] == tiles 44 | 45 | 46 | def test_find_34_tile_in_136_array() -> None: 47 | result = TilesConverter.find_34_tile_in_136_array(0, [3, 4, 5, 6]) 48 | assert result == 3 49 | 50 | result = TilesConverter.find_34_tile_in_136_array(33, [3, 4, 134, 135]) 51 | assert result == 134 52 | 53 | result = TilesConverter.find_34_tile_in_136_array(20, [3, 4, 134, 135]) 54 | assert result is None 55 | 56 | 57 | def test_convert_string_with_aka_dora_to_136_array() -> None: 58 | tiles = TilesConverter.string_to_136_array(man="22444", pin="333r67", sou="444", has_aka_dora=True) 59 | assert FIVE_RED_PIN in tiles 60 | 61 | 62 | def test_convert_string_with_aka_dora_as_zero_to_136_array() -> None: 63 | tiles = TilesConverter.string_to_136_array(man="22444", pin="333067", sou="444", has_aka_dora=True) 64 | assert FIVE_RED_PIN in tiles 65 | 66 | 67 | def test_one_line_string_to_136_array() -> None: 68 | initial_string = "789m456p555s11222z" 69 | tiles = TilesConverter.one_line_string_to_136_array(initial_string) 70 | assert len(tiles) == 14 71 | 72 | new_string = TilesConverter.to_one_line_string(tiles) 73 | assert initial_string == new_string 74 | 75 | 76 | def test_one_line_string_to_34_array() -> None: 77 | initial_string = "789m456p555s11222z" 78 | tiles = TilesConverter.one_line_string_to_34_array(initial_string) 79 | assert len(tiles) == 34 80 | 81 | tiles = TilesConverter.to_136_array(tiles) 82 | new_string = TilesConverter.to_one_line_string(tiles) 83 | assert initial_string == new_string 84 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Releases History 2 | ================ 3 | 4 | 1.1.11 (2020-10-28) 5 | ------------------- 6 | 7 | - Speed up performance a bit 8 | - Add support for Python 3.9 9 | 10 | 1.1.10 (2020-05-11) 11 | ------------------- 12 | 13 | - Add japanese yaku names 14 | - Fix an issue with not correct ryuuiisou detection 15 | - Allow to print aka dora in TilesConverter.to\_one\_line\_string() 16 | method ("0" symbol) 17 | - Add support for Python 3.8 18 | 19 | 1.1.9 (2019-07-29) 20 | ------------------ 21 | 22 | - Add TilesConverter.one\_line\_string\_to\_136\_array() and TilesConverter.one\_line\_string\_to\_34\_array() methods 23 | 24 | 1.1.8 (2019-07-25) 25 | ------------------ 26 | 27 | - Fix an issue with incorrect daburu chuuren poutou calculations 28 | - Allow passing '0' as a red five to tiles converter 29 | 30 | 1.1.7 (2019-04-09) 31 | ------------------ 32 | 33 | - Introduce OptionalRules hand configuration 34 | 35 | 1.1.6 (2019-02-10) 36 | ------------------ 37 | 38 | - Fix a bug when hatsu yaku was added to the hand instead of chun 39 | - Fix a bug where kokushi wasn't combined with tenhou/renhou/chihou 40 | - Add English names to all yaku 41 | - Add support of python 2.7 42 | - Add a way to pass aka dora to tile converter 43 | 44 | 1.1.5 (2018-09-04) 45 | ------------------ 46 | 47 | - Allow to disable chiitoitsu or kokushi in shanten calculator 48 | 49 | 1.1.4 (2018-08-31) 50 | ------------------ 51 | 52 | - Add is\_terminal() and is\_dora\_indicator\_for\_terminal() 53 | functions to the utils.py 54 | 55 | 1.1.3 (2018-08-22) 56 | ------------------ 57 | 58 | - Add is\_tile\_strictly\_isolated() function to the utils.py 59 | 60 | 1.1.2 (2017-10-14) 61 | ------------------ 62 | 63 | - Add settings for different kazoe yakuman calculation (it kan be an yakuman or a sanbaiman) 64 | - Support up to sextuple yakuman scores 65 | - Support kiriage mangan 66 | - Allow to disable +2 fu in open hand 67 | - Allow to disable tsumo pinfu (add 2 additional fu for hand like that) 68 | 69 | 1.1.1 (2017-10-07) 70 | ------------------ 71 | 72 | - Fix a bug with not correct agari state determination and closed kan in the hand 73 | 74 | 1.1.0 (2017-10-07) 75 | ------------------ 76 | 77 | Breaking changes: 78 | 79 | - Interface of hand calculator was changed. New interface will allow to easy support different game rules 80 | 81 | Additional fixes: 82 | 83 | - Refactor hand divider. Allow to pass melds objects instead of arrays 84 | - Add file with usage examples 85 | - Minor project refactoring 86 | 87 | 1.0.5 (2017-09-25) 88 | ------------------ 89 | 90 | - Improve installation script 91 | 92 | 1.0.4 (2017-09-25) 93 | ------------------ 94 | 95 | Bug fixes: 96 | 97 | - Fix refactoring regressions with kan sets and dora calculations 98 | - Fix regression with sankantsusuukantsu and called chankan 99 | - Closed kan can't be used in chuuren poutou 100 | - Fix yaku ids (some of them had incorrect numbers) 101 | 102 | Features: 103 | 104 | - Allow to disable double yakuman (like suuanko tanki) 105 | - Remove float calculations from scores and fu 106 | - Add travis build status 107 | - Add usage examples to the readme 108 | 109 | 1.0.3 (2017-09-23) 110 | ------------------ 111 | 112 | - Hand calculation code was moved from mahjong bot package 113 | 114 | - This library can calculate hand cost (han, fu with details, yaku and scores) for riichi mahjong (japanese version) 115 | -------------------------------------------------------------------------------- /tests/hand_calculating/tests_hand_dividing.py: -------------------------------------------------------------------------------- 1 | from mahjong.hand_calculating.divider import HandDivider 2 | from mahjong.hand_calculating.hand import HandCalculator 3 | from mahjong.meld import Meld 4 | from mahjong.tile import TilesConverter 5 | from tests.utils_for_tests import _make_meld, _string_to_136_tile 6 | 7 | 8 | def _string(hand: list[list[int]]) -> list[str]: 9 | results = [] 10 | for set_item in hand: 11 | results.append(TilesConverter.to_one_line_string([x * 4 for x in set_item])) 12 | return results 13 | 14 | 15 | def test_simple_hand_dividing() -> None: 16 | hand = HandDivider() 17 | 18 | tiles_34 = TilesConverter.string_to_34_array(man="234567", sou="23455", honors="777") 19 | result = hand.divide_hand(tiles_34) 20 | assert len(result) == 1 21 | assert _string(result[0]) == ["234m", "567m", "234s", "55s", "777z"] 22 | 23 | 24 | def test_second_simple_hand_dividing() -> None: 25 | hand = HandDivider() 26 | 27 | tiles_34 = TilesConverter.string_to_34_array(man="123", pin="123", sou="123", honors="11222") 28 | result = hand.divide_hand(tiles_34) 29 | assert len(result) == 1 30 | assert _string(result[0]) == ["123m", "123p", "123s", "11z", "222z"] 31 | 32 | 33 | def test_hand_with_pairs_dividing() -> None: 34 | hand = HandDivider() 35 | 36 | tiles_34 = TilesConverter.string_to_34_array(man="23444", pin="344556", sou="333") 37 | result = hand.divide_hand(tiles_34) 38 | assert len(result) == 1 39 | assert _string(result[0]) == ["234m", "44m", "345p", "456p", "333s"] 40 | 41 | 42 | def test_one_suit_hand_dividing() -> None: 43 | hand = HandDivider() 44 | 45 | tiles_34 = TilesConverter.string_to_34_array(man="11122233388899") 46 | result = hand.divide_hand(tiles_34) 47 | assert len(result) == 2 48 | assert _string(result[0]) == ["111m", "222m", "333m", "888m", "99m"] 49 | assert _string(result[1]) == ["123m", "123m", "123m", "888m", "99m"] 50 | 51 | 52 | def test_second_one_suit_hand_dividing() -> None: 53 | hand = HandDivider() 54 | 55 | tiles_34 = TilesConverter.string_to_34_array(sou="111123666789", honors="11") 56 | result = hand.divide_hand(tiles_34) 57 | assert len(result) == 1 58 | assert _string(result[0]) == ["111s", "123s", "666s", "789s", "11z"] 59 | 60 | 61 | def test_third_one_suit_hand_dividing() -> None: 62 | hand = HandDivider() 63 | 64 | tiles_34 = TilesConverter.string_to_34_array(pin="234777888999", honors="22") 65 | melds = [ 66 | _make_meld(Meld.CHI, pin="789"), 67 | _make_meld(Meld.CHI, pin="234"), 68 | ] 69 | result = hand.divide_hand(tiles_34, melds) 70 | assert len(result) == 1 71 | assert _string(result[0]) == ["234p", "789p", "789p", "789p", "22z"] 72 | 73 | 74 | def test_chitoitsu_like_hand_dividing() -> None: 75 | hand = HandDivider() 76 | 77 | tiles_34 = TilesConverter.string_to_34_array(man="112233", pin="99", sou="445566") 78 | result = hand.divide_hand(tiles_34) 79 | assert len(result) == 2 80 | assert _string(result[0]) == ["11m", "22m", "33m", "99p", "44s", "55s", "66s"] 81 | assert _string(result[1]) == ["123m", "123m", "99p", "456s", "456s"] 82 | 83 | 84 | def test_fix_not_correct_kan_handling() -> None: 85 | # Hand calculator crashed because it wasn't able to split hand 86 | 87 | hand = HandCalculator() 88 | 89 | tiles = TilesConverter.string_to_136_array(man="55666777", pin="111", honors="222") 90 | win_tile = _string_to_136_tile(man="5") 91 | melds = [ 92 | _make_meld(Meld.KAN, man="6666", is_open=False), 93 | _make_meld(Meld.PON, pin="111"), 94 | _make_meld(Meld.PON, man="777"), 95 | ] 96 | 97 | hand.estimate_hand_value(tiles, win_tile, melds=melds) 98 | -------------------------------------------------------------------------------- /doc/examples.py: -------------------------------------------------------------------------------- 1 | from mahjong.hand_calculating.hand import HandCalculator 2 | from mahjong.hand_calculating.hand_config import HandConfig, OptionalRules 3 | from mahjong.hand_calculating.hand_response import HandResponse 4 | from mahjong.meld import Meld 5 | from mahjong.shanten import Shanten 6 | from mahjong.tile import TilesConverter 7 | 8 | calculator = HandCalculator() 9 | 10 | 11 | # useful helper 12 | def print_hand_result(hand_result: HandResponse) -> None: 13 | print(hand_result.han, hand_result.fu) 14 | print(hand_result.cost["main"]) 15 | print(hand_result.yaku) 16 | for fu_item in hand_result.fu_details: 17 | print(fu_item) 18 | print("") 19 | 20 | 21 | #################################################################### 22 | # Tanyao hand by ron # 23 | #################################################################### 24 | 25 | 26 | # we had to use all 14 tiles in that array 27 | tiles = TilesConverter.string_to_136_array(man="22444", pin="333567", sou="444") 28 | win_tile = TilesConverter.string_to_136_array(sou="4")[0] 29 | 30 | result = calculator.estimate_hand_value(tiles, win_tile) 31 | print_hand_result(result) 32 | 33 | 34 | #################################################################### 35 | # Tanyao hand by tsumo # 36 | #################################################################### 37 | 38 | 39 | result = calculator.estimate_hand_value(tiles, win_tile, config=HandConfig(is_tsumo=True)) 40 | print_hand_result(result) 41 | 42 | 43 | #################################################################### 44 | # Add open set to hand # 45 | #################################################################### 46 | 47 | 48 | melds = [Meld(meld_type=Meld.PON, tiles=TilesConverter.string_to_136_array(man="444"))] 49 | 50 | result = calculator.estimate_hand_value( 51 | tiles, win_tile, melds=melds, config=HandConfig(options=OptionalRules(has_open_tanyao=True)) 52 | ) 53 | print_hand_result(result) 54 | 55 | 56 | #################################################################### 57 | # Shanten calculation # 58 | #################################################################### 59 | 60 | 61 | shanten = Shanten() 62 | tiles = TilesConverter.string_to_34_array(man="13569", pin="123459", sou="443") 63 | result = shanten.calculate_shanten(tiles) 64 | 65 | print(result) 66 | 67 | #################################################################### 68 | # Kazoe as a sanbaiman # 69 | #################################################################### 70 | 71 | 72 | tiles = TilesConverter.string_to_136_array(man="222244466677788") 73 | win_tile = TilesConverter.string_to_136_array(man="7")[0] 74 | melds = [Meld(Meld.KAN, TilesConverter.string_to_136_array(man="2222"), False)] 75 | 76 | dora_indicators = [ 77 | TilesConverter.string_to_136_array(man="1")[0], 78 | TilesConverter.string_to_136_array(man="1")[0], 79 | TilesConverter.string_to_136_array(man="1")[0], 80 | TilesConverter.string_to_136_array(man="1")[0], 81 | ] 82 | 83 | config = HandConfig(is_riichi=True, options=OptionalRules(kazoe_limit=HandConfig.KAZOE_SANBAIMAN)) 84 | result = calculator.estimate_hand_value(tiles, win_tile, melds, dora_indicators, config) 85 | print_hand_result(result) 86 | 87 | 88 | #################################################################### 89 | # Change the cost of yaku # 90 | #################################################################### 91 | 92 | 93 | config = HandConfig(is_renhou=True) 94 | # renhou as an yakuman - old style 95 | config.yaku.renhou.han_closed = 13 96 | 97 | tiles = TilesConverter.string_to_136_array(man="22444", pin="333567", sou="444") 98 | win_tile = TilesConverter.string_to_136_array(sou="4")[0] 99 | 100 | result = calculator.estimate_hand_value(tiles, win_tile, config=config) 101 | print_hand_result(result) 102 | -------------------------------------------------------------------------------- /tests/utils_for_tests.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from mahjong.hand_calculating.divider import HandDivider 4 | from mahjong.hand_calculating.hand_config import HandConfig, OptionalRules 5 | from mahjong.meld import Meld 6 | from mahjong.tile import TilesConverter 7 | 8 | 9 | def _string_to_open_34_set( 10 | sou: Optional[str] = "", 11 | pin: Optional[str] = "", 12 | man: Optional[str] = "", 13 | honors: Optional[str] = "", 14 | ) -> list[int]: 15 | open_set = TilesConverter.string_to_136_array(sou=sou, pin=pin, man=man, honors=honors) 16 | open_set[0] //= 4 17 | open_set[1] //= 4 18 | open_set[2] //= 4 19 | return open_set 20 | 21 | 22 | def _string_to_34_tile( 23 | sou: Optional[str] = "", 24 | pin: Optional[str] = "", 25 | man: Optional[str] = "", 26 | honors: Optional[str] = "", 27 | ) -> int: 28 | item = TilesConverter.string_to_136_array(sou=sou, pin=pin, man=man, honors=honors) 29 | item[0] //= 4 30 | return item[0] 31 | 32 | 33 | def _string_to_136_tile( 34 | sou: Optional[str] = "", 35 | pin: Optional[str] = "", 36 | man: Optional[str] = "", 37 | honors: Optional[str] = "", 38 | ) -> int: 39 | return TilesConverter.string_to_136_array(sou=sou, pin=pin, man=man, honors=honors)[0] 40 | 41 | 42 | def _hand(tiles: list[int], hand_index: int = 0) -> list[list[int]]: 43 | hand_divider = HandDivider() 44 | return hand_divider.divide_hand(tiles)[hand_index] 45 | 46 | 47 | def _make_meld( 48 | meld_type: str, 49 | is_open: bool = True, 50 | man: Optional[str] = "", 51 | pin: Optional[str] = "", 52 | sou: Optional[str] = "", 53 | honors: Optional[str] = "", 54 | ) -> Meld: 55 | tiles = TilesConverter.string_to_136_array(man=man, pin=pin, sou=sou, honors=honors) 56 | meld = Meld(meld_type=meld_type, tiles=tiles, opened=is_open, called_tile=tiles[0], who=0) 57 | return meld 58 | 59 | 60 | def _make_hand_config( 61 | is_tsumo: bool = False, 62 | is_riichi: bool = False, 63 | is_ippatsu: bool = False, 64 | is_rinshan: bool = False, 65 | is_chankan: bool = False, 66 | is_haitei: bool = False, 67 | is_houtei: bool = False, 68 | is_daburu_riichi: bool = False, 69 | is_nagashi_mangan: bool = False, 70 | is_tenhou: bool = False, 71 | is_renhou: bool = False, 72 | is_chiihou: bool = False, 73 | player_wind: Optional[int] = None, 74 | round_wind: Optional[int] = None, 75 | has_open_tanyao: bool = False, 76 | has_aka_dora: bool = False, 77 | disable_double_yakuman: bool = False, 78 | renhou_as_yakuman: bool = False, 79 | allow_daisharin: bool = False, 80 | allow_daisharin_other_suits: bool = False, 81 | is_open_riichi: bool = False, 82 | has_sashikomi_yakuman: bool = False, 83 | limit_to_sextuple_yakuman: bool = True, 84 | paarenchan_needs_yaku: bool = True, 85 | has_daichisei: bool = False, 86 | paarenchan: int = 0, 87 | ) -> HandConfig: 88 | options = OptionalRules( 89 | has_open_tanyao=has_open_tanyao, 90 | has_aka_dora=has_aka_dora, 91 | has_double_yakuman=not disable_double_yakuman, 92 | renhou_as_yakuman=renhou_as_yakuman, 93 | has_daisharin=allow_daisharin, 94 | has_daisharin_other_suits=allow_daisharin_other_suits, 95 | has_daichisei=has_daichisei, 96 | has_sashikomi_yakuman=has_sashikomi_yakuman, 97 | limit_to_sextuple_yakuman=limit_to_sextuple_yakuman, 98 | paarenchan_needs_yaku=paarenchan_needs_yaku, 99 | ) 100 | return HandConfig( 101 | is_tsumo=is_tsumo, 102 | is_riichi=is_riichi, 103 | is_ippatsu=is_ippatsu, 104 | is_rinshan=is_rinshan, 105 | is_chankan=is_chankan, 106 | is_haitei=is_haitei, 107 | is_houtei=is_houtei, 108 | is_daburu_riichi=is_daburu_riichi, 109 | is_nagashi_mangan=is_nagashi_mangan, 110 | is_tenhou=is_tenhou, 111 | is_renhou=is_renhou, 112 | is_chiihou=is_chiihou, 113 | player_wind=player_wind, 114 | round_wind=round_wind, 115 | is_open_riichi=is_open_riichi, 116 | paarenchan=paarenchan, 117 | options=options, 118 | ) 119 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/yaku_config.py: -------------------------------------------------------------------------------- 1 | from itertools import count 2 | 3 | from mahjong.hand_calculating.yaku_list import ( 4 | AkaDora, 5 | Chankan, 6 | Chantai, 7 | Chiitoitsu, 8 | Chinitsu, 9 | Chun, 10 | DaburuOpenRiichi, 11 | DaburuRiichi, 12 | Dora, 13 | Haitei, 14 | Haku, 15 | Hatsu, 16 | Honitsu, 17 | Honroto, 18 | Houtei, 19 | Iipeiko, 20 | Ippatsu, 21 | Ittsu, 22 | Junchan, 23 | NagashiMangan, 24 | OpenRiichi, 25 | Pinfu, 26 | Renhou, 27 | Riichi, 28 | Rinshan, 29 | Ryanpeikou, 30 | Sanankou, 31 | SanKantsu, 32 | Sanshoku, 33 | SanshokuDoukou, 34 | Shosangen, 35 | Tanyao, 36 | Toitoi, 37 | Tsumo, 38 | YakuhaiEast, 39 | YakuhaiNorth, 40 | YakuhaiOfPlace, 41 | YakuhaiOfRound, 42 | YakuhaiSouth, 43 | YakuhaiWest, 44 | ) 45 | from mahjong.hand_calculating.yaku_list.yakuman import ( 46 | Chiihou, 47 | Chinroutou, 48 | ChuurenPoutou, 49 | DaburuChuurenPoutou, 50 | DaburuKokushiMusou, 51 | Daichisei, 52 | Daisangen, 53 | Daisharin, 54 | DaiSuushii, 55 | KokushiMusou, 56 | Paarenchan, 57 | RenhouYakuman, 58 | Ryuuiisou, 59 | Sashikomi, 60 | Shousuushii, 61 | Suuankou, 62 | SuuankouTanki, 63 | Suukantsu, 64 | Tenhou, 65 | Tsuuiisou, 66 | ) 67 | 68 | 69 | class YakuConfig: 70 | def __init__(self) -> None: 71 | id = count(0) 72 | 73 | # Yaku situations 74 | self.tsumo = Tsumo(next(id)) 75 | self.riichi = Riichi(next(id)) 76 | self.open_riichi = OpenRiichi(next(id)) 77 | self.ippatsu = Ippatsu(next(id)) 78 | self.chankan = Chankan(next(id)) 79 | self.rinshan = Rinshan(next(id)) 80 | self.haitei = Haitei(next(id)) 81 | self.houtei = Houtei(next(id)) 82 | self.daburu_riichi = DaburuRiichi(next(id)) 83 | self.daburu_open_riichi = DaburuOpenRiichi(next(id)) 84 | self.nagashi_mangan = NagashiMangan(next(id)) 85 | self.renhou = Renhou(next(id)) 86 | 87 | # Yaku 1 Han 88 | self.pinfu = Pinfu(next(id)) 89 | self.tanyao = Tanyao(next(id)) 90 | self.iipeiko = Iipeiko(next(id)) 91 | self.haku = Haku(next(id)) 92 | self.hatsu = Hatsu(next(id)) 93 | self.chun = Chun(next(id)) 94 | 95 | self.east = YakuhaiEast(next(id)) 96 | self.south = YakuhaiSouth(next(id)) 97 | self.west = YakuhaiWest(next(id)) 98 | self.north = YakuhaiNorth(next(id)) 99 | self.yakuhai_place = YakuhaiOfPlace(next(id)) 100 | self.yakuhai_round = YakuhaiOfRound(next(id)) 101 | 102 | # Yaku 2 Hans 103 | self.sanshoku = Sanshoku(next(id)) 104 | self.ittsu = Ittsu(next(id)) 105 | self.chantai = Chantai(next(id)) 106 | self.honroto = Honroto(next(id)) 107 | self.toitoi = Toitoi(next(id)) 108 | self.sanankou = Sanankou(next(id)) 109 | self.sankantsu = SanKantsu(next(id)) 110 | self.sanshoku_douko = SanshokuDoukou(next(id)) 111 | self.chiitoitsu = Chiitoitsu(next(id)) 112 | self.shosangen = Shosangen(next(id)) 113 | 114 | # Yaku 3 Hans 115 | self.honitsu = Honitsu(next(id)) 116 | self.junchan = Junchan(next(id)) 117 | self.ryanpeiko = Ryanpeikou(next(id)) 118 | 119 | # Yaku 6 Hans 120 | self.chinitsu = Chinitsu(next(id)) 121 | 122 | # Yakuman list 123 | self.kokushi = KokushiMusou(next(id)) 124 | self.chuuren_poutou = ChuurenPoutou(next(id)) 125 | self.suuankou = Suuankou(next(id)) 126 | self.daisangen = Daisangen(next(id)) 127 | self.shosuushi = Shousuushii(next(id)) 128 | self.ryuisou = Ryuuiisou(next(id)) 129 | self.suukantsu = Suukantsu(next(id)) 130 | self.tsuisou = Tsuuiisou(next(id)) 131 | self.chinroto = Chinroutou(next(id)) 132 | self.daisharin = Daisharin(next(id)) 133 | self.daichisei = Daichisei(next(id)) 134 | 135 | # Double yakuman 136 | self.daisuushi = DaiSuushii(next(id)) 137 | self.daburu_kokushi = DaburuKokushiMusou(next(id)) 138 | self.suuankou_tanki = SuuankouTanki(next(id)) 139 | self.daburu_chuuren_poutou = DaburuChuurenPoutou(next(id)) 140 | 141 | # Yakuman situations 142 | self.tenhou = Tenhou(next(id)) 143 | self.chiihou = Chiihou(next(id)) 144 | self.renhou_yakuman = RenhouYakuman(next(id)) 145 | self.sashikomi = Sashikomi(next(id)) 146 | self.paarenchan = Paarenchan(next(id)) 147 | 148 | # Other 149 | self.dora = Dora(next(id)) 150 | self.aka_dora = AkaDora(next(id)) 151 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/hand_config.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from mahjong.constants import EAST 4 | from mahjong.hand_calculating.yaku_config import YakuConfig 5 | 6 | 7 | class HandConstants: 8 | # Hands over 26+ han don't count as double yakuman 9 | KAZOE_LIMITED = 0 10 | # Hands over 13+ is a sanbaiman 11 | KAZOE_SANBAIMAN = 1 12 | # 26+ han as double yakuman, 39+ han as triple yakuman, etc. 13 | KAZOE_NO_LIMIT = 2 14 | 15 | 16 | class OptionalRules: 17 | """ 18 | All the supported optional rules 19 | """ 20 | 21 | has_open_tanyao = False 22 | has_aka_dora = False 23 | has_double_yakuman = True 24 | # not implemented! tenhou does not support double yakuman for a single yaku 25 | kazoe_limit = HandConstants.KAZOE_LIMITED 26 | kiriage = False 27 | # if false, 1-20 hand will be possible 28 | fu_for_open_pinfu = True 29 | # if true, pinfu tsumo will be disabled 30 | fu_for_pinfu_tsumo = False 31 | renhou_as_yakuman = False 32 | has_daisharin = False 33 | has_daisharin_other_suits = False 34 | has_daichisei = False 35 | has_sashikomi_yakuman = False 36 | limit_to_sextuple_yakuman = True 37 | paarenchan_needs_yaku = True 38 | 39 | def __init__( 40 | self, 41 | has_open_tanyao: bool = False, 42 | has_aka_dora: bool = False, 43 | has_double_yakuman: bool = True, 44 | kazoe_limit: int = HandConstants.KAZOE_LIMITED, 45 | kiriage: bool = False, 46 | fu_for_open_pinfu: bool = True, 47 | fu_for_pinfu_tsumo: bool = False, 48 | renhou_as_yakuman: bool = False, 49 | has_daisharin: bool = False, 50 | has_daisharin_other_suits: bool = False, 51 | has_sashikomi_yakuman: bool = False, 52 | limit_to_sextuple_yakuman: bool = True, 53 | paarenchan_needs_yaku: bool = True, 54 | has_daichisei: bool = False, 55 | ) -> None: 56 | self.has_open_tanyao = has_open_tanyao 57 | self.has_aka_dora = has_aka_dora 58 | self.has_double_yakuman = has_double_yakuman 59 | self.kazoe_limit = kazoe_limit 60 | self.kiriage = kiriage 61 | self.fu_for_open_pinfu = fu_for_open_pinfu 62 | self.fu_for_pinfu_tsumo = fu_for_pinfu_tsumo 63 | self.renhou_as_yakuman = renhou_as_yakuman 64 | self.has_daisharin = has_daisharin or has_daisharin_other_suits 65 | self.has_daisharin_other_suits = has_daisharin_other_suits 66 | self.has_sashikomi_yakuman = has_sashikomi_yakuman 67 | self.limit_to_sextuple_yakuman = limit_to_sextuple_yakuman 68 | self.has_daichisei = has_daichisei 69 | self.paarenchan_needs_yaku = paarenchan_needs_yaku 70 | 71 | 72 | class HandConfig(HandConstants): 73 | """ 74 | Special class to pass various settings to the hand calculator object 75 | """ 76 | 77 | yaku = None 78 | options = None 79 | 80 | is_tsumo = False 81 | is_riichi = False 82 | is_ippatsu = False 83 | is_rinshan = False 84 | is_chankan = False 85 | is_haitei = False 86 | is_houtei = False 87 | is_daburu_riichi = False 88 | is_nagashi_mangan = False 89 | is_tenhou = False 90 | is_renhou = False 91 | is_chiihou = False 92 | is_open_riichi = False 93 | 94 | is_dealer = False 95 | player_wind = None 96 | round_wind = None 97 | # for optional yakuman paarenchan above 0 means that dealer has paarenchan possibility 98 | paarenchan = 0 99 | 100 | kyoutaku_number = 0 # 1000-point 101 | tsumi_number = 0 # 100-point 102 | 103 | def __init__( 104 | self, 105 | is_tsumo: bool = False, 106 | is_riichi: bool = False, 107 | is_ippatsu: bool = False, 108 | is_rinshan: bool = False, 109 | is_chankan: bool = False, 110 | is_haitei: bool = False, 111 | is_houtei: bool = False, 112 | is_daburu_riichi: bool = False, 113 | is_nagashi_mangan: bool = False, 114 | is_tenhou: bool = False, 115 | is_renhou: bool = False, 116 | is_chiihou: bool = False, 117 | is_open_riichi: bool = False, 118 | player_wind: Optional[int] = None, 119 | round_wind: Optional[int] = None, 120 | kyoutaku_number: int = 0, 121 | tsumi_number: int = 0, 122 | paarenchan: int = 0, 123 | options: Optional[OptionalRules] = None, 124 | ) -> None: 125 | self.yaku = YakuConfig() 126 | self.options = options or OptionalRules() 127 | 128 | self.is_tsumo = is_tsumo 129 | self.is_riichi = is_riichi 130 | self.is_ippatsu = is_ippatsu 131 | self.is_rinshan = is_rinshan 132 | self.is_chankan = is_chankan 133 | self.is_haitei = is_haitei 134 | self.is_houtei = is_houtei 135 | self.is_daburu_riichi = is_daburu_riichi 136 | self.is_nagashi_mangan = is_nagashi_mangan 137 | self.is_tenhou = is_tenhou 138 | self.is_renhou = is_renhou 139 | self.is_chiihou = is_chiihou 140 | self.is_open_riichi = is_open_riichi 141 | 142 | self.player_wind = player_wind 143 | self.round_wind = round_wind 144 | self.is_dealer = player_wind == EAST 145 | self.paarenchan = paarenchan 146 | 147 | self.kyoutaku_number = kyoutaku_number 148 | self.tsumi_number = tsumi_number 149 | -------------------------------------------------------------------------------- /tests/tests_utils.py: -------------------------------------------------------------------------------- 1 | from mahjong.tile import TilesConverter 2 | from mahjong.utils import find_isolated_tile_indices, is_tile_strictly_isolated 3 | from tests.utils_for_tests import _string_to_34_tile 4 | 5 | 6 | def test_find_isolated_tiles() -> None: 7 | hand_34 = TilesConverter.string_to_34_array(sou="1369", pin="15678", man="25", honors="124") 8 | isolated_tiles = find_isolated_tile_indices(hand_34) 9 | 10 | assert (_string_to_34_tile(sou="1") in isolated_tiles) is False 11 | assert (_string_to_34_tile(sou="2") in isolated_tiles) is False 12 | assert (_string_to_34_tile(sou="3") in isolated_tiles) is False 13 | assert (_string_to_34_tile(sou="4") in isolated_tiles) is False 14 | assert (_string_to_34_tile(sou="5") in isolated_tiles) is False 15 | assert (_string_to_34_tile(sou="6") in isolated_tiles) is False 16 | assert (_string_to_34_tile(sou="7") in isolated_tiles) is False 17 | assert (_string_to_34_tile(sou="8") in isolated_tiles) is False 18 | assert (_string_to_34_tile(sou="9") in isolated_tiles) is False 19 | assert (_string_to_34_tile(pin="1") in isolated_tiles) is False 20 | assert (_string_to_34_tile(pin="2") in isolated_tiles) is False 21 | assert (_string_to_34_tile(pin="3") in isolated_tiles) is True 22 | assert (_string_to_34_tile(pin="4") in isolated_tiles) is False 23 | assert (_string_to_34_tile(pin="5") in isolated_tiles) is False 24 | assert (_string_to_34_tile(pin="6") in isolated_tiles) is False 25 | assert (_string_to_34_tile(pin="7") in isolated_tiles) is False 26 | assert (_string_to_34_tile(pin="8") in isolated_tiles) is False 27 | assert (_string_to_34_tile(pin="9") in isolated_tiles) is False 28 | assert (_string_to_34_tile(man="1") in isolated_tiles) is False 29 | assert (_string_to_34_tile(man="2") in isolated_tiles) is False 30 | assert (_string_to_34_tile(man="3") in isolated_tiles) is False 31 | assert (_string_to_34_tile(man="4") in isolated_tiles) is False 32 | assert (_string_to_34_tile(man="5") in isolated_tiles) is False 33 | assert (_string_to_34_tile(man="6") in isolated_tiles) is False 34 | assert (_string_to_34_tile(man="7") in isolated_tiles) is True 35 | assert (_string_to_34_tile(man="8") in isolated_tiles) is True 36 | assert (_string_to_34_tile(man="9") in isolated_tiles) is True 37 | assert (_string_to_34_tile(honors="1") in isolated_tiles) is False 38 | assert (_string_to_34_tile(honors="2") in isolated_tiles) is False 39 | assert (_string_to_34_tile(honors="3") in isolated_tiles) is True 40 | assert (_string_to_34_tile(honors="4") in isolated_tiles) is False 41 | assert (_string_to_34_tile(honors="5") in isolated_tiles) is True 42 | assert (_string_to_34_tile(honors="6") in isolated_tiles) is True 43 | assert (_string_to_34_tile(honors="7") in isolated_tiles) is True 44 | 45 | 46 | def test_is_strictly_isolated_tile() -> None: 47 | hand_34 = TilesConverter.string_to_34_array(sou="1399", pin="1567", man="25", honors="1224") 48 | 49 | assert is_tile_strictly_isolated(hand_34, _string_to_34_tile(sou="1")) is False 50 | assert is_tile_strictly_isolated(hand_34, _string_to_34_tile(sou="2")) is False 51 | assert is_tile_strictly_isolated(hand_34, _string_to_34_tile(sou="3")) is False 52 | assert is_tile_strictly_isolated(hand_34, _string_to_34_tile(sou="4")) is False 53 | assert is_tile_strictly_isolated(hand_34, _string_to_34_tile(sou="5")) is False 54 | assert is_tile_strictly_isolated(hand_34, _string_to_34_tile(sou="6")) is True 55 | assert is_tile_strictly_isolated(hand_34, _string_to_34_tile(sou="7")) is False 56 | assert is_tile_strictly_isolated(hand_34, _string_to_34_tile(sou="8")) is False 57 | assert is_tile_strictly_isolated(hand_34, _string_to_34_tile(sou="9")) is False 58 | assert is_tile_strictly_isolated(hand_34, _string_to_34_tile(pin="1")) is True 59 | assert is_tile_strictly_isolated(hand_34, _string_to_34_tile(pin="2")) is False 60 | assert is_tile_strictly_isolated(hand_34, _string_to_34_tile(pin="3")) is False 61 | assert is_tile_strictly_isolated(hand_34, _string_to_34_tile(pin="4")) is False 62 | assert is_tile_strictly_isolated(hand_34, _string_to_34_tile(pin="5")) is False 63 | assert is_tile_strictly_isolated(hand_34, _string_to_34_tile(pin="6")) is False 64 | assert is_tile_strictly_isolated(hand_34, _string_to_34_tile(pin="7")) is False 65 | assert is_tile_strictly_isolated(hand_34, _string_to_34_tile(pin="8")) is False 66 | assert is_tile_strictly_isolated(hand_34, _string_to_34_tile(pin="9")) is False 67 | assert is_tile_strictly_isolated(hand_34, _string_to_34_tile(man="1")) is False 68 | assert is_tile_strictly_isolated(hand_34, _string_to_34_tile(man="2")) is True 69 | assert is_tile_strictly_isolated(hand_34, _string_to_34_tile(man="3")) is False 70 | assert is_tile_strictly_isolated(hand_34, _string_to_34_tile(man="4")) is False 71 | assert is_tile_strictly_isolated(hand_34, _string_to_34_tile(man="5")) is True 72 | assert is_tile_strictly_isolated(hand_34, _string_to_34_tile(man="6")) is False 73 | assert is_tile_strictly_isolated(hand_34, _string_to_34_tile(man="7")) is False 74 | assert is_tile_strictly_isolated(hand_34, _string_to_34_tile(man="8")) is True 75 | assert is_tile_strictly_isolated(hand_34, _string_to_34_tile(man="9")) is True 76 | assert is_tile_strictly_isolated(hand_34, _string_to_34_tile(honors="1")) is True 77 | assert is_tile_strictly_isolated(hand_34, _string_to_34_tile(honors="2")) is False 78 | assert is_tile_strictly_isolated(hand_34, _string_to_34_tile(honors="3")) is True 79 | assert is_tile_strictly_isolated(hand_34, _string_to_34_tile(honors="4")) is True 80 | assert is_tile_strictly_isolated(hand_34, _string_to_34_tile(honors="5")) is True 81 | assert is_tile_strictly_isolated(hand_34, _string_to_34_tile(honors="6")) is True 82 | assert is_tile_strictly_isolated(hand_34, _string_to_34_tile(honors="7")) is True 83 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/fu.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Any, Optional 3 | 4 | from mahjong.constants import HONOR_INDICES, TERMINAL_INDICES 5 | from mahjong.hand_calculating.hand_config import HandConfig 6 | from mahjong.meld import Meld 7 | from mahjong.utils import contains_terminals, is_pair, is_pon_or_kan, simplify 8 | 9 | 10 | class FuCalculator: 11 | BASE = "base" 12 | PENCHAN = "penchan" 13 | KANCHAN = "kanchan" 14 | VALUED_PAIR = "valued_pair" 15 | DOUBLE_VALUED_PAIR = "double_valued_pair" 16 | PAIR_WAIT = "pair_wait" 17 | TSUMO = "tsumo" 18 | HAND_WITHOUT_FU = "hand_without_fu" 19 | 20 | CLOSED_PON = "closed_pon" 21 | OPEN_PON = "open_pon" 22 | 23 | CLOSED_TERMINAL_PON = "closed_terminal_pon" 24 | OPEN_TERMINAL_PON = "open_terminal_pon" 25 | 26 | CLOSED_KAN = "closed_kan" 27 | OPEN_KAN = "open_kan" 28 | 29 | CLOSED_TERMINAL_KAN = "closed_terminal_kan" 30 | OPEN_TERMINAL_KAN = "open_terminal_kan" 31 | 32 | def calculate_fu( 33 | self, 34 | hand: Collection[Sequence[int]], 35 | win_tile: int, 36 | win_group: Sequence[int], 37 | config: HandConfig, 38 | valued_tiles: Optional[Sequence[int]] = None, 39 | melds: Optional[Collection[Meld]] = None, 40 | ) -> tuple[list[dict[str, Any]], int]: 41 | """ 42 | Calculate hand fu with explanations 43 | :param hand: 44 | :param win_tile: 136 tile format 45 | :param win_group: one set where win tile exists 46 | :param config: HandConfig object 47 | :param valued_tiles: dragons, player wind, round wind 48 | :param melds: opened sets 49 | :return: 50 | """ 51 | 52 | win_tile_34 = win_tile // 4 53 | 54 | if not valued_tiles: 55 | valued_tiles = [] 56 | 57 | if not melds: 58 | melds = [] 59 | 60 | fu_details = [] 61 | 62 | if len(hand) == 7: 63 | return [{"fu": 25, "reason": FuCalculator.BASE}], 25 64 | 65 | pair = [x for x in hand if is_pair(x)][0] 66 | pon_sets = [x for x in hand if is_pon_or_kan(x)] 67 | 68 | copied_opened_melds = [x.tiles_34 for x in melds if x.type == Meld.CHI] 69 | closed_chi_sets = [] 70 | for x in hand: 71 | if x not in copied_opened_melds: 72 | closed_chi_sets.append(list(x)) 73 | else: 74 | copied_opened_melds.remove(list(x)) 75 | 76 | is_open_hand = any(x.opened for x in melds) 77 | 78 | if win_group in closed_chi_sets: 79 | tile_index = simplify(win_tile_34) 80 | 81 | # penchan 82 | if contains_terminals(win_group): 83 | # 1-2-... wait 84 | if tile_index == 2 and win_group.index(win_tile_34) == 2: 85 | fu_details.append({"fu": 2, "reason": FuCalculator.PENCHAN}) 86 | # 8-9-... wait 87 | elif tile_index == 6 and win_group.index(win_tile_34) == 0: 88 | fu_details.append({"fu": 2, "reason": FuCalculator.PENCHAN}) 89 | 90 | # kanchan waiting 5-...-7 91 | if win_group.index(win_tile_34) == 1: 92 | fu_details.append({"fu": 2, "reason": FuCalculator.KANCHAN}) 93 | 94 | # valued pair 95 | count_of_valued_pairs = valued_tiles.count(pair[0]) 96 | if count_of_valued_pairs == 1: 97 | fu_details.append({"fu": 2, "reason": FuCalculator.VALUED_PAIR}) 98 | 99 | # east-east pair when you are on east gave double fu 100 | if count_of_valued_pairs == 2: 101 | fu_details.append({"fu": 4, "reason": FuCalculator.DOUBLE_VALUED_PAIR}) 102 | 103 | # pair wait 104 | if is_pair(win_group): 105 | fu_details.append({"fu": 2, "reason": FuCalculator.PAIR_WAIT}) 106 | 107 | for set_item in pon_sets: 108 | open_melds = [x for x in melds if set_item == x.tiles_34] 109 | open_meld = open_melds[0] if open_melds else None 110 | 111 | set_was_open = open_meld and open_meld.opened or False 112 | is_kan_set = (open_meld and (open_meld.type == Meld.KAN or open_meld.type == Meld.SHOUMINKAN)) or False 113 | is_honor = set_item[0] in TERMINAL_INDICES + HONOR_INDICES 114 | 115 | # we win by ron on the third pon tile, our pon will be count as open 116 | if not config.is_tsumo and set_item == win_group: 117 | set_was_open = True 118 | 119 | if is_honor: 120 | if is_kan_set: 121 | if set_was_open: 122 | fu_details.append({"fu": 16, "reason": FuCalculator.OPEN_TERMINAL_KAN}) 123 | else: 124 | fu_details.append({"fu": 32, "reason": FuCalculator.CLOSED_TERMINAL_KAN}) 125 | else: 126 | if set_was_open: 127 | fu_details.append({"fu": 4, "reason": FuCalculator.OPEN_TERMINAL_PON}) 128 | else: 129 | fu_details.append({"fu": 8, "reason": FuCalculator.CLOSED_TERMINAL_PON}) 130 | else: 131 | if is_kan_set: 132 | if set_was_open: 133 | fu_details.append({"fu": 8, "reason": FuCalculator.OPEN_KAN}) 134 | else: 135 | fu_details.append({"fu": 16, "reason": FuCalculator.CLOSED_KAN}) 136 | else: 137 | if set_was_open: 138 | fu_details.append({"fu": 2, "reason": FuCalculator.OPEN_PON}) 139 | else: 140 | fu_details.append({"fu": 4, "reason": FuCalculator.CLOSED_PON}) 141 | 142 | add_tsumo_fu = len(fu_details) > 0 or config.options.fu_for_pinfu_tsumo 143 | 144 | if config.is_tsumo and add_tsumo_fu: 145 | # 2 additional fu for tsumo (but not for pinfu) 146 | fu_details.append({"fu": 2, "reason": FuCalculator.TSUMO}) 147 | 148 | if is_open_hand and not len(fu_details) and config.options.fu_for_open_pinfu: 149 | # there is no 1-20 hands, so we had to add additional fu 150 | fu_details.append({"fu": 2, "reason": FuCalculator.HAND_WITHOUT_FU}) 151 | 152 | if is_open_hand or config.is_tsumo: 153 | fu_details.append({"fu": 20, "reason": FuCalculator.BASE}) 154 | else: 155 | fu_details.append({"fu": 30, "reason": FuCalculator.BASE}) 156 | 157 | return fu_details, self.round_fu(fu_details) 158 | 159 | def round_fu(self, fu_details: Collection[dict[str, Any]]) -> int: 160 | # 22 -> 30 and etc. 161 | fu = sum([x["fu"] for x in fu_details]) 162 | return (fu + 9) // 10 * 10 163 | -------------------------------------------------------------------------------- /mahjong/agari.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Optional 3 | 4 | from mahjong.utils import find_isolated_tile_indices 5 | 6 | 7 | class Agari: 8 | def is_agari(self, tiles_34: Sequence[int], open_sets_34: Optional[Collection[Sequence[int]]] = None) -> bool: 9 | """ 10 | Determine was it win or not 11 | :param tiles_34: 34 tiles format array 12 | :param open_sets_34: array of array of 34 tiles format 13 | :return: boolean 14 | """ 15 | # we will modify them later, so we need to use a copy 16 | tiles = list(tiles_34) 17 | 18 | # With open hand we need to remove open sets from hand and replace them with isolated pon sets 19 | # it will allow to determine agari state correctly 20 | if open_sets_34: 21 | isolated_tiles = find_isolated_tile_indices(tiles) 22 | for meld in open_sets_34: 23 | if not isolated_tiles: 24 | break 25 | 26 | isolated_tile = isolated_tiles.pop() 27 | 28 | tiles[meld[0]] -= 1 29 | tiles[meld[1]] -= 1 30 | tiles[meld[2]] -= 1 31 | # kan 32 | if len(meld) > 3: 33 | tiles[meld[3]] -= 1 34 | tiles[isolated_tile] = 3 35 | 36 | j = ( 37 | (1 << tiles[27]) 38 | | (1 << tiles[28]) 39 | | (1 << tiles[29]) 40 | | (1 << tiles[30]) 41 | | (1 << tiles[31]) 42 | | (1 << tiles[32]) 43 | | (1 << tiles[33]) 44 | ) 45 | 46 | if j >= 0x10: 47 | return False 48 | 49 | # 13 orphans 50 | if ((j & 3) == 2) and ( 51 | tiles[0] 52 | * tiles[8] 53 | * tiles[9] 54 | * tiles[17] 55 | * tiles[18] 56 | * tiles[26] 57 | * tiles[27] 58 | * tiles[28] 59 | * tiles[29] 60 | * tiles[30] 61 | * tiles[31] 62 | * tiles[32] 63 | * tiles[33] 64 | == 2 65 | ): 66 | return True 67 | 68 | # seven pairs 69 | if not (j & 10) and sum([tiles[i] == 2 for i in range(0, 34)]) == 7: 70 | return True 71 | 72 | if j & 2: 73 | return False 74 | 75 | n00 = tiles[0] + tiles[3] + tiles[6] 76 | n01 = tiles[1] + tiles[4] + tiles[7] 77 | n02 = tiles[2] + tiles[5] + tiles[8] 78 | 79 | n10 = tiles[9] + tiles[12] + tiles[15] 80 | n11 = tiles[10] + tiles[13] + tiles[16] 81 | n12 = tiles[11] + tiles[14] + tiles[17] 82 | 83 | n20 = tiles[18] + tiles[21] + tiles[24] 84 | n21 = tiles[19] + tiles[22] + tiles[25] 85 | n22 = tiles[20] + tiles[23] + tiles[26] 86 | 87 | n0 = (n00 + n01 + n02) % 3 88 | if n0 == 1: 89 | return False 90 | 91 | n1 = (n10 + n11 + n12) % 3 92 | if n1 == 1: 93 | return False 94 | 95 | n2 = (n20 + n21 + n22) % 3 96 | if n2 == 1: 97 | return False 98 | 99 | if (n0 == 2) + (n1 == 2) + (n2 == 2) + (tiles[27] == 2) + (tiles[28] == 2) + (tiles[29] == 2) + ( 100 | tiles[30] == 2 101 | ) + (tiles[31] == 2) + (tiles[32] == 2) + (tiles[33] == 2) != 1: 102 | return False 103 | 104 | nn0 = (n00 * 1 + n01 * 2) % 3 105 | m0 = self._to_meld(tiles, 0) 106 | nn1 = (n10 * 1 + n11 * 2) % 3 107 | m1 = self._to_meld(tiles, 9) 108 | nn2 = (n20 * 1 + n21 * 2) % 3 109 | m2 = self._to_meld(tiles, 18) 110 | 111 | if j & 4: 112 | return ( 113 | not (n0 | nn0 | n1 | nn1 | n2 | nn2) 114 | and self._is_mentsu(m0) 115 | and self._is_mentsu(m1) 116 | and self._is_mentsu(m2) 117 | ) 118 | 119 | if n0 == 2: 120 | return ( 121 | not (n1 | nn1 | n2 | nn2) 122 | and self._is_mentsu(m1) 123 | and self._is_mentsu(m2) 124 | and self._is_atama_mentsu(nn0, m0) 125 | ) 126 | 127 | if n1 == 2: 128 | return ( 129 | not (n2 | nn2 | n0 | nn0) 130 | and self._is_mentsu(m2) 131 | and self._is_mentsu(m0) 132 | and self._is_atama_mentsu(nn1, m1) 133 | ) 134 | 135 | if n2 == 2: 136 | return ( 137 | not (n0 | nn0 | n1 | nn1) 138 | and self._is_mentsu(m0) 139 | and self._is_mentsu(m1) 140 | and self._is_atama_mentsu(nn2, m2) 141 | ) 142 | 143 | return False 144 | 145 | def _is_mentsu(self, m: int) -> bool: 146 | a = m & 7 147 | b = 0 148 | c = 0 149 | if a == 1 or a == 4: 150 | b = c = 1 151 | elif a == 2: 152 | b = c = 2 153 | m >>= 3 154 | a = (m & 7) - b 155 | 156 | if a < 0: 157 | return False 158 | 159 | is_not_mentsu = False 160 | for _ in range(0, 6): 161 | b = c 162 | c = 0 163 | if a == 1 or a == 4: 164 | b += 1 165 | c += 1 166 | elif a == 2: 167 | b += 2 168 | c += 2 169 | m >>= 3 170 | a = (m & 7) - b 171 | if a < 0: 172 | is_not_mentsu = True 173 | break 174 | 175 | if is_not_mentsu: 176 | return False 177 | 178 | m >>= 3 179 | a = (m & 7) - c 180 | 181 | return a == 0 or a == 3 182 | 183 | def _is_atama_mentsu(self, nn: int, m: int) -> bool: 184 | if nn == 0: 185 | if (m & (7 << 6)) >= (2 << 6) and self._is_mentsu(m - (2 << 6)): 186 | return True 187 | if (m & (7 << 15)) >= (2 << 15) and self._is_mentsu(m - (2 << 15)): 188 | return True 189 | if (m & (7 << 24)) >= (2 << 24) and self._is_mentsu(m - (2 << 24)): 190 | return True 191 | elif nn == 1: 192 | if (m & (7 << 3)) >= (2 << 3) and self._is_mentsu(m - (2 << 3)): 193 | return True 194 | if (m & (7 << 12)) >= (2 << 12) and self._is_mentsu(m - (2 << 12)): 195 | return True 196 | if (m & (7 << 21)) >= (2 << 21) and self._is_mentsu(m - (2 << 21)): 197 | return True 198 | elif nn == 2: 199 | if (m & (7 << 0)) >= (2 << 0) and self._is_mentsu(m - (2 << 0)): 200 | return True 201 | if (m & (7 << 9)) >= (2 << 9) and self._is_mentsu(m - (2 << 9)): 202 | return True 203 | if (m & (7 << 18)) >= (2 << 18) and self._is_mentsu(m - (2 << 18)): 204 | return True 205 | return False 206 | 207 | def _to_meld(self, tiles: list[int], d: int) -> int: 208 | result = 0 209 | for i in range(0, 9): 210 | result |= tiles[d + i] << i * 3 211 | return result 212 | -------------------------------------------------------------------------------- /mahjong/utils.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | 3 | from mahjong.constants import CHUN, EAST, FIVE_RED_MAN, FIVE_RED_PIN, FIVE_RED_SOU, TERMINAL_INDICES 4 | 5 | 6 | def is_aka_dora(tile_136: int, aka_enabled: bool) -> bool: 7 | """ 8 | Check if tile is aka dora 9 | """ 10 | if not aka_enabled: 11 | return False 12 | 13 | if tile_136 in [FIVE_RED_MAN, FIVE_RED_PIN, FIVE_RED_SOU]: 14 | return True 15 | 16 | return False 17 | 18 | 19 | def plus_dora(tile_136: int, dora_indicators_136: Collection[int], add_aka_dora: bool = False) -> int: 20 | """ 21 | Calculate the number of dora for the tile 22 | """ 23 | tile_index = tile_136 // 4 24 | dora_count = 0 25 | 26 | if add_aka_dora and is_aka_dora(tile_136, aka_enabled=True): 27 | dora_count += 1 28 | 29 | for dora in dora_indicators_136: 30 | dora //= 4 31 | 32 | # sou, pin, man 33 | if tile_index < EAST: 34 | # with indicator 9, dora will be 1 35 | if dora == 8: 36 | dora = -1 37 | elif dora == 17: 38 | dora = 8 39 | elif dora == 26: 40 | dora = 17 41 | 42 | if tile_index == dora + 1: 43 | dora_count += 1 44 | else: 45 | if dora < EAST: 46 | continue 47 | 48 | dora -= 9 * 3 49 | tile_index_temp = tile_index - 9 * 3 50 | 51 | # dora indicator is north 52 | if dora == 3: 53 | dora = -1 54 | 55 | # dora indicator is hatsu 56 | if dora == 6: 57 | dora = 3 58 | 59 | if tile_index_temp == dora + 1: 60 | dora_count += 1 61 | 62 | return dora_count 63 | 64 | 65 | def is_chi(item: Sequence[int]) -> bool: 66 | """ 67 | :param item: array of tile 34 indices 68 | :return: boolean 69 | """ 70 | if len(item) != 3: 71 | return False 72 | 73 | return item[0] == item[1] - 1 == item[2] - 2 74 | 75 | 76 | def is_pon(item: Sequence[int]) -> bool: 77 | """ 78 | :param item: array of tile 34 indices 79 | :return: boolean 80 | """ 81 | if len(item) != 3: 82 | return False 83 | 84 | return item[0] == item[1] == item[2] 85 | 86 | 87 | def is_kan(item: Sequence[int]) -> bool: 88 | return len(item) == 4 89 | 90 | 91 | def is_pon_or_kan(item: Sequence[int]) -> bool: 92 | return is_pon(item) or is_kan(item) 93 | 94 | 95 | def is_pair(item: Sequence[int]) -> bool: 96 | """ 97 | :param item: array of tile 34 indices 98 | :return: boolean 99 | """ 100 | return len(item) == 2 101 | 102 | 103 | def is_man(tile: int) -> bool: 104 | """ 105 | :param tile: 34 tile format 106 | :return: boolean 107 | """ 108 | return tile <= 8 109 | 110 | 111 | def is_pin(tile: int) -> bool: 112 | """ 113 | :param tile: 34 tile format 114 | :return: boolean 115 | """ 116 | return 8 < tile <= 17 117 | 118 | 119 | def is_sou(tile: int) -> bool: 120 | """ 121 | :param tile: 34 tile format 122 | :return: boolean 123 | """ 124 | return 17 < tile <= 26 125 | 126 | 127 | def is_honor(tile: int) -> bool: 128 | """ 129 | :param tile: 34 tile format 130 | :return: boolean 131 | """ 132 | return tile >= 27 133 | 134 | 135 | def is_sangenpai(tile_34: int) -> bool: 136 | return tile_34 >= 31 137 | 138 | 139 | def is_terminal(tile: int) -> bool: 140 | """ 141 | :param tile: 34 tile format 142 | :return: boolean 143 | """ 144 | return tile in TERMINAL_INDICES 145 | 146 | 147 | def is_dora_indicator_for_terminal(tile: int) -> bool: 148 | """ 149 | :param tile: 34 tile format 150 | :return: boolean 151 | """ 152 | return tile == 7 or tile == 8 or tile == 16 or tile == 17 or tile == 25 or tile == 26 153 | 154 | 155 | def contains_terminals(hand_set: Collection[int]) -> bool: 156 | """ 157 | :param hand_set: array of 34 tiles 158 | :return: boolean 159 | """ 160 | return any(x in TERMINAL_INDICES for x in hand_set) 161 | 162 | 163 | def simplify(tile: int) -> int: 164 | """ 165 | :param tile: 34 tile format 166 | :return: tile: 0-8 presentation 167 | """ 168 | return tile - 9 * (tile // 9) 169 | 170 | 171 | def find_isolated_tile_indices(hand_34: Sequence[int]) -> list[int]: 172 | """ 173 | Tiles that don't have -1, 0 and +1 neighbors 174 | :param hand_34: array of tiles in 34 tile format 175 | :return: array of isolated tiles indices 176 | """ 177 | isolated_indices = [] 178 | 179 | for x in range(0, CHUN + 1): 180 | # for honor tiles we don't need to check nearby tiles 181 | if is_honor(x) and hand_34[x] == 0: 182 | isolated_indices.append(x) 183 | else: 184 | simplified = simplify(x) 185 | 186 | # 1 suit tile 187 | if simplified == 0: 188 | if hand_34[x] == 0 and hand_34[x + 1] == 0: 189 | isolated_indices.append(x) 190 | # 9 suit tile 191 | elif simplified == 8: 192 | if hand_34[x] == 0 and hand_34[x - 1] == 0: 193 | isolated_indices.append(x) 194 | # 2-8 tiles tiles 195 | else: 196 | if hand_34[x] == 0 and hand_34[x - 1] == 0 and hand_34[x + 1] == 0: 197 | isolated_indices.append(x) 198 | 199 | return isolated_indices 200 | 201 | 202 | def is_tile_strictly_isolated(hand_34: Sequence[int], tile_34: int) -> bool: 203 | """ 204 | Tile is strictly isolated if it doesn't have -2, -1, 0, +1, +2 neighbors 205 | :param hand_34: array of tiles in 34 tile format 206 | :param tile_34: int 207 | :return: bool 208 | """ 209 | 210 | if is_honor(tile_34): 211 | return hand_34[tile_34] - 1 <= 0 212 | 213 | simplified = simplify(tile_34) 214 | 215 | # 1 suit tile 216 | if simplified == 0: 217 | indices = [tile_34, tile_34 + 1, tile_34 + 2] 218 | # 2 suit tile 219 | elif simplified == 1: 220 | indices = [tile_34 - 1, tile_34, tile_34 + 1, tile_34 + 2] 221 | # 8 suit tile 222 | elif simplified == 7: 223 | indices = [tile_34 - 2, tile_34 - 1, tile_34, tile_34 + 1] 224 | # 9 suit tile 225 | elif simplified == 8: 226 | indices = [tile_34 - 2, tile_34 - 1, tile_34] 227 | # 3-7 tiles tiles 228 | else: 229 | indices = [tile_34 - 2, tile_34 - 1, tile_34, tile_34 + 1, tile_34 + 2] 230 | 231 | isolated = True 232 | for tile_index in indices: 233 | # we don't want to count our tile as it is in hand already 234 | if tile_index == tile_34: 235 | isolated &= hand_34[tile_index] - 1 <= 0 236 | else: 237 | isolated &= hand_34[tile_index] == 0 238 | 239 | return isolated 240 | 241 | 242 | def count_tiles_by_suits(tiles_34: Sequence[int]) -> list[dict]: 243 | """ 244 | Separate tiles by suits and count them 245 | :param tiles_34: array of tiles to count 246 | :return: dict 247 | """ 248 | suits = [ 249 | {"count": 0, "name": "sou", "function": is_sou}, 250 | {"count": 0, "name": "man", "function": is_man}, 251 | {"count": 0, "name": "pin", "function": is_pin}, 252 | {"count": 0, "name": "honor", "function": is_honor}, 253 | ] 254 | 255 | for x in range(0, 34): 256 | tile = tiles_34[x] 257 | if not tile: 258 | continue 259 | 260 | for item in suits: 261 | if item["function"](x): # type: ignore 262 | item["count"] += tile # type: ignore 263 | 264 | return suits 265 | -------------------------------------------------------------------------------- /mahjong/tile.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection, Sequence 2 | from typing import Any, Optional 3 | 4 | from mahjong.constants import FIVE_RED_MAN, FIVE_RED_PIN, FIVE_RED_SOU 5 | 6 | 7 | class Tile: 8 | value = None 9 | is_tsumogiri = None 10 | 11 | def __init__(self, value: Any, is_tsumogiri: Any) -> None: # noqa: ANN401 12 | self.value = value 13 | self.is_tsumogiri = is_tsumogiri 14 | 15 | 16 | class TilesConverter: 17 | @staticmethod 18 | def to_one_line_string(tiles: Collection[int], print_aka_dora: bool = False) -> str: 19 | """ 20 | Convert 136 tiles array to the one line string 21 | Example of output with print_aka_dora=False: 1244579m3p57z 22 | Example of output with print_aka_dora=True: 1244079m3p57z 23 | """ 24 | tiles = sorted(tiles) 25 | 26 | man = [t for t in tiles if t < 36] 27 | 28 | pin = [t for t in tiles if 36 <= t < 72] 29 | pin = [t - 36 for t in pin] 30 | 31 | sou = [t for t in tiles if 72 <= t < 108] 32 | sou = [t - 72 for t in sou] 33 | 34 | honors = [t for t in tiles if t >= 108] 35 | honors = [t - 108 for t in honors] 36 | 37 | def words(suits: list[int], red_five: int, suffix: str) -> str: 38 | return ( 39 | suits 40 | and "".join(["0" if i == red_five and print_aka_dora else str((i // 4) + 1) for i in suits]) + suffix 41 | or "" 42 | ) 43 | 44 | sou = words(sou, FIVE_RED_SOU - 72, "s") # type: ignore 45 | pin = words(pin, FIVE_RED_PIN - 36, "p") # type: ignore 46 | man = words(man, FIVE_RED_MAN, "m") # type: ignore 47 | honors = words(honors, -1 - 108, "z") # type: ignore 48 | 49 | return man + pin + sou + honors # type: ignore 50 | 51 | @staticmethod 52 | def to_34_array(tiles: Collection[int]) -> list[int]: 53 | """ 54 | Convert 136 array to the 34 tiles array 55 | """ 56 | results = [0] * 34 57 | for tile in tiles: 58 | tile //= 4 59 | results[tile] += 1 60 | return results 61 | 62 | @staticmethod 63 | def to_136_array(tiles: Sequence[int]) -> list[int]: 64 | """ 65 | Convert 34 array to the 136 tiles array 66 | """ 67 | results: list[int] = [] 68 | for index, count in enumerate(tiles): 69 | base_id = index * 4 70 | for i in range(count): 71 | results.append(base_id + i) 72 | return results 73 | 74 | @staticmethod 75 | def string_to_136_array( 76 | sou: Optional[str] = None, 77 | pin: Optional[str] = None, 78 | man: Optional[str] = None, 79 | honors: Optional[str] = None, 80 | has_aka_dora: bool = False, 81 | ) -> list[int]: 82 | """ 83 | Method to convert one line string tiles format to the 136 array. 84 | You can pass r or 0 instead of 5 for it to become a red five from 85 | that suit. To prevent old usage without red, 86 | has_aka_dora has to be True for this to do that. 87 | We need it to increase readability of our tests 88 | """ 89 | 90 | def _split_string(string: Optional[str], offset: int, red: Optional[int] = None) -> list[int]: 91 | data = [] 92 | temp = [] 93 | 94 | if not string: 95 | return [] 96 | 97 | for i in string: 98 | if (i == "r" or i == "0") and has_aka_dora: 99 | assert red is not None 100 | temp.append(red) 101 | data.append(red) 102 | else: 103 | tile = offset + (int(i) - 1) * 4 104 | if tile == red and has_aka_dora: 105 | # prevent non reds to become red 106 | tile += 1 107 | if tile in data: 108 | count_of_tiles = len([x for x in temp if x == tile]) 109 | new_tile = tile + count_of_tiles 110 | data.append(new_tile) 111 | 112 | temp.append(tile) 113 | else: 114 | data.append(tile) 115 | temp.append(tile) 116 | 117 | return data 118 | 119 | results = _split_string(man, 0, FIVE_RED_MAN) 120 | results += _split_string(pin, 36, FIVE_RED_PIN) 121 | results += _split_string(sou, 72, FIVE_RED_SOU) 122 | results += _split_string(honors, 108) 123 | 124 | return results 125 | 126 | @staticmethod 127 | def string_to_34_array( 128 | sou: Optional[str] = None, 129 | pin: Optional[str] = None, 130 | man: Optional[str] = None, 131 | honors: Optional[str] = None, 132 | ) -> list[int]: 133 | """ 134 | Method to convert one line string tiles format to the 34 array 135 | We need it to increase readability of our tests 136 | """ 137 | results = TilesConverter.string_to_136_array(sou, pin, man, honors) 138 | results = TilesConverter.to_34_array(results) 139 | return results 140 | 141 | @staticmethod 142 | def find_34_tile_in_136_array(tile34: Optional[int], tiles: Collection[int]) -> Optional[int]: 143 | """ 144 | Our shanten calculator will operate with 34 tiles format, 145 | after calculations we need to find calculated 34 tile 146 | in player's 136 tiles. 147 | 148 | For example we had 0 tile from 34 array 149 | in 136 array it can be present as 0, 1, 2, 3 150 | """ 151 | if tile34 is None or tile34 > 33: 152 | return None 153 | 154 | tile = tile34 * 4 155 | 156 | possible_tiles = [tile] + [tile + i for i in range(1, 4)] 157 | 158 | found_tile = None 159 | for possible_tile in possible_tiles: 160 | if possible_tile in tiles: 161 | found_tile = possible_tile 162 | break 163 | 164 | return found_tile 165 | 166 | @staticmethod 167 | def one_line_string_to_136_array(string: str, has_aka_dora: bool = False) -> list[int]: 168 | """ 169 | Method to convert one line string tiles format to the 136 array, like 170 | "123s456p789m11222z". 's' stands for sou, 'p' stands for pin, 171 | 'm' stands for man and 'z' or 'h' stands for honor. 172 | You can pass r or 0 instead of 5 for it to become a red five from 173 | that suit. To prevent old usage without red, 174 | has_aka_dora has to be True for this to do that. 175 | """ 176 | sou = "" 177 | pin = "" 178 | man = "" 179 | honors = "" 180 | 181 | split_start = 0 182 | 183 | for index, i in enumerate(string): 184 | if i == "m": 185 | man += string[split_start:index] 186 | split_start = index + 1 187 | if i == "p": 188 | pin += string[split_start:index] 189 | split_start = index + 1 190 | if i == "s": 191 | sou += string[split_start:index] 192 | split_start = index + 1 193 | if i == "z" or i == "h": 194 | honors += string[split_start:index] 195 | split_start = index + 1 196 | 197 | return TilesConverter.string_to_136_array(sou, pin, man, honors, has_aka_dora) 198 | 199 | @staticmethod 200 | def one_line_string_to_34_array(string: str, has_aka_dora: bool = False) -> list[int]: 201 | """ 202 | Method to convert one line string tiles format to the 34 array, like 203 | "123s456p789m11222z". 's' stands for sou, 'p' stands for pin, 204 | 'm' stands for man and 'z' or 'h' stands for honor. 205 | You can pass r or 0 instead of 5 for it to become a red five from 206 | that suit. To prevent old usage without red, 207 | has_aka_dora has to be True for this to do that. 208 | """ 209 | results = TilesConverter.one_line_string_to_136_array(string, has_aka_dora) 210 | results = TilesConverter.to_34_array(results) 211 | return results 212 | -------------------------------------------------------------------------------- /tests/tests_shanten.py: -------------------------------------------------------------------------------- 1 | from mahjong.shanten import Shanten 2 | from mahjong.tile import TilesConverter 3 | 4 | 5 | def test_shanten_number() -> None: 6 | shanten = Shanten() 7 | 8 | tiles = TilesConverter.string_to_34_array(sou="111234567", pin="11", man="567") 9 | assert shanten.calculate_shanten_for_regular_hand(tiles) == Shanten.AGARI_STATE 10 | 11 | tiles = TilesConverter.string_to_34_array(sou="111345677", pin="11", man="567") 12 | assert shanten.calculate_shanten_for_regular_hand(tiles) == 0 13 | 14 | tiles = TilesConverter.string_to_34_array(sou="111345677", pin="15", man="567") 15 | assert shanten.calculate_shanten_for_regular_hand(tiles) == 1 16 | 17 | tiles = TilesConverter.string_to_34_array(sou="11134567", pin="15", man="1578") 18 | assert shanten.calculate_shanten_for_regular_hand(tiles) == 2 19 | 20 | tiles = TilesConverter.string_to_34_array(sou="113456", pin="1358", man="1358") 21 | assert shanten.calculate_shanten_for_regular_hand(tiles) == 3 22 | 23 | tiles = TilesConverter.string_to_34_array(sou="1589", pin="13588", man="1358", honors="1") 24 | assert shanten.calculate_shanten_for_regular_hand(tiles) == 4 25 | 26 | tiles = TilesConverter.string_to_34_array(sou="159", pin="13588", man="1358", honors="12") 27 | assert shanten.calculate_shanten_for_regular_hand(tiles) == 5 28 | 29 | tiles = TilesConverter.string_to_34_array(sou="1589", pin="258", man="1358", honors="123") 30 | assert shanten.calculate_shanten_for_regular_hand(tiles) == 6 31 | 32 | tiles = TilesConverter.string_to_34_array(sou="11123456788999") 33 | assert shanten.calculate_shanten_for_regular_hand(tiles) == Shanten.AGARI_STATE 34 | 35 | tiles = TilesConverter.string_to_34_array(sou="11122245679999") 36 | assert shanten.calculate_shanten_for_regular_hand(tiles) == 0 37 | 38 | tiles = TilesConverter.string_to_34_array(sou="4566677", pin="1367", man="8", honors="12") 39 | assert shanten.calculate_shanten(tiles) == 2 40 | 41 | tiles = TilesConverter.string_to_34_array(sou="14", pin="3356", man="3678", honors="2567") 42 | assert shanten.calculate_shanten(tiles) == 4 43 | 44 | tiles = TilesConverter.string_to_34_array(sou="159", pin="17", man="359", honors="123567") 45 | assert shanten.calculate_shanten_for_regular_hand(tiles) == 7 46 | 47 | tiles = TilesConverter.string_to_34_array(man="1111222235555", honors="1") 48 | assert shanten.calculate_shanten(tiles) == 0 49 | 50 | tiles = TilesConverter.string_to_34_array(honors="11112222333444") 51 | assert shanten.calculate_shanten_for_regular_hand(tiles) == 1 52 | 53 | tiles = TilesConverter.string_to_34_array(man="11", honors="111122223333") 54 | assert shanten.calculate_shanten_for_regular_hand(tiles) == 2 55 | 56 | tiles = TilesConverter.string_to_34_array(man="23", honors="111122223333") 57 | assert shanten.calculate_shanten_for_regular_hand(tiles) == 2 58 | 59 | 60 | def test_shanten_for_not_completed_hand() -> None: 61 | shanten = Shanten() 62 | 63 | tiles = TilesConverter.string_to_34_array(sou="111345677", pin="1", man="567") 64 | assert shanten.calculate_shanten_for_regular_hand(tiles) == 1 65 | 66 | tiles = TilesConverter.string_to_34_array(sou="111345677", man="567") 67 | assert shanten.calculate_shanten_for_regular_hand(tiles) == 1 68 | 69 | tiles = TilesConverter.string_to_34_array(sou="111345677", man="56") 70 | assert shanten.calculate_shanten_for_regular_hand(tiles) == 0 71 | 72 | tiles = TilesConverter.string_to_34_array(man="123456789", honors="1111") 73 | assert shanten.calculate_shanten_for_regular_hand(tiles) == 1 74 | 75 | tiles = TilesConverter.string_to_34_array(man="123456789", pin="1111") 76 | assert shanten.calculate_shanten_for_regular_hand(tiles) == 1 77 | 78 | tiles = TilesConverter.string_to_34_array(sou="112233", pin="123", man="1111") 79 | assert shanten.calculate_shanten_for_regular_hand(tiles) == 1 80 | 81 | tiles = TilesConverter.string_to_34_array(honors="1111222333444") 82 | assert shanten.calculate_shanten_for_regular_hand(tiles) == 1 83 | 84 | tiles = TilesConverter.string_to_34_array(man="11", honors="11112222333") 85 | assert shanten.calculate_shanten_for_regular_hand(tiles) == 2 86 | 87 | tiles = TilesConverter.string_to_34_array(man="23", honors="11112222333") 88 | assert shanten.calculate_shanten_for_regular_hand(tiles) == 2 89 | 90 | tiles = TilesConverter.string_to_34_array(honors="1111222233334") 91 | assert shanten.calculate_shanten_for_regular_hand(tiles) == 3 92 | 93 | 94 | def test_shanten_number_and_chiitoitsu() -> None: 95 | shanten = Shanten() 96 | 97 | tiles = TilesConverter.string_to_34_array(sou="114477", pin="114477", man="77") 98 | assert shanten.calculate_shanten_for_chiitoitsu_hand(tiles) == Shanten.AGARI_STATE 99 | 100 | tiles = TilesConverter.string_to_34_array(sou="114477", pin="114477", man="76") 101 | assert shanten.calculate_shanten_for_chiitoitsu_hand(tiles) == 0 102 | 103 | tiles = TilesConverter.string_to_34_array(sou="114477", pin="114479", man="76") 104 | assert shanten.calculate_shanten_for_chiitoitsu_hand(tiles) == 1 105 | 106 | tiles = TilesConverter.string_to_34_array(sou="114477", pin="14479", man="76", honors="1") 107 | assert shanten.calculate_shanten_for_chiitoitsu_hand(tiles) == 2 108 | 109 | tiles = TilesConverter.string_to_34_array(sou="114477", pin="13479", man="76", honors="1") 110 | assert shanten.calculate_shanten_for_chiitoitsu_hand(tiles) == 3 111 | 112 | tiles = TilesConverter.string_to_34_array(sou="114467", pin="13479", man="76", honors="1") 113 | assert shanten.calculate_shanten_for_chiitoitsu_hand(tiles) == 4 114 | 115 | tiles = TilesConverter.string_to_34_array(sou="114367", pin="13479", man="76", honors="1") 116 | assert shanten.calculate_shanten_for_chiitoitsu_hand(tiles) == 5 117 | 118 | tiles = TilesConverter.string_to_34_array(sou="124367", pin="13479", man="76", honors="1") 119 | assert shanten.calculate_shanten_for_chiitoitsu_hand(tiles) == 6 120 | 121 | tiles = TilesConverter.string_to_34_array(sou="66677888", pin="55", man="2255") 122 | assert shanten.calculate_shanten_for_chiitoitsu_hand(tiles) == 1 123 | 124 | 125 | def test_shanten_number_and_kokushi() -> None: 126 | shanten = Shanten() 127 | 128 | tiles = TilesConverter.string_to_34_array(sou="19", pin="19", man="19", honors="12345677") 129 | assert shanten.calculate_shanten_for_kokushi_hand(tiles) == Shanten.AGARI_STATE 130 | 131 | tiles = TilesConverter.string_to_34_array(sou="129", pin="19", man="19", honors="1234567") 132 | assert shanten.calculate_shanten_for_kokushi_hand(tiles) == 0 133 | 134 | tiles = TilesConverter.string_to_34_array(sou="129", pin="129", man="19", honors="123456") 135 | assert shanten.calculate_shanten_for_kokushi_hand(tiles) == 1 136 | 137 | tiles = TilesConverter.string_to_34_array(sou="129", pin="129", man="129", honors="12345") 138 | assert shanten.calculate_shanten_for_kokushi_hand(tiles) == 2 139 | 140 | tiles = TilesConverter.string_to_34_array(sou="1239", pin="129", man="129", honors="2345") 141 | assert shanten.calculate_shanten_for_kokushi_hand(tiles) == 3 142 | 143 | tiles = TilesConverter.string_to_34_array(sou="1239", pin="1239", man="129", honors="345") 144 | assert shanten.calculate_shanten_for_kokushi_hand(tiles) == 4 145 | 146 | tiles = TilesConverter.string_to_34_array(sou="1239", pin="1239", man="1239", honors="45") 147 | assert shanten.calculate_shanten_for_kokushi_hand(tiles) == 5 148 | 149 | tiles = TilesConverter.string_to_34_array(sou="12349", pin="1239", man="1239", honors="5") 150 | assert shanten.calculate_shanten_for_kokushi_hand(tiles) == 6 151 | 152 | tiles = TilesConverter.string_to_34_array(sou="12349", pin="12349", man="1239") 153 | assert shanten.calculate_shanten_for_kokushi_hand(tiles) == 7 154 | 155 | 156 | def test_shanten_number_and_open_sets() -> None: 157 | shanten = Shanten() 158 | 159 | tiles = TilesConverter.string_to_34_array(sou="44467778", pin="222567") 160 | assert shanten.calculate_shanten(tiles) == Shanten.AGARI_STATE 161 | 162 | tiles = TilesConverter.string_to_34_array(sou="44468", pin="222567") 163 | assert shanten.calculate_shanten(tiles) == 0 164 | 165 | tiles = TilesConverter.string_to_34_array(sou="68", pin="222567") 166 | assert shanten.calculate_shanten(tiles) == 0 167 | 168 | tiles = TilesConverter.string_to_34_array(sou="68", pin="567") 169 | assert shanten.calculate_shanten(tiles) == 0 170 | 171 | tiles = TilesConverter.string_to_34_array(sou="68") 172 | assert shanten.calculate_shanten(tiles) == 0 173 | 174 | tiles = TilesConverter.string_to_34_array(sou="88") 175 | assert shanten.calculate_shanten(tiles) == Shanten.AGARI_STATE 176 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/divider.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import itertools 3 | import marshal 4 | from collections.abc import Collection, Sequence 5 | from functools import reduce 6 | from typing import Optional 7 | 8 | from mahjong.constants import HONOR_INDICES 9 | from mahjong.meld import Meld 10 | from mahjong.utils import is_chi, is_pon 11 | 12 | 13 | class HandDivider: 14 | divider_cache = None 15 | cache_key = None 16 | 17 | def __init__(self) -> None: 18 | self.divider_cache = {} 19 | 20 | def divide_hand( 21 | self, 22 | tiles_34: Sequence[int], 23 | melds: Optional[Collection[Meld]] = None, 24 | use_cache: bool = False, 25 | ) -> list[list[list[int]]]: 26 | """ 27 | Return a list of possible hands. 28 | :param tiles_34: 29 | :param melds: list of Meld objects 30 | :return: 31 | """ 32 | if not melds: 33 | melds = [] 34 | 35 | if use_cache: 36 | self.cache_key = self._build_divider_cache_key(tiles_34, melds) 37 | if self.cache_key in self.divider_cache: 38 | return self.divider_cache[self.cache_key] 39 | 40 | closed_hand_tiles_34 = list(tiles_34) 41 | 42 | # small optimization, we can't have a pair in open part of the hand, 43 | # so we don't need to try find pairs in open sets 44 | open_tile_indices = list(itertools.chain.from_iterable(x.tiles_34 for x in melds)) if melds else [] 45 | for open_item in open_tile_indices: 46 | closed_hand_tiles_34[open_item] -= 1 47 | 48 | pair_indices = self.find_pairs(closed_hand_tiles_34) 49 | 50 | # let's try to find all possible hand options 51 | hands: list[list[list[int]]] = [] 52 | for pair_index in pair_indices: 53 | local_tiles_34 = list(tiles_34) 54 | 55 | # we don't need to combine already open sets 56 | for open_item in open_tile_indices: 57 | local_tiles_34[open_item] -= 1 58 | 59 | local_tiles_34[pair_index] -= 2 60 | 61 | # 0 - 8 man tiles 62 | man = self.find_valid_combinations(local_tiles_34, 0, 8) 63 | 64 | # 9 - 17 pin tiles 65 | pin = self.find_valid_combinations(local_tiles_34, 9, 17) 66 | 67 | # 18 - 26 sou tiles 68 | sou = self.find_valid_combinations(local_tiles_34, 18, 26) 69 | 70 | honor: list = [] 71 | for x in HONOR_INDICES: 72 | if local_tiles_34[x] == 3: 73 | honor.append([x] * 3) 74 | 75 | if honor: 76 | honor = [honor] 77 | 78 | arrays = [[[pair_index] * 2]] 79 | if sou: 80 | arrays.append(sou) 81 | if man: 82 | arrays.append(man) 83 | if pin: 84 | arrays.append(pin) 85 | if honor: 86 | arrays.append(honor) 87 | 88 | for meld in melds: 89 | arrays.append([meld.tiles_34]) 90 | 91 | # let's find all possible hand from our valid sets 92 | for s in itertools.product(*arrays): 93 | hand: list[list[int]] = [] 94 | for item in s: 95 | if isinstance(item[0], list): 96 | for x in item: 97 | hand.append(x) 98 | else: 99 | hand.append(item) 100 | 101 | hand = sorted(hand, key=lambda a: a[0]) 102 | if len(hand) == 5: 103 | hands.append(hand) 104 | 105 | # small optimization, let's remove hand duplicates 106 | unique_hands = [] 107 | for hand in hands: 108 | hand = sorted(hand, key=lambda x: (x[0], x[1])) 109 | if hand not in unique_hands: 110 | unique_hands.append(hand) 111 | 112 | hands = unique_hands 113 | 114 | if len(pair_indices) == 7: 115 | hand = [] 116 | for index in pair_indices: 117 | hand.append([index] * 2) 118 | hands.append(hand) 119 | 120 | result = sorted(hands) 121 | 122 | if use_cache: 123 | self.divider_cache[self.cache_key] = result 124 | 125 | return result 126 | 127 | def find_pairs(self, tiles_34: Sequence[int], first_index: int = 0, second_index: int = 33) -> list[int]: 128 | """ 129 | Find all possible pairs in the hand and return their indices 130 | :return: array of pair indices 131 | """ 132 | pair_indices = [] 133 | for x in range(first_index, second_index + 1): 134 | # ignore pon of honor tiles, because it can't be a part of pair 135 | if x in HONOR_INDICES and tiles_34[x] != 2: 136 | continue 137 | 138 | if tiles_34[x] >= 2: 139 | pair_indices.append(x) 140 | 141 | return pair_indices 142 | 143 | def find_valid_combinations( 144 | self, 145 | tiles_34: Sequence[int], 146 | first_index: int, 147 | second_index: int, 148 | hand_not_completed: bool = False, 149 | ) -> list[list[list[int]]]: 150 | """ 151 | Find and return all valid set combinations in given suit 152 | :param tiles_34: 153 | :param first_index: 154 | :param second_index: 155 | :param hand_not_completed: in that mode we can return just possible shi or pon sets 156 | :return: list of valid combinations 157 | """ 158 | indices = [] 159 | for x in range(first_index, second_index + 1): 160 | if tiles_34[x] > 0: 161 | indices.extend([x] * tiles_34[x]) 162 | 163 | if not indices: 164 | return [] 165 | 166 | all_possible_combinations: list[tuple[int, int, int]] = list(itertools.permutations(indices, 3)) 167 | 168 | def is_valid_combination(possible_set: tuple[int, int, int]) -> bool: 169 | if is_chi(possible_set): 170 | return True 171 | 172 | if is_pon(possible_set): 173 | return True 174 | 175 | return False 176 | 177 | valid_combinations: list[list[int]] = [] 178 | for combination in all_possible_combinations: 179 | if is_valid_combination(combination): 180 | valid_combinations.append(list(combination)) 181 | 182 | if not valid_combinations: 183 | return [] 184 | 185 | count_of_needed_combinations = int(len(indices) / 3) 186 | 187 | # simple case, we have count of sets == count of tiles 188 | if ( 189 | count_of_needed_combinations == len(valid_combinations) 190 | and reduce(lambda z, y: z + y, valid_combinations) == indices 191 | ): 192 | return [valid_combinations] 193 | 194 | # filter and remove not possible pon sets 195 | for item in valid_combinations: 196 | if is_pon(item): 197 | count_of_sets = 1 198 | count_of_tiles = 0 199 | while count_of_sets > count_of_tiles: 200 | count_of_tiles = len([x for x in indices if x == item[0]]) / 3 201 | count_of_sets = len( 202 | [x for x in valid_combinations if x[0] == item[0] and x[1] == item[1] and x[2] == item[2]] 203 | ) 204 | 205 | if count_of_sets > count_of_tiles: 206 | valid_combinations.remove(item) 207 | 208 | # filter and remove not possible chi sets 209 | for item in valid_combinations: 210 | if is_chi(item): 211 | count_of_sets = 5 212 | # TODO calculate real count of possible sets 213 | count_of_possible_sets = 4 214 | while count_of_sets > count_of_possible_sets: 215 | count_of_sets = len( 216 | [x for x in valid_combinations if x[0] == item[0] and x[1] == item[1] and x[2] == item[2]] 217 | ) 218 | 219 | if count_of_sets > count_of_possible_sets: 220 | valid_combinations.remove(item) 221 | 222 | # lit of chi\pon sets for not completed hand 223 | if hand_not_completed: 224 | return [valid_combinations] 225 | 226 | # hard case - we can build a lot of sets from our tiles 227 | # for example we have 123456 tiles and we can build sets: 228 | # [1, 2, 3] [4, 5, 6] [2, 3, 4] [3, 4, 5] 229 | # and only two of them valid in the same time [1, 2, 3] [4, 5, 6] 230 | 231 | possible_combinations = set( 232 | itertools.permutations(range(0, len(valid_combinations)), count_of_needed_combinations) 233 | ) 234 | 235 | combinations_results = [] 236 | for combination in possible_combinations: 237 | result = [] 238 | for item in combination: 239 | result += valid_combinations[item] 240 | result = sorted(result) 241 | 242 | if result == indices: 243 | results = [] 244 | for item in combination: 245 | results.append(valid_combinations[item]) 246 | results = sorted(results, key=lambda z: z[0]) 247 | if results not in combinations_results: 248 | combinations_results.append(results) 249 | 250 | return combinations_results 251 | 252 | def clear_cache(self) -> None: 253 | self.divider_cache = {} 254 | self.cache_key = None 255 | 256 | def _build_divider_cache_key(self, tiles_34: Sequence[int], melds: Collection[Meld]) -> str: 257 | prepared_array = list(tiles_34) + [x.tiles for x in melds] if melds else list(tiles_34) 258 | return hashlib.md5(marshal.dumps(prepared_array)).hexdigest() 259 | -------------------------------------------------------------------------------- /tests/hand_calculating/tests_hand_response_error.py: -------------------------------------------------------------------------------- 1 | from mahjong.constants import EAST, SOUTH 2 | from mahjong.hand_calculating.hand import HandCalculator 3 | from mahjong.meld import Meld 4 | from mahjong.tile import TilesConverter 5 | from tests.utils_for_tests import _make_hand_config, _make_meld, _string_to_136_tile 6 | 7 | 8 | def test_no_winning_tile() -> None: 9 | hand = HandCalculator() 10 | 11 | tiles = TilesConverter.string_to_136_array(sou="123444", man="234456", pin="66") 12 | win_tile = _string_to_136_tile(sou="9") 13 | 14 | result = hand.estimate_hand_value(tiles, win_tile, config=_make_hand_config(is_riichi=True)) 15 | assert result.error == "winning_tile_not_in_hand" 16 | 17 | 18 | def test_open_hand_riichi() -> None: 19 | hand = HandCalculator() 20 | 21 | tiles = TilesConverter.string_to_136_array(sou="123444", man="234456", pin="66") 22 | win_tile = _string_to_136_tile(sou="4") 23 | 24 | melds = [_make_meld(Meld.CHI, sou="123")] 25 | result = hand.estimate_hand_value(tiles, win_tile, melds=melds, config=_make_hand_config(is_riichi=True)) 26 | assert result.error == "open_hand_riichi_not_allowed" 27 | 28 | 29 | def test_open_hand_daburi() -> None: 30 | hand = HandCalculator() 31 | 32 | tiles = TilesConverter.string_to_136_array(sou="123444", man="234456", pin="66") 33 | win_tile = _string_to_136_tile(sou="4") 34 | 35 | melds = [_make_meld(Meld.CHI, sou="123")] 36 | result = hand.estimate_hand_value( 37 | tiles, win_tile, melds=melds, config=_make_hand_config(is_riichi=True, is_daburu_riichi=True) 38 | ) 39 | assert result.error == "open_hand_daburi_not_allowed" 40 | 41 | 42 | def test_ippatsu_without_riichi() -> None: 43 | hand = HandCalculator() 44 | 45 | tiles = TilesConverter.string_to_136_array(sou="123444", man="234456", pin="66") 46 | win_tile = _string_to_136_tile(sou="4") 47 | 48 | result = hand.estimate_hand_value(tiles, win_tile, config=_make_hand_config(is_ippatsu=True)) 49 | assert result.error == "ippatsu_without_riichi_not_allowed" 50 | 51 | 52 | def test_hand_not_winning() -> None: 53 | hand = HandCalculator() 54 | 55 | tiles = TilesConverter.string_to_136_array(sou="123344", man="234456", pin="66") 56 | win_tile = _string_to_136_tile(sou="4") 57 | 58 | result = hand.estimate_hand_value(tiles, win_tile) 59 | assert result.error == "hand_not_winning" 60 | 61 | 62 | def test_no_yaku() -> None: 63 | hand = HandCalculator() 64 | 65 | tiles = TilesConverter.string_to_136_array(sou="123444", man="234456", pin="66") 66 | win_tile = _string_to_136_tile(sou="4") 67 | 68 | melds = [_make_meld(Meld.CHI, sou="123")] 69 | result = hand.estimate_hand_value(tiles, win_tile, melds=melds) 70 | assert result.error == "no_yaku" 71 | 72 | 73 | def test_chankan_with_tsumo() -> None: 74 | hand = HandCalculator() 75 | 76 | tiles = TilesConverter.string_to_136_array(sou="123444", man="234456", pin="66") 77 | win_tile = _string_to_136_tile(sou="1") 78 | 79 | result = hand.estimate_hand_value(tiles, win_tile, config=_make_hand_config(is_tsumo=True, is_chankan=True)) 80 | assert result.error == "chankan_with_tsumo_not_allowed" 81 | 82 | 83 | def test_rinshan_without_tsumo() -> None: 84 | hand = HandCalculator() 85 | 86 | tiles = TilesConverter.string_to_136_array(sou="123444", man="234456", pin="66") 87 | win_tile = _string_to_136_tile(sou="4") 88 | 89 | result = hand.estimate_hand_value(tiles, win_tile, config=_make_hand_config(is_tsumo=False, is_rinshan=True)) 90 | assert result.error == "rinshan_without_tsumo_not_allowed" 91 | 92 | 93 | def test_haitei_without_tsumo() -> None: 94 | hand = HandCalculator() 95 | 96 | tiles = TilesConverter.string_to_136_array(sou="123444", man="234456", pin="66") 97 | win_tile = _string_to_136_tile(sou="4") 98 | 99 | result = hand.estimate_hand_value(tiles, win_tile, config=_make_hand_config(is_tsumo=False, is_haitei=True)) 100 | assert result.error == "haitei_without_tsumo_not_allowed" 101 | 102 | 103 | def test_houtei_with_tsumo() -> None: 104 | hand = HandCalculator() 105 | 106 | tiles = TilesConverter.string_to_136_array(sou="123444", man="234456", pin="66") 107 | win_tile = _string_to_136_tile(sou="4") 108 | 109 | result = hand.estimate_hand_value(tiles, win_tile, config=_make_hand_config(is_tsumo=True, is_houtei=True)) 110 | assert result.error == "houtei_with_tsumo_not_allowed" 111 | 112 | 113 | def test_haitei_with_rinshan() -> None: 114 | hand = HandCalculator() 115 | 116 | tiles = TilesConverter.string_to_136_array(sou="123444", man="234456", pin="66") 117 | win_tile = _string_to_136_tile(sou="4") 118 | 119 | result = hand.estimate_hand_value( 120 | tiles, win_tile, config=_make_hand_config(is_tsumo=True, is_rinshan=True, is_haitei=True) 121 | ) 122 | assert result.error == "haitei_with_rinshan_not_allowed" 123 | 124 | 125 | def test_houtei_with_chankan() -> None: 126 | hand = HandCalculator() 127 | 128 | tiles = TilesConverter.string_to_136_array(sou="123444", man="234456", pin="66") 129 | win_tile = _string_to_136_tile(sou="1") 130 | 131 | result = hand.estimate_hand_value( 132 | tiles, win_tile, config=_make_hand_config(is_tsumo=False, is_chankan=True, is_houtei=True) 133 | ) 134 | assert result.error == "houtei_with_chankan_not_allowed" 135 | 136 | 137 | def test_tenhou_not_as_dealer() -> None: 138 | hand = HandCalculator() 139 | 140 | tiles = TilesConverter.string_to_136_array(sou="123444", man="234456", pin="66") 141 | win_tile = _string_to_136_tile(sou="4") 142 | 143 | # no error when player wind is *not* specified 144 | result = hand.estimate_hand_value(tiles, win_tile, config=_make_hand_config(is_tsumo=True, is_tenhou=True)) 145 | assert result.error is None 146 | 147 | # raise error when player wind is specified and *not* EAST 148 | result = hand.estimate_hand_value( 149 | tiles, win_tile, config=_make_hand_config(is_tsumo=True, is_tenhou=True, player_wind=SOUTH) 150 | ) 151 | assert result.error == "tenhou_not_as_dealer_not_allowed" 152 | 153 | 154 | def test_tenhou_without_tsumo() -> None: 155 | hand = HandCalculator() 156 | 157 | tiles = TilesConverter.string_to_136_array(sou="123444", man="234456", pin="66") 158 | win_tile = _string_to_136_tile(sou="4") 159 | 160 | result = hand.estimate_hand_value(tiles, win_tile, config=_make_hand_config(is_tsumo=False, is_tenhou=True)) 161 | assert result.error == "tenhou_without_tsumo_not_allowed" 162 | 163 | 164 | def test_tenhou_with_meld() -> None: 165 | hand = HandCalculator() 166 | 167 | tiles = TilesConverter.string_to_136_array(sou="1234444", man="234456", pin="66") 168 | win_tile = _string_to_136_tile(sou="1") 169 | 170 | melds = [_make_meld(Meld.KAN, is_open=False, sou="4444")] 171 | result = hand.estimate_hand_value( 172 | tiles, win_tile, melds=melds, config=_make_hand_config(is_tsumo=True, is_rinshan=True, is_tenhou=True) 173 | ) 174 | assert result.error == "tenhou_with_meld_not_allowed" 175 | 176 | 177 | def test_chiihou_as_dealer() -> None: 178 | hand = HandCalculator() 179 | 180 | tiles = TilesConverter.string_to_136_array(sou="123444", man="234456", pin="66") 181 | win_tile = _string_to_136_tile(sou="4") 182 | 183 | # no error when player wind is *not* specified 184 | result = hand.estimate_hand_value(tiles, win_tile, config=_make_hand_config(is_tsumo=True, is_chiihou=True)) 185 | assert result.error is None 186 | 187 | # raise error when player wind is specified EAST 188 | result = hand.estimate_hand_value( 189 | tiles, win_tile, config=_make_hand_config(is_tsumo=True, is_chiihou=True, player_wind=EAST) 190 | ) 191 | assert result.error == "chiihou_as_dealer_not_allowed" 192 | 193 | 194 | def test_chiihou_without_tsumo() -> None: 195 | hand = HandCalculator() 196 | 197 | tiles = TilesConverter.string_to_136_array(sou="123444", man="234456", pin="66") 198 | win_tile = _string_to_136_tile(sou="4") 199 | 200 | result = hand.estimate_hand_value(tiles, win_tile, config=_make_hand_config(is_tsumo=False, is_chiihou=True)) 201 | assert result.error == "chiihou_without_tsumo_not_allowed" 202 | 203 | 204 | def test_chiihou_with_meld() -> None: 205 | hand = HandCalculator() 206 | 207 | tiles = TilesConverter.string_to_136_array(sou="1234444", man="234456", pin="66") 208 | win_tile = _string_to_136_tile(sou="1") 209 | 210 | melds = [_make_meld(Meld.KAN, is_open=False, sou="4444")] 211 | result = hand.estimate_hand_value( 212 | tiles, win_tile, melds=melds, config=_make_hand_config(is_tsumo=True, is_rinshan=True, is_chiihou=True) 213 | ) 214 | assert result.error == "chiihou_with_meld_not_allowed" 215 | 216 | 217 | def test_renhou_as_dealer() -> None: 218 | hand = HandCalculator() 219 | 220 | tiles = TilesConverter.string_to_136_array(sou="123444", man="234456", pin="66") 221 | win_tile = _string_to_136_tile(sou="4") 222 | 223 | # no error when player wind is *not* specified 224 | result = hand.estimate_hand_value(tiles, win_tile, config=_make_hand_config(is_tsumo=False, is_renhou=True)) 225 | assert result.error is None 226 | 227 | # raise error when player wind is specified EAST 228 | result = hand.estimate_hand_value( 229 | tiles, win_tile, config=_make_hand_config(is_tsumo=False, is_renhou=True, player_wind=EAST) 230 | ) 231 | assert result.error == "renhou_as_dealer_not_allowed" 232 | 233 | 234 | def test_renhou_with_tsumo() -> None: 235 | hand = HandCalculator() 236 | 237 | tiles = TilesConverter.string_to_136_array(sou="123444", man="234456", pin="66") 238 | win_tile = _string_to_136_tile(sou="4") 239 | 240 | result = hand.estimate_hand_value(tiles, win_tile, config=_make_hand_config(is_tsumo=True, is_renhou=True)) 241 | assert result.error == "renhou_with_tsumo_not_allowed" 242 | 243 | 244 | def test_renhou_with_meld() -> None: 245 | hand = HandCalculator() 246 | 247 | tiles = TilesConverter.string_to_136_array(sou="1234444", man="234456", pin="66") 248 | win_tile = _string_to_136_tile(sou="1") 249 | 250 | melds = [_make_meld(Meld.KAN, is_open=False, sou="4444")] 251 | result = hand.estimate_hand_value( 252 | tiles, win_tile, melds=melds, config=_make_hand_config(is_tsumo=False, is_renhou=True) 253 | ) 254 | assert result.error == "renhou_with_meld_not_allowed" 255 | -------------------------------------------------------------------------------- /mahjong/hand_calculating/scores.py: -------------------------------------------------------------------------------- 1 | from collections.abc import MutableSequence, MutableSet 2 | from typing import Any, Union 3 | 4 | from mahjong.hand_calculating.hand_config import HandConfig 5 | from mahjong.hand_calculating.yaku import Yaku 6 | 7 | 8 | class ScoresCalculator: 9 | def calculate_scores(self, han: int, fu: int, config: HandConfig, is_yakuman: bool = False) -> dict[str, Any]: 10 | """ 11 | Calculate how much scores cost a hand with given han and fu 12 | :param han: int 13 | :param fu: int 14 | :param config: HandConfig object 15 | :param is_yakuman: boolean 16 | :return: a dictionary with following keys: 17 | 'main': main cost (honba number / tsumi bou not included) 18 | 'additional': additional cost (honba number not included) 19 | 'main_bonus': extra cost due to honba number to be added on main cost 20 | 'additional_bonus': extra cost due to honba number to be added on additional cost 21 | 'kyoutaku_bonus': the points taken from accumulated riichi 1000-point bous (kyoutaku) 22 | 'total': the total points the winner is to earn 23 | 'yaku_level': level of yaku (e.g. yakuman, mangan, nagashi mangan, etc) 24 | 25 | for ron, main cost is the cost for the player who triggers the ron, and additional cost is always = 0 26 | for dealer tsumo, main cost is the same as additional cost, which is the cost for any other player 27 | for non-dealer (player) tsumo, main cost is cost for dealer and additional is cost for player 28 | 29 | examples: 30 | 1. dealer tsumo 2000 ALL in 2 honba, with 3 riichi bous on desk 31 | {'main': 2000, 'additional': 2000, 32 | 'main_bonus': 200, 'additional_bonus': 200, 33 | 'kyoutaku_bonus': 3000, 'total': 9600, 'yaku_level': ''} 34 | 35 | 2. player tsumo 3900-2000 in 4 honba, with 1 riichi bou on desk 36 | {'main': 3900, 'additional': 2000, 37 | 'main_bonus': 400, 'additional_bonus': 400, 38 | 'kyoutaku_bonus': 1000, 'total': 10100, 'yaku_level': ''} 39 | 40 | 3. dealer (or player) ron 12000 in 5 honba, with no riichi bou on desk 41 | {'main': 12000, 'additional': 0, 42 | 'main_bonus': 1500, 'additional_bonus': 0, 43 | 'kyoutaku_bonus': 0, 'total': 13500} 44 | 45 | """ 46 | 47 | yaku_level = "" 48 | 49 | # kazoe hand 50 | if han >= 13 and not is_yakuman: 51 | # Hands over 26+ han don't count as double yakuman 52 | if config.options.kazoe_limit == HandConfig.KAZOE_LIMITED: 53 | han = 13 54 | yaku_level = "kazoe yakuman" 55 | # Hands over 13+ is a sanbaiman 56 | elif config.options.kazoe_limit == HandConfig.KAZOE_SANBAIMAN: 57 | han = 12 58 | yaku_level = "kazoe sanbaiman" 59 | 60 | if han >= 5: 61 | if han >= 78: 62 | yaku_level = "6x yakuman" 63 | if config.options.limit_to_sextuple_yakuman: 64 | rounded = 48000 65 | else: 66 | extra_han, _ = divmod(han - 78, 13) 67 | rounded = 48000 + (extra_han * 8000) 68 | elif han >= 65: 69 | yaku_level = "5x yakuman" 70 | rounded = 40000 71 | elif han >= 52: 72 | yaku_level = "4x yakuman" 73 | rounded = 32000 74 | elif han >= 39: 75 | yaku_level = "3x yakuman" 76 | rounded = 24000 77 | # double yakuman 78 | elif han >= 26: 79 | yaku_level = "2x yakuman" 80 | rounded = 16000 81 | # yakuman 82 | elif han >= 13: 83 | yaku_level = "yakuman" 84 | rounded = 8000 85 | # sanbaiman 86 | elif han >= 11: 87 | yaku_level = "sanbaiman" 88 | rounded = 6000 89 | # baiman 90 | elif han >= 8: 91 | yaku_level = "baiman" 92 | rounded = 4000 93 | # haneman 94 | elif han >= 6: 95 | yaku_level = "haneman" 96 | rounded = 3000 97 | else: 98 | yaku_level = "mangan" 99 | rounded = 2000 100 | 101 | double_rounded = rounded * 2 102 | four_rounded = double_rounded * 2 103 | six_rounded = double_rounded * 3 104 | else: # han < 5 105 | base_points = fu * pow(2, 2 + han) 106 | rounded = (base_points + 99) // 100 * 100 107 | double_rounded = (2 * base_points + 99) // 100 * 100 108 | four_rounded = (4 * base_points + 99) // 100 * 100 109 | six_rounded = (6 * base_points + 99) // 100 * 100 110 | 111 | is_kiriage = False 112 | if config.options.kiriage: 113 | if (han == 4 and fu == 30) or (han == 3 and fu == 60): 114 | yaku_level = "kiriage mangan" 115 | is_kiriage = True 116 | else: # kiriage not supported 117 | if rounded > 2000: 118 | yaku_level = "mangan" 119 | 120 | # mangan 121 | if rounded > 2000 or is_kiriage: 122 | rounded = 2000 123 | double_rounded = rounded * 2 124 | four_rounded = double_rounded * 2 125 | six_rounded = double_rounded * 3 126 | else: # below mangan 127 | pass 128 | 129 | if config.is_tsumo: 130 | main = double_rounded 131 | main_bonus = 100 * config.tsumi_number 132 | additional_bonus = main_bonus 133 | 134 | if config.is_dealer: 135 | additional = main 136 | else: # player 137 | additional = rounded 138 | 139 | else: # ron 140 | additional = 0 141 | additional_bonus = 0 142 | main_bonus = 300 * config.tsumi_number 143 | 144 | if config.is_dealer: 145 | main = six_rounded 146 | else: # player 147 | main = four_rounded 148 | 149 | kyoutaku_bonus = 1000 * config.kyoutaku_number 150 | total = (main + main_bonus) + 2 * (additional + additional_bonus) + kyoutaku_bonus 151 | 152 | if config.is_nagashi_mangan: 153 | yaku_level = "nagashi mangan" 154 | 155 | ret_dict = { 156 | "main": main, 157 | "main_bonus": main_bonus, 158 | "additional": additional, 159 | "additional_bonus": additional_bonus, 160 | "kyoutaku_bonus": kyoutaku_bonus, 161 | "total": total, 162 | "yaku_level": yaku_level, 163 | } 164 | 165 | return ret_dict 166 | 167 | 168 | class Aotenjou(ScoresCalculator): 169 | def calculate_scores(self, han: int, fu: int, config: HandConfig, is_yakuman: bool = False) -> dict[str, int]: 170 | base_points: int = fu * pow(2, 2 + han) 171 | rounded = (base_points + 99) // 100 * 100 172 | double_rounded = (2 * base_points + 99) // 100 * 100 173 | four_rounded = (4 * base_points + 99) // 100 * 100 174 | six_rounded = (6 * base_points + 99) // 100 * 100 175 | 176 | if config.is_tsumo: 177 | return {"main": double_rounded, "additional": config.is_dealer and double_rounded or rounded} 178 | else: 179 | return {"main": config.is_dealer and six_rounded or four_rounded, "additional": 0} 180 | 181 | def aotenjou_filter_yaku( 182 | self, 183 | hand_yaku: Union[MutableSequence[Yaku], MutableSet[Yaku]], 184 | config: HandConfig, 185 | ) -> None: 186 | # in aotenjou yakumans are normal yaku 187 | # but we need to filter lower yaku that are precursors to yakumans 188 | if config.yaku.daisangen in hand_yaku: 189 | # for daisangen precursors are all dragons and shosangen 190 | hand_yaku.remove(config.yaku.chun) 191 | hand_yaku.remove(config.yaku.hatsu) 192 | hand_yaku.remove(config.yaku.haku) 193 | hand_yaku.remove(config.yaku.shosangen) 194 | 195 | if config.yaku.tsuisou in hand_yaku: 196 | # for tsuuiisou we need to remove toitoi and honroto 197 | hand_yaku.remove(config.yaku.toitoi) 198 | hand_yaku.remove(config.yaku.honroto) 199 | 200 | if config.yaku.shosuushi in hand_yaku: 201 | # for shosuushi we do not need to remove anything 202 | pass 203 | 204 | if config.yaku.daisuushi in hand_yaku: 205 | # for daisuushi we need to remove toitoi 206 | hand_yaku.remove(config.yaku.toitoi) 207 | 208 | if config.yaku.suuankou in hand_yaku or config.yaku.suuankou_tanki in hand_yaku: 209 | # for suu ankou we need to remove toitoi and sanankou (sanankou is already removed by default) 210 | if config.yaku.toitoi in hand_yaku: 211 | # toitoi is "optional" in closed suukantsu, maybe a bug? or toitoi is not given when it's kans? 212 | hand_yaku.remove(config.yaku.toitoi) 213 | 214 | if config.yaku.chinroto in hand_yaku: 215 | # for chinroto we need to remove toitoi and honroto 216 | hand_yaku.remove(config.yaku.toitoi) 217 | hand_yaku.remove(config.yaku.honroto) 218 | 219 | if config.yaku.suukantsu in hand_yaku: 220 | # for suukantsu we need to remove toitoi and sankantsu (sankantsu is already removed by default) 221 | if config.yaku.toitoi in hand_yaku: 222 | # same as above? 223 | hand_yaku.remove(config.yaku.toitoi) 224 | 225 | if config.yaku.chuuren_poutou in hand_yaku or config.yaku.daburu_chuuren_poutou in hand_yaku: 226 | # for chuuren poutou we need to remove chinitsu 227 | hand_yaku.remove(config.yaku.chinitsu) 228 | 229 | if config.yaku.daisharin in hand_yaku: 230 | # for daisharin we need to remove chinitsu, pinfu, tanyao, ryanpeiko, chiitoitsu 231 | hand_yaku.remove(config.yaku.chinitsu) 232 | if config.yaku.pinfu in hand_yaku: 233 | hand_yaku.remove(config.yaku.pinfu) 234 | hand_yaku.remove(config.yaku.tanyao) 235 | if config.yaku.ryanpeiko in hand_yaku: 236 | hand_yaku.remove(config.yaku.ryanpeiko) 237 | if config.yaku.chiitoitsu in hand_yaku: 238 | hand_yaku.remove(config.yaku.chiitoitsu) 239 | 240 | if config.yaku.ryuisou in hand_yaku: 241 | # for ryuisou we need to remove honitsu, if it is there 242 | if config.yaku.honitsu in hand_yaku: 243 | hand_yaku.remove(config.yaku.honitsu) 244 | -------------------------------------------------------------------------------- /tests/hand_calculating/tests_scores_calculation.py: -------------------------------------------------------------------------------- 1 | from mahjong.constants import EAST, WEST 2 | from mahjong.hand_calculating.hand_config import HandConfig, OptionalRules 3 | from mahjong.hand_calculating.scores import ScoresCalculator 4 | 5 | 6 | def test_calculate_scores_and_ron() -> None: 7 | hand = ScoresCalculator() 8 | config = HandConfig(options=OptionalRules(kazoe_limit=HandConfig.KAZOE_NO_LIMIT)) 9 | 10 | result = hand.calculate_scores(han=1, fu=30, config=config) 11 | assert result["main"] == 1000 12 | 13 | result = hand.calculate_scores(han=1, fu=110, config=config) 14 | assert result["main"] == 3600 15 | 16 | result = hand.calculate_scores(han=2, fu=30, config=config) 17 | assert result["main"] == 2000 18 | 19 | result = hand.calculate_scores(han=3, fu=30, config=config) 20 | assert result["main"] == 3900 21 | 22 | result = hand.calculate_scores(han=4, fu=30, config=config) 23 | assert result["main"] == 7700 24 | 25 | result = hand.calculate_scores(han=4, fu=40, config=config) 26 | assert result["main"] == 8000 27 | 28 | result = hand.calculate_scores(han=5, fu=0, config=config) 29 | assert result["main"] == 8000 30 | 31 | result = hand.calculate_scores(han=6, fu=0, config=config) 32 | assert result["main"] == 12000 33 | 34 | result = hand.calculate_scores(han=8, fu=0, config=config) 35 | assert result["main"] == 16000 36 | 37 | result = hand.calculate_scores(han=11, fu=0, config=config) 38 | assert result["main"] == 24000 39 | 40 | result = hand.calculate_scores(han=13, fu=0, config=config) 41 | assert result["main"] == 32000 42 | 43 | result = hand.calculate_scores(han=26, fu=0, config=config) 44 | assert result["main"] == 64000 45 | 46 | result = hand.calculate_scores(han=39, fu=0, config=config) 47 | assert result["main"] == 96000 48 | 49 | result = hand.calculate_scores(han=52, fu=0, config=config) 50 | assert result["main"] == 128000 51 | 52 | result = hand.calculate_scores(han=65, fu=0, config=config) 53 | assert result["main"] == 160000 54 | 55 | result = hand.calculate_scores(han=78, fu=0, config=config) 56 | assert result["main"] == 192000 57 | 58 | 59 | def test_calculate_scores_and_ron_by_dealer() -> None: 60 | hand = ScoresCalculator() 61 | config = HandConfig(player_wind=EAST, options=OptionalRules(kazoe_limit=HandConfig.KAZOE_NO_LIMIT)) 62 | 63 | result = hand.calculate_scores(han=1, fu=30, config=config) 64 | assert result["main"] == 1500 65 | 66 | result = hand.calculate_scores(han=2, fu=30, config=config) 67 | assert result["main"] == 2900 68 | 69 | result = hand.calculate_scores(han=3, fu=30, config=config) 70 | assert result["main"] == 5800 71 | 72 | result = hand.calculate_scores(han=4, fu=30, config=config) 73 | assert result["main"] == 11600 74 | 75 | result = hand.calculate_scores(han=5, fu=0, config=config) 76 | assert result["main"] == 12000 77 | 78 | result = hand.calculate_scores(han=6, fu=0, config=config) 79 | assert result["main"] == 18000 80 | 81 | result = hand.calculate_scores(han=8, fu=0, config=config) 82 | assert result["main"] == 24000 83 | 84 | result = hand.calculate_scores(han=11, fu=0, config=config) 85 | assert result["main"] == 36000 86 | 87 | result = hand.calculate_scores(han=13, fu=0, config=config) 88 | assert result["main"] == 48000 89 | 90 | result = hand.calculate_scores(han=26, fu=0, config=config) 91 | assert result["main"] == 96000 92 | 93 | result = hand.calculate_scores(han=39, fu=0, config=config) 94 | assert result["main"] == 144000 95 | 96 | result = hand.calculate_scores(han=52, fu=0, config=config) 97 | assert result["main"] == 192000 98 | 99 | result = hand.calculate_scores(han=65, fu=0, config=config) 100 | assert result["main"] == 240000 101 | 102 | result = hand.calculate_scores(han=78, fu=0, config=config) 103 | assert result["main"] == 288000 104 | 105 | 106 | def test_calculate_scores_and_tsumo() -> None: 107 | hand = ScoresCalculator() 108 | config = HandConfig(is_tsumo=True, options=OptionalRules(kazoe_limit=HandConfig.KAZOE_NO_LIMIT)) 109 | 110 | result = hand.calculate_scores(han=1, fu=30, config=config) 111 | assert result["main"] == 500 112 | assert result["additional"] == 300 113 | 114 | result = hand.calculate_scores(han=3, fu=30, config=config) 115 | assert result["main"] == 2000 116 | assert result["additional"] == 1000 117 | 118 | result = hand.calculate_scores(han=3, fu=60, config=config) 119 | assert result["main"] == 3900 120 | assert result["additional"] == 2000 121 | 122 | result = hand.calculate_scores(han=4, fu=30, config=config) 123 | assert result["main"] == 3900 124 | assert result["additional"] == 2000 125 | 126 | result = hand.calculate_scores(han=5, fu=0, config=config) 127 | assert result["main"] == 4000 128 | assert result["additional"] == 2000 129 | 130 | result = hand.calculate_scores(han=6, fu=0, config=config) 131 | assert result["main"] == 6000 132 | assert result["additional"] == 3000 133 | 134 | result = hand.calculate_scores(han=8, fu=0, config=config) 135 | assert result["main"] == 8000 136 | assert result["additional"] == 4000 137 | 138 | result = hand.calculate_scores(han=11, fu=0, config=config) 139 | assert result["main"] == 12000 140 | assert result["additional"] == 6000 141 | 142 | result = hand.calculate_scores(han=13, fu=0, config=config) 143 | assert result["main"] == 16000 144 | assert result["additional"] == 8000 145 | 146 | result = hand.calculate_scores(han=26, fu=0, config=config) 147 | assert result["main"] == 32000 148 | assert result["additional"] == 16000 149 | 150 | result = hand.calculate_scores(han=39, fu=0, config=config) 151 | assert result["main"] == 48000 152 | assert result["additional"] == 24000 153 | 154 | result = hand.calculate_scores(han=52, fu=0, config=config) 155 | assert result["main"] == 64000 156 | assert result["additional"] == 32000 157 | 158 | result = hand.calculate_scores(han=65, fu=0, config=config) 159 | assert result["main"] == 80000 160 | assert result["additional"] == 40000 161 | 162 | result = hand.calculate_scores(han=78, fu=0, config=config) 163 | assert result["main"] == 96000 164 | assert result["additional"] == 48000 165 | 166 | 167 | def test_calculate_scores_and_tsumo_by_dealer() -> None: 168 | hand = ScoresCalculator() 169 | config = HandConfig(player_wind=EAST, is_tsumo=True, options=OptionalRules(kazoe_limit=HandConfig.KAZOE_NO_LIMIT)) 170 | 171 | result = hand.calculate_scores(han=1, fu=30, config=config) 172 | assert result["main"] == 500 173 | assert result["additional"] == 500 174 | 175 | result = hand.calculate_scores(han=3, fu=30, config=config) 176 | assert result["main"] == 2000 177 | assert result["additional"] == 2000 178 | 179 | result = hand.calculate_scores(han=4, fu=30, config=config) 180 | assert result["main"] == 3900 181 | assert result["additional"] == 3900 182 | 183 | result = hand.calculate_scores(han=5, fu=0, config=config) 184 | assert result["main"] == 4000 185 | assert result["additional"] == 4000 186 | 187 | result = hand.calculate_scores(han=6, fu=0, config=config) 188 | assert result["main"] == 6000 189 | assert result["additional"] == 6000 190 | 191 | result = hand.calculate_scores(han=8, fu=0, config=config) 192 | assert result["main"] == 8000 193 | assert result["additional"] == 8000 194 | 195 | result = hand.calculate_scores(han=11, fu=0, config=config) 196 | assert result["main"] == 12000 197 | assert result["additional"] == 12000 198 | 199 | result = hand.calculate_scores(han=13, fu=0, config=config) 200 | assert result["main"] == 16000 201 | assert result["additional"] == 16000 202 | 203 | result = hand.calculate_scores(han=26, fu=0, config=config) 204 | assert result["main"] == 32000 205 | assert result["additional"] == 32000 206 | 207 | result = hand.calculate_scores(han=39, fu=0, config=config) 208 | assert result["main"] == 48000 209 | assert result["additional"] == 48000 210 | 211 | result = hand.calculate_scores(han=52, fu=0, config=config) 212 | assert result["main"] == 64000 213 | assert result["additional"] == 64000 214 | 215 | result = hand.calculate_scores(han=65, fu=0, config=config) 216 | assert result["main"] == 80000 217 | assert result["additional"] == 80000 218 | 219 | result = hand.calculate_scores(han=78, fu=0, config=config) 220 | assert result["main"] == 96000 221 | assert result["additional"] == 96000 222 | 223 | 224 | def test_calculate_scores_with_bonus() -> None: 225 | hand = ScoresCalculator() 226 | 227 | config = HandConfig(player_wind=EAST, is_tsumo=True, tsumi_number=2, kyoutaku_number=3) 228 | result = hand.calculate_scores(han=3, fu=30, config=config) 229 | assert result["main"] == 2000 230 | assert result["additional"] == 2000 231 | assert result["main_bonus"] == 200 232 | assert result["additional_bonus"] == 200 233 | assert result["kyoutaku_bonus"] == 3000 234 | assert result["total"] == 9600 235 | 236 | config = HandConfig(player_wind=WEST, is_tsumo=True, tsumi_number=4, kyoutaku_number=1) 237 | result = hand.calculate_scores(han=4, fu=30, config=config) 238 | assert result["main"] == 3900 239 | assert result["additional"] == 2000 240 | assert result["main_bonus"] == 400 241 | assert result["additional_bonus"] == 400 242 | assert result["kyoutaku_bonus"] == 1000 243 | assert result["total"] == 10100 244 | 245 | config = HandConfig(player_wind=WEST, tsumi_number=5) 246 | result = hand.calculate_scores(han=6, fu=30, config=config) 247 | assert result["main"] == 12000 248 | assert result["additional"] == 0 249 | assert result["main_bonus"] == 1500 250 | assert result["additional_bonus"] == 0 251 | assert result["kyoutaku_bonus"] == 0 252 | assert result["total"] == 13500 253 | 254 | config = HandConfig(player_wind=EAST, tsumi_number=5) 255 | result = hand.calculate_scores(han=5, fu=30, config=config) 256 | assert result["main"] == 12000 257 | assert result["additional"] == 0 258 | assert result["main_bonus"] == 1500 259 | assert result["additional_bonus"] == 0 260 | assert result["kyoutaku_bonus"] == 0 261 | assert result["total"] == 13500 262 | 263 | 264 | def test_kiriage_mangan() -> None: 265 | hand = ScoresCalculator() 266 | 267 | config = HandConfig(options=OptionalRules(kiriage=True)) 268 | 269 | result = hand.calculate_scores(han=4, fu=30, config=config) 270 | assert result["main"] == 8000 271 | 272 | result = hand.calculate_scores(han=3, fu=60, config=config) 273 | assert result["main"] == 8000 274 | 275 | config = HandConfig(player_wind=EAST, options=OptionalRules(kiriage=True)) 276 | 277 | result = hand.calculate_scores(han=4, fu=30, config=config) 278 | assert result["main"] == 12000 279 | 280 | result = hand.calculate_scores(han=3, fu=60, config=config) 281 | assert result["main"] == 12000 282 | -------------------------------------------------------------------------------- /tests/hand_calculating/tests_aotenjou.py: -------------------------------------------------------------------------------- 1 | from mahjong.constants import EAST 2 | from mahjong.hand_calculating.hand import HandCalculator 3 | from mahjong.hand_calculating.scores import Aotenjou 4 | from mahjong.meld import Meld 5 | from mahjong.tile import TilesConverter 6 | from tests.utils_for_tests import _make_hand_config, _make_meld, _string_to_136_tile 7 | 8 | 9 | def test_aotenjou_hands() -> None: 10 | hand = HandCalculator() 11 | 12 | tiles = TilesConverter.string_to_136_array(sou="119", man="19", pin="19", honors="1234567") 13 | win_tile = _string_to_136_tile(sou="1") 14 | 15 | result = hand.estimate_hand_value( 16 | tiles, 17 | win_tile, 18 | scores_calculator_factory=Aotenjou, 19 | config=_make_hand_config(player_wind=EAST, round_wind=EAST, disable_double_yakuman=True), 20 | ) 21 | assert result.error is None 22 | assert result.han == 13 23 | assert result.fu == 40 24 | assert len(result.yaku) == 1 25 | assert result.cost["main"] == 7864400 26 | 27 | tiles = TilesConverter.string_to_136_array(man="234", honors="11122233344") 28 | win_tile = _string_to_136_tile(man="2") 29 | melds = [ 30 | _make_meld(Meld.PON, honors="111"), 31 | _make_meld(Meld.PON, honors="333"), 32 | ] 33 | 34 | result = hand.estimate_hand_value( 35 | tiles, 36 | win_tile, 37 | melds=melds, 38 | scores_calculator_factory=Aotenjou, 39 | config=_make_hand_config(is_tsumo=True, player_wind=EAST, round_wind=EAST), 40 | ) 41 | assert result.error is None 42 | assert result.han == 17 43 | assert result.fu == 40 44 | assert len(result.yaku) == 4 45 | assert result.cost["main"] + result.cost["additional"] == 83886200 46 | 47 | tiles = TilesConverter.string_to_136_array(honors="11122233444777") 48 | win_tile = _string_to_136_tile(honors="2") 49 | melds = [ 50 | _make_meld(Meld.PON, honors="444"), 51 | ] 52 | 53 | result = hand.estimate_hand_value( 54 | tiles, 55 | win_tile, 56 | melds=melds, 57 | scores_calculator_factory=Aotenjou, 58 | config=_make_hand_config(is_tsumo=True, player_wind=EAST, round_wind=EAST), 59 | ) 60 | assert result.error is None 61 | assert result.han == 31 62 | assert result.fu == 50 63 | assert len(result.yaku) == 6 64 | assert result.cost["main"] + result.cost["additional"] == 1717986918400 65 | 66 | # monster hand for fun 67 | 68 | tiles = TilesConverter.string_to_136_array(honors="111133555566667777") 69 | win_tile = _string_to_136_tile(honors="3") 70 | 71 | melds = [ 72 | _make_meld(Meld.KAN, honors="1111", is_open=False), 73 | _make_meld(Meld.KAN, honors="5555", is_open=False), 74 | _make_meld(Meld.KAN, honors="6666", is_open=False), 75 | _make_meld(Meld.KAN, honors="7777", is_open=False), 76 | ] 77 | 78 | result = hand.estimate_hand_value( 79 | tiles, 80 | win_tile, 81 | melds=melds, 82 | dora_indicators=TilesConverter.string_to_136_array(honors="22224444"), 83 | scores_calculator_factory=Aotenjou, 84 | config=_make_hand_config( 85 | is_riichi=True, is_tsumo=True, is_ippatsu=True, is_haitei=True, player_wind=EAST, round_wind=EAST 86 | ), 87 | ) 88 | assert result.error is None 89 | assert result.han == 95 90 | assert result.fu == 160 91 | assert len(result.yaku) == 11 92 | assert result.cost["main"] + result.cost["additional"] == 101412048018258352119736256430200 93 | 94 | 95 | def test_daisangen() -> None: 96 | hand = HandCalculator() 97 | 98 | tiles = TilesConverter.string_to_136_array(sou="11123", honors="555666777") 99 | win_tile = _string_to_136_tile(sou="1") 100 | 101 | result = hand.estimate_hand_value( 102 | tiles, 103 | win_tile, 104 | scores_calculator_factory=Aotenjou, 105 | config=_make_hand_config(player_wind=EAST, round_wind=EAST), 106 | ) 107 | assert result.error is None 108 | assert result.fu == 60 109 | assert result.han == 20 110 | assert len(result.yaku) == 4 111 | assert result.cost["main"] == 1509949500 112 | 113 | 114 | def test_shousuushii() -> None: 115 | hand = HandCalculator() 116 | 117 | tiles = TilesConverter.string_to_136_array(sou="123", honors="11122233444") 118 | win_tile = _string_to_136_tile(honors="2") 119 | melds = [ 120 | _make_meld(Meld.PON, honors="444"), 121 | ] 122 | 123 | result = hand.estimate_hand_value( 124 | tiles, 125 | win_tile, 126 | melds=melds, 127 | scores_calculator_factory=Aotenjou, 128 | config=_make_hand_config(is_tsumo=True, player_wind=EAST, round_wind=EAST), 129 | ) 130 | assert result.error is None 131 | assert result.han == 18 132 | assert result.fu == 50 133 | assert len(result.yaku) == 5 134 | assert result.cost["main"] + result.cost["additional"] == 209715200 135 | 136 | 137 | def test_daisuushii() -> None: 138 | hand = HandCalculator() 139 | 140 | tiles = TilesConverter.string_to_136_array(sou="11", honors="111222333444") 141 | win_tile = _string_to_136_tile(honors="2") 142 | melds = [ 143 | _make_meld(Meld.PON, honors="444"), 144 | ] 145 | 146 | result = hand.estimate_hand_value( 147 | tiles, 148 | win_tile, 149 | melds=melds, 150 | scores_calculator_factory=Aotenjou, 151 | config=_make_hand_config(is_tsumo=True, player_wind=EAST, round_wind=EAST), 152 | ) 153 | assert result.error is None 154 | assert result.han == 34 155 | assert result.fu == 50 156 | assert len(result.yaku) == 6 157 | assert result.cost["main"] + result.cost["additional"] == 13743895347200 158 | 159 | 160 | def test_tsuuiisou() -> None: 161 | hand = HandCalculator() 162 | 163 | tiles = TilesConverter.string_to_136_array(honors="11133344455566") 164 | win_tile = _string_to_136_tile(honors="6") 165 | melds = [ 166 | _make_meld(Meld.PON, honors="444"), 167 | ] 168 | 169 | result = hand.estimate_hand_value( 170 | tiles, 171 | win_tile, 172 | melds=melds, 173 | scores_calculator_factory=Aotenjou, 174 | config=_make_hand_config(is_tsumo=True, player_wind=EAST, round_wind=EAST), 175 | ) 176 | assert result.error is None 177 | assert result.han == 18 178 | assert result.fu == 60 179 | assert len(result.yaku) == 5 180 | assert result.cost["main"] + result.cost["additional"] == 251658400 181 | 182 | 183 | def test_suuankou() -> None: 184 | hand = HandCalculator() 185 | 186 | tiles = TilesConverter.string_to_136_array(man="11133355566688") 187 | win_tile = _string_to_136_tile(man="8") 188 | 189 | result = hand.estimate_hand_value( 190 | tiles, 191 | win_tile, 192 | scores_calculator_factory=Aotenjou, 193 | config=_make_hand_config(is_tsumo=True, player_wind=EAST, round_wind=EAST), 194 | ) 195 | assert result.error is None 196 | assert result.han == 33 197 | assert result.fu == 50 198 | assert len(result.yaku) == 3 199 | assert result.cost["main"] + result.cost["additional"] == 6871947673600 200 | 201 | 202 | def test_chinroutou() -> None: 203 | hand = HandCalculator() 204 | 205 | tiles = TilesConverter.string_to_136_array(man="111999", sou="11999", pin="111") 206 | win_tile = _string_to_136_tile(sou="9") 207 | melds = [ 208 | _make_meld(Meld.PON, pin="111"), 209 | ] 210 | 211 | result = hand.estimate_hand_value( 212 | tiles, 213 | win_tile, 214 | melds=melds, 215 | scores_calculator_factory=Aotenjou, 216 | config=_make_hand_config(is_tsumo=True, player_wind=EAST, round_wind=EAST), 217 | ) 218 | assert result.error is None 219 | assert result.han == 15 220 | assert result.fu == 50 221 | assert len(result.yaku) == 2 222 | assert result.cost["main"] + result.cost["additional"] == 26214400 223 | 224 | 225 | def test_chuuren_poutou() -> None: 226 | hand = HandCalculator() 227 | 228 | tiles = TilesConverter.string_to_136_array(man="11112345678999") 229 | win_tile = _string_to_136_tile(man="1") 230 | 231 | result = hand.estimate_hand_value( 232 | tiles, 233 | win_tile, 234 | scores_calculator_factory=Aotenjou, 235 | config=_make_hand_config(is_tsumo=True, player_wind=EAST, round_wind=EAST), 236 | ) 237 | assert result.error is None 238 | assert result.han == 29 239 | assert result.fu == 30 240 | assert len(result.yaku) == 3 241 | assert result.cost["main"] + result.cost["additional"] == 257698037800 242 | 243 | 244 | def test_chuuren_poutou_inner() -> None: 245 | hand = HandCalculator() 246 | 247 | tiles = TilesConverter.string_to_136_array(man="11123455678999") 248 | win_tile = _string_to_136_tile(man="5") 249 | 250 | result = hand.estimate_hand_value( 251 | tiles, 252 | win_tile, 253 | scores_calculator_factory=Aotenjou, 254 | config=_make_hand_config(is_tsumo=True, player_wind=EAST, round_wind=EAST), 255 | ) 256 | assert result.error is None 257 | assert result.han == 27 258 | assert result.fu == 40 259 | assert len(result.yaku) == 2 260 | assert result.cost["main"] + result.cost["additional"] == 85899346000 261 | 262 | 263 | def test_suukantsu() -> None: 264 | hand = HandCalculator() 265 | 266 | tiles = TilesConverter.string_to_136_array(man="1111", sou="4444", pin="9999", honors="333322") 267 | win_tile = _string_to_136_tile(honors="2") 268 | 269 | melds = [ 270 | _make_meld(Meld.KAN, man="1111"), 271 | _make_meld(Meld.KAN, sou="4444"), 272 | _make_meld(Meld.KAN, pin="9999"), 273 | _make_meld(Meld.KAN, honors="3333"), 274 | ] 275 | 276 | result = hand.estimate_hand_value( 277 | tiles, 278 | win_tile, 279 | melds=melds, 280 | scores_calculator_factory=Aotenjou, 281 | config=_make_hand_config(is_tsumo=True, player_wind=EAST, round_wind=EAST), 282 | ) 283 | assert result.error is None 284 | assert result.han == 13 285 | assert result.fu == 80 286 | assert len(result.yaku) == 1 287 | assert result.cost["main"] + result.cost["additional"] == 10485800 288 | 289 | 290 | def test_daisharin() -> None: 291 | hand = HandCalculator() 292 | 293 | tiles = TilesConverter.string_to_136_array(pin="22334455667788") 294 | win_tile = _string_to_136_tile(pin="7") 295 | 296 | result = hand.estimate_hand_value( 297 | tiles, 298 | win_tile, 299 | scores_calculator_factory=Aotenjou, 300 | config=_make_hand_config(is_tsumo=True, player_wind=EAST, round_wind=EAST, allow_daisharin=True), 301 | ) 302 | assert result.error is None 303 | assert result.han == 14 304 | assert result.fu == 30 305 | assert len(result.yaku) == 2 306 | assert result.cost["main"] + result.cost["additional"] == 7864400 307 | 308 | 309 | def test_ryuisou() -> None: 310 | hand = HandCalculator() 311 | 312 | tiles = TilesConverter.string_to_136_array(sou="223344666888", honors="66") 313 | win_tile = _string_to_136_tile(sou="8") 314 | 315 | result = hand.estimate_hand_value( 316 | tiles, 317 | win_tile, 318 | scores_calculator_factory=Aotenjou, 319 | config=_make_hand_config(is_tsumo=True, player_wind=EAST, round_wind=EAST, allow_daisharin=True), 320 | ) 321 | assert result.error is None 322 | assert result.han == 15 323 | assert result.fu == 40 324 | assert len(result.yaku) == 3 325 | assert result.cost["main"] + result.cost["additional"] == 20971600 326 | --------------------------------------------------------------------------------