mahjong 2.1.0__tar.gz → 2.2.0.dev1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/MANIFEST.in +1 -0
- {mahjong-2.1.0/mahjong.egg-info → mahjong-2.2.0.dev1}/PKG-INFO +4 -30
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/README.md +3 -29
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/divider.py +18 -17
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/hand.py +77 -16
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/hand_config.py +2 -2
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/sanankou.py +1 -1
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/kokushi.py +2 -2
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/meld.py +52 -3
- {mahjong-2.1.0 → mahjong-2.2.0.dev1/mahjong.egg-info}/PKG-INFO +4 -30
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/pyproject.toml +16 -6
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/LICENSE.txt +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/__init__.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/agari.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/constants.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/__init__.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/fu.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/hand_response.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/scores.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_config.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/__init__.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/aka_dora.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/chankan.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/chantai.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/chiitoitsu.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/chinitsu.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/chun.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/daburu_open_riichi.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/daburu_riichi.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/dora.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/haitei.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/haku.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/hatsu.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/honitsu.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/honroto.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/houtei.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/iipeiko.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/ippatsu.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/ittsu.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/junchan.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/nagashi_mangan.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/open_riichi.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/pinfu.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/renhou.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/riichi.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/rinshan.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/round_wind_east.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/round_wind_north.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/round_wind_south.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/round_wind_west.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/ryanpeiko.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/sankantsu.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/sanshoku.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/sanshoku_douko.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/seat_wind_east.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/seat_wind_north.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/seat_wind_south.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/seat_wind_west.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/shosangen.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/tanyao.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/toitoi.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/tsumo.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/ura_dora.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/__init__.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/chiihou.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/chinroto.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/chuuren_poutou.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/daburu_chuuren_poutou.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/daburu_kokushi.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/daichisei.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/daisangen.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/daisharin.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/daisuushi.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/paarenchan.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/renhou_yakuman.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/ryuisou.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/sashikomi.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/shosuushi.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/suuankou.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/suuankou_tanki.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/suukantsu.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/tenhou.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/tsuisou.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/py.typed +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/shanten.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/tile.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/utils.py +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong.egg-info/SOURCES.txt +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong.egg-info/dependency_links.txt +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong.egg-info/top_level.txt +0 -0
- {mahjong-2.1.0 → mahjong-2.2.0.dev1}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mahjong
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.0.dev1
|
|
4
4
|
Summary: Mahjong hands calculation
|
|
5
5
|
Author-email: Alexey Lisikhin <alexey@nihisil.com>
|
|
6
6
|
Maintainer: Apricot S.
|
|
@@ -55,36 +55,10 @@ So, we can say that our hand calculator works the same way that tenhou.net hand
|
|
|
55
55
|
pip install mahjong
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
-
##
|
|
58
|
+
## Contributing
|
|
59
59
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
1. Clone the repository:
|
|
63
|
-
|
|
64
|
-
```bash
|
|
65
|
-
git clone https://github.com/MahjongRepository/mahjong.git
|
|
66
|
-
cd mahjong
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
2. Setup env using [uv](https://github.com/astral-sh/uv):
|
|
70
|
-
|
|
71
|
-
```bash
|
|
72
|
-
uv sync
|
|
73
|
-
```
|
|
74
|
-
|
|
75
|
-
3. Run tests to verify setup:
|
|
76
|
-
|
|
77
|
-
```bash
|
|
78
|
-
make tests
|
|
79
|
-
# Or directly:
|
|
80
|
-
uv run pytest
|
|
81
|
-
```
|
|
82
|
-
|
|
83
|
-
4. Run full checks before committing:
|
|
84
|
-
|
|
85
|
-
```bash
|
|
86
|
-
make check # Runs format, lint, and tests
|
|
87
|
-
```
|
|
60
|
+
For guidance on setting up a development environment and contributing to mahjong,
|
|
61
|
+
see [Contributing to mahjong](CONTRIBUTING.md).
|
|
88
62
|
|
|
89
63
|
## License
|
|
90
64
|
|
|
@@ -26,36 +26,10 @@ So, we can say that our hand calculator works the same way that tenhou.net hand
|
|
|
26
26
|
pip install mahjong
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
-
##
|
|
29
|
+
## Contributing
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
1. Clone the repository:
|
|
34
|
-
|
|
35
|
-
```bash
|
|
36
|
-
git clone https://github.com/MahjongRepository/mahjong.git
|
|
37
|
-
cd mahjong
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
2. Setup env using [uv](https://github.com/astral-sh/uv):
|
|
41
|
-
|
|
42
|
-
```bash
|
|
43
|
-
uv sync
|
|
44
|
-
```
|
|
45
|
-
|
|
46
|
-
3. Run tests to verify setup:
|
|
47
|
-
|
|
48
|
-
```bash
|
|
49
|
-
make tests
|
|
50
|
-
# Or directly:
|
|
51
|
-
uv run pytest
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
4. Run full checks before committing:
|
|
55
|
-
|
|
56
|
-
```bash
|
|
57
|
-
make check # Runs format, lint, and tests
|
|
58
|
-
```
|
|
31
|
+
For guidance on setting up a development environment and contributing to mahjong,
|
|
32
|
+
see [Contributing to mahjong](CONTRIBUTING.md).
|
|
59
33
|
|
|
60
34
|
## License
|
|
61
35
|
|
|
@@ -2,6 +2,7 @@ from collections.abc import Collection, Sequence
|
|
|
2
2
|
from dataclasses import dataclass
|
|
3
3
|
from enum import IntEnum
|
|
4
4
|
from functools import lru_cache
|
|
5
|
+
from itertools import product
|
|
5
6
|
from typing import Literal
|
|
6
7
|
|
|
7
8
|
from mahjong.meld import Meld
|
|
@@ -21,7 +22,7 @@ class _Block:
|
|
|
21
22
|
|
|
22
23
|
@classmethod
|
|
23
24
|
def from_meld(cls, meld: Meld) -> "_Block":
|
|
24
|
-
tile_34 = meld.
|
|
25
|
+
tile_34 = meld._sorted_tiles_34[0] # noqa: SLF001
|
|
25
26
|
match meld.type:
|
|
26
27
|
case Meld.CHI:
|
|
27
28
|
return cls(tile_34, _BlockType.SEQUENCE)
|
|
@@ -160,29 +161,27 @@ class HandDivider:
|
|
|
160
161
|
man_combinations = HandDivider._decompose_single_color_hand(hand[0:9], 0)
|
|
161
162
|
pin_combinations = HandDivider._decompose_single_color_hand(hand[9:18], 9)
|
|
162
163
|
sou_combinations = HandDivider._decompose_single_color_hand(hand[18:27], 18)
|
|
163
|
-
|
|
164
|
+
honors_combinations = HandDivider._decompose_honors_hand(hand[27:34])
|
|
164
165
|
|
|
165
166
|
combinations: list[list[_Block]] = []
|
|
166
167
|
|
|
167
168
|
if not melds and (chiitoitsu := HandDivider._decompose_chiitoitsu(hand)):
|
|
168
169
|
combinations.append(chiitoitsu)
|
|
169
170
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
# For invalid suits, *_combinations will be [] and the loop will not be executed.
|
|
174
|
-
all_blocks = [*man, *pin, *sou, *honors]
|
|
171
|
+
# For invalid suits or honors, *_combinations will be [] and the loop will not be executed.
|
|
172
|
+
for man, pin, sou, honors in product(man_combinations, pin_combinations, sou_combinations, honors_combinations):
|
|
173
|
+
all_blocks = [*man, *pin, *sou, *honors]
|
|
175
174
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
175
|
+
num_pair = sum(block.ty == _BlockType.PAIR for block in all_blocks)
|
|
176
|
+
if num_pair != 1:
|
|
177
|
+
continue
|
|
179
178
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
179
|
+
all_blocks.extend(melds)
|
|
180
|
+
if len(all_blocks) != 5:
|
|
181
|
+
continue
|
|
183
182
|
|
|
184
|
-
|
|
185
|
-
|
|
183
|
+
all_blocks.sort()
|
|
184
|
+
combinations.append(all_blocks)
|
|
186
185
|
|
|
187
186
|
combinations.sort()
|
|
188
187
|
return tuple(tuple(blocks) for blocks in combinations)
|
|
@@ -282,7 +281,9 @@ class HandDivider:
|
|
|
282
281
|
return combinations
|
|
283
282
|
|
|
284
283
|
@staticmethod
|
|
285
|
-
def _decompose_honors_hand(honors_hand: list[int]) -> list[_Block]:
|
|
284
|
+
def _decompose_honors_hand(honors_hand: list[int]) -> list[list[_Block]]:
|
|
285
|
+
# If there is no honor tile, returns [[]].
|
|
286
|
+
# If the honors cannot be decomposed, returns [].
|
|
286
287
|
has_pair = False
|
|
287
288
|
blocks: list[_Block] = []
|
|
288
289
|
for i, count in enumerate(honors_hand):
|
|
@@ -299,4 +300,4 @@ class HandDivider:
|
|
|
299
300
|
case _:
|
|
300
301
|
return []
|
|
301
302
|
|
|
302
|
-
return blocks
|
|
303
|
+
return [blocks]
|
|
@@ -56,6 +56,25 @@ class HandCalculator:
|
|
|
56
56
|
ERR_NO_YAKU = "no_yaku"
|
|
57
57
|
"""Hand can be decomposed but has zero han (no yaku applies)."""
|
|
58
58
|
|
|
59
|
+
# count validation
|
|
60
|
+
ERR_NEGATIVE_TSUMI_NUMBER = "negative_tsumi_number_not_allowed"
|
|
61
|
+
"""``HandConfig.tsumi_number`` (honba counters) is negative."""
|
|
62
|
+
ERR_NEGATIVE_KYOUTAKU_NUMBER = "negative_kyoutaku_number_not_allowed"
|
|
63
|
+
"""``HandConfig.kyoutaku_number`` (riichi deposits) is negative."""
|
|
64
|
+
ERR_NEGATIVE_PAARENCHAN = "negative_paarenchan_not_allowed"
|
|
65
|
+
"""``HandConfig.paarenchan`` (consecutive dealer wins) is negative."""
|
|
66
|
+
ERR_NEGATIVE_NUKI_DORA = "negative_nuki_dora_not_allowed"
|
|
67
|
+
"""``HandConfig.num_nuki_dora`` (extracted north winds) is negative."""
|
|
68
|
+
|
|
69
|
+
# meld validation
|
|
70
|
+
ERR_MELD_INVALID = "meld_invalid"
|
|
71
|
+
"""A meld fails :meth:`Meld.is_valid`: its tiles don't match its type, its type is unknown,
|
|
72
|
+
or a meld other than a kan or a nuki is closed."""
|
|
73
|
+
ERR_NUKI_MELD_NOT_SUPPORTED = "nuki_meld_not_supported"
|
|
74
|
+
"""A nuki meld was passed; pass nuki through ``HandConfig.num_nuki_dora`` instead."""
|
|
75
|
+
ERR_MELD_TILE_NOT_IN_HAND = "meld_tile_not_in_hand"
|
|
76
|
+
"""A meld tile index is not present in the ``tiles`` array."""
|
|
77
|
+
|
|
59
78
|
# riichi constraints
|
|
60
79
|
ERR_OPEN_HAND_RIICHI = "open_hand_riichi_not_allowed"
|
|
61
80
|
"""Riichi declared on an open hand (open melds present)."""
|
|
@@ -77,10 +96,14 @@ class HandCalculator:
|
|
|
77
96
|
"""Haitei and rinshan are mutually exclusive (different last-tile sources)."""
|
|
78
97
|
ERR_HOUTEI_WITH_CHANKAN = "houtei_with_chankan_not_allowed"
|
|
79
98
|
"""Houtei and chankan are mutually exclusive."""
|
|
99
|
+
ERR_RINSHAN_WITHOUT_KAN = "rinshan_without_kan_not_allowed"
|
|
100
|
+
"""Rinshan kaihou requires a kan meld, or an extracted north wind (nuki dora)."""
|
|
101
|
+
ERR_IPPATSU_WITH_RINSHAN = "ippatsu_with_rinshan_not_allowed"
|
|
102
|
+
"""Ippatsu and rinshan are mutually exclusive (declaring a kan cancels ippatsu)."""
|
|
80
103
|
|
|
81
104
|
# tenhou (blessing of heaven) constraints
|
|
82
105
|
ERR_TENHOU_NOT_AS_DEALER = "tenhou_not_as_dealer_not_allowed"
|
|
83
|
-
"""Tenhou is exclusive to the dealer
|
|
106
|
+
"""Tenhou is exclusive to the dealer: ``player_wind`` must be East."""
|
|
84
107
|
ERR_TENHOU_WITHOUT_TSUMO = "tenhou_without_tsumo_not_allowed"
|
|
85
108
|
"""Tenhou requires a tsumo win on the dealer's first draw."""
|
|
86
109
|
ERR_TENHOU_WITH_MELD = "tenhou_with_meld_not_allowed"
|
|
@@ -208,8 +231,19 @@ class HandCalculator:
|
|
|
208
231
|
|
|
209
232
|
is_aotenjou = isinstance(scores_calculator, Aotenjou)
|
|
210
233
|
|
|
211
|
-
|
|
212
|
-
|
|
234
|
+
is_open_hand = any(meld.opened for meld in melds)
|
|
235
|
+
|
|
236
|
+
if config.tsumi_number < 0:
|
|
237
|
+
return HandResponse(error=HandCalculator.ERR_NEGATIVE_TSUMI_NUMBER)
|
|
238
|
+
|
|
239
|
+
if config.kyoutaku_number < 0:
|
|
240
|
+
return HandResponse(error=HandCalculator.ERR_NEGATIVE_KYOUTAKU_NUMBER)
|
|
241
|
+
|
|
242
|
+
if config.paarenchan < 0:
|
|
243
|
+
return HandResponse(error=HandCalculator.ERR_NEGATIVE_PAARENCHAN)
|
|
244
|
+
|
|
245
|
+
if config.num_nuki_dora < 0:
|
|
246
|
+
return HandResponse(error=HandCalculator.ERR_NEGATIVE_NUKI_DORA)
|
|
213
247
|
|
|
214
248
|
# special situation
|
|
215
249
|
if config.is_nagashi_mangan:
|
|
@@ -222,6 +256,10 @@ class HandCalculator:
|
|
|
222
256
|
if win_tile not in tiles:
|
|
223
257
|
return HandResponse(error=HandCalculator.ERR_NO_WINNING_TILE)
|
|
224
258
|
|
|
259
|
+
for meld in melds:
|
|
260
|
+
if meld_error := HandCalculator._validate_meld(meld, tiles):
|
|
261
|
+
return HandResponse(error=meld_error)
|
|
262
|
+
|
|
225
263
|
if config.is_riichi and not config.is_daburu_riichi and is_open_hand:
|
|
226
264
|
return HandResponse(error=HandCalculator.ERR_OPEN_HAND_RIICHI)
|
|
227
265
|
|
|
@@ -249,8 +287,17 @@ class HandCalculator:
|
|
|
249
287
|
if config.is_houtei and config.is_chankan:
|
|
250
288
|
return HandResponse(error=HandCalculator.ERR_HOUTEI_WITH_CHANKAN)
|
|
251
289
|
|
|
252
|
-
|
|
253
|
-
|
|
290
|
+
if (
|
|
291
|
+
config.is_rinshan
|
|
292
|
+
and config.num_nuki_dora == 0
|
|
293
|
+
and not any(x.type in (Meld.KAN, Meld.SHOUMINKAN) for x in melds)
|
|
294
|
+
):
|
|
295
|
+
return HandResponse(error=HandCalculator.ERR_RINSHAN_WITHOUT_KAN)
|
|
296
|
+
|
|
297
|
+
if config.is_rinshan and config.is_ippatsu:
|
|
298
|
+
return HandResponse(error=HandCalculator.ERR_IPPATSU_WITH_RINSHAN)
|
|
299
|
+
|
|
300
|
+
if config.is_tenhou and not config.is_dealer:
|
|
254
301
|
return HandResponse(error=HandCalculator.ERR_TENHOU_NOT_AS_DEALER)
|
|
255
302
|
|
|
256
303
|
if config.is_tenhou and not config.is_tsumo:
|
|
@@ -319,6 +366,7 @@ class HandCalculator:
|
|
|
319
366
|
)
|
|
320
367
|
|
|
321
368
|
calculated_hands: list[_CalculatedHand] = []
|
|
369
|
+
meld_blocks = [meld._sorted_tiles_34 for meld in melds] # noqa: SLF001
|
|
322
370
|
for hand in hand_options:
|
|
323
371
|
is_chiitoitsu = config.yaku.chiitoitsu.is_condition_met(hand)
|
|
324
372
|
valued_tiles = [HAKU, HATSU, CHUN, config.player_wind, config.round_wind]
|
|
@@ -342,7 +390,7 @@ class HandCalculator:
|
|
|
342
390
|
suit_mask, honor_count = classify_hand_suits(hand)
|
|
343
391
|
has_honors = honor_count > 0
|
|
344
392
|
|
|
345
|
-
win_groups = HandCalculator._find_win_groups(win_tile, hand,
|
|
393
|
+
win_groups = HandCalculator._find_win_groups(win_tile, hand, meld_blocks)
|
|
346
394
|
for win_group in win_groups:
|
|
347
395
|
cost = None
|
|
348
396
|
error = None
|
|
@@ -596,7 +644,7 @@ class HandCalculator:
|
|
|
596
644
|
calculated_hands.append(calculated_hand)
|
|
597
645
|
|
|
598
646
|
# exception hand
|
|
599
|
-
if not
|
|
647
|
+
if not melds and config.yaku.kokushi.is_condition_met(None, tiles_34):
|
|
600
648
|
if tiles_34[win_tile // 4] == 2:
|
|
601
649
|
hand_yaku.append(config.yaku.daburu_kokushi)
|
|
602
650
|
else:
|
|
@@ -687,25 +735,38 @@ class HandCalculator:
|
|
|
687
735
|
)
|
|
688
736
|
|
|
689
737
|
@staticmethod
|
|
690
|
-
def
|
|
738
|
+
def _validate_meld(meld: Meld, tiles: Collection[int]) -> str | None:
|
|
739
|
+
if not meld.is_valid():
|
|
740
|
+
return HandCalculator.ERR_MELD_INVALID
|
|
741
|
+
|
|
742
|
+
if meld.type == Meld.NUKI:
|
|
743
|
+
return HandCalculator.ERR_NUKI_MELD_NOT_SUPPORTED
|
|
744
|
+
|
|
745
|
+
if any(tile not in tiles for tile in meld.tiles):
|
|
746
|
+
return HandCalculator.ERR_MELD_TILE_NOT_IN_HAND
|
|
747
|
+
|
|
748
|
+
return None
|
|
749
|
+
|
|
750
|
+
@staticmethod
|
|
751
|
+
def _find_win_groups(win_tile: int, hand: list[list[int]], meld_blocks: list[list[int]]) -> list[list[int]]:
|
|
691
752
|
win_tile_34 = (win_tile or 0) // 4
|
|
692
753
|
|
|
693
|
-
# track which
|
|
694
|
-
consumed = [False] * len(
|
|
754
|
+
# track which declared melds have been matched using a consumed flags array
|
|
755
|
+
consumed = [False] * len(meld_blocks)
|
|
695
756
|
|
|
696
|
-
# collect
|
|
757
|
+
# collect undeclared sets and find unique win groups in a single pass
|
|
697
758
|
seen: set[tuple[int, ...]] = set()
|
|
698
759
|
win_groups: list[list[int]] = []
|
|
699
760
|
for x in hand:
|
|
700
|
-
# check if this set matches an unconsumed
|
|
701
|
-
|
|
702
|
-
for i, meld in enumerate(
|
|
761
|
+
# check if this set matches an unconsumed declared meld
|
|
762
|
+
is_declared_meld = False
|
|
763
|
+
for i, meld in enumerate(meld_blocks):
|
|
703
764
|
if not consumed[i] and x == meld:
|
|
704
765
|
consumed[i] = True
|
|
705
|
-
|
|
766
|
+
is_declared_meld = True
|
|
706
767
|
break
|
|
707
768
|
|
|
708
|
-
if
|
|
769
|
+
if is_declared_meld:
|
|
709
770
|
continue
|
|
710
771
|
|
|
711
772
|
# for forms like 45666 and ron on 6
|
|
@@ -206,7 +206,7 @@ class HandConfig(HandConstants):
|
|
|
206
206
|
:vartype is_daburu_riichi: bool
|
|
207
207
|
:ivar is_nagashi_mangan: all discards are terminals and honors with no calls against them
|
|
208
208
|
:vartype is_nagashi_mangan: bool
|
|
209
|
-
:ivar is_tenhou: dealer wins on the initial draw
|
|
209
|
+
:ivar is_tenhou: dealer wins on the initial draw; requires ``player_wind`` to be East
|
|
210
210
|
:vartype is_tenhou: bool
|
|
211
211
|
:ivar is_renhou: non-dealer wins on the first go-around before any calls
|
|
212
212
|
:vartype is_renhou: bool
|
|
@@ -305,7 +305,7 @@ class HandConfig(HandConstants):
|
|
|
305
305
|
:param is_houtei: win on the discard following the last drawable tile
|
|
306
306
|
:param is_daburu_riichi: player declared double riichi
|
|
307
307
|
:param is_nagashi_mangan: all discards are terminals and honors
|
|
308
|
-
:param is_tenhou: dealer wins on the initial draw
|
|
308
|
+
:param is_tenhou: dealer wins on the initial draw; requires ``player_wind`` to be East
|
|
309
309
|
:param is_renhou: non-dealer wins on the first go-around
|
|
310
310
|
:param is_chiihou: non-dealer wins on the initial draw
|
|
311
311
|
:param is_open_riichi: player declared open riichi
|
|
@@ -21,9 +21,9 @@ class KokushiMusou(Yaku):
|
|
|
21
21
|
|
|
22
22
|
:param hand: not used for kokushi; pass None
|
|
23
23
|
:param tiles_34: hand in 34-format count array
|
|
24
|
-
:return: True if the hand
|
|
24
|
+
:return: True if the hand is exactly 14 tiles: one of each terminal and honor with exactly one duplicate
|
|
25
25
|
"""
|
|
26
|
-
return (
|
|
26
|
+
return sum(tiles_34) == 14 and (
|
|
27
27
|
tiles_34[0]
|
|
28
28
|
* tiles_34[8]
|
|
29
29
|
* tiles_34[9]
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
from collections.abc import Sequence
|
|
2
2
|
from functools import cached_property
|
|
3
3
|
|
|
4
|
+
from mahjong.constants import NORTH
|
|
4
5
|
from mahjong.tile import TilesConverter
|
|
6
|
+
from mahjong.utils import is_chi, is_honor, is_pon
|
|
5
7
|
|
|
6
8
|
|
|
7
9
|
class Meld:
|
|
@@ -108,10 +110,11 @@ class Meld:
|
|
|
108
110
|
self.from_who = from_who
|
|
109
111
|
|
|
110
112
|
def __setattr__(self, name: str, value: object) -> None:
|
|
111
|
-
"""Invalidate the tiles_34
|
|
113
|
+
"""Invalidate the tiles_34 caches when tiles are reassigned."""
|
|
112
114
|
super().__setattr__(name, value)
|
|
113
|
-
if name == "tiles"
|
|
114
|
-
|
|
115
|
+
if name == "tiles":
|
|
116
|
+
self.__dict__.pop("tiles_34", None)
|
|
117
|
+
self.__dict__.pop("_sorted_tiles_34", None)
|
|
115
118
|
|
|
116
119
|
def __str__(self) -> str:
|
|
117
120
|
"""
|
|
@@ -145,3 +148,49 @@ class Meld:
|
|
|
145
148
|
:return: list of tile indices in 34-format
|
|
146
149
|
"""
|
|
147
150
|
return [x // 4 for x in self.tiles]
|
|
151
|
+
|
|
152
|
+
@cached_property
|
|
153
|
+
def _sorted_tiles_34(self) -> list[int]:
|
|
154
|
+
"""
|
|
155
|
+
Return the meld's 34-format tile indices sorted, so a chi given as 987p reads as 789p.
|
|
156
|
+
|
|
157
|
+
:return: sorted list of tile indices in 34-format
|
|
158
|
+
"""
|
|
159
|
+
return sorted(self.tiles_34)
|
|
160
|
+
|
|
161
|
+
def is_valid(self) -> bool:
|
|
162
|
+
"""
|
|
163
|
+
Check that the meld tiles match its type and that only a kan is closed.
|
|
164
|
+
|
|
165
|
+
A chi is three consecutive tiles of one suit, a pon is three identical tiles, a kan
|
|
166
|
+
or shouminkan is four identical tiles, a nuki is one to four north winds. Chi, pon and
|
|
167
|
+
shouminkan are always called, so only a kan or a nuki may be closed. Unknown types
|
|
168
|
+
are not valid.
|
|
169
|
+
|
|
170
|
+
>>> from mahjong.meld import Meld
|
|
171
|
+
>>> from mahjong.tile import TilesConverter
|
|
172
|
+
>>> Meld(meld_type=Meld.CHI, tiles=TilesConverter.string_to_136_array(man="123")).is_valid()
|
|
173
|
+
True
|
|
174
|
+
|
|
175
|
+
>>> Meld(meld_type=Meld.CHI, tiles=TilesConverter.string_to_136_array(man="89", pin="1")).is_valid()
|
|
176
|
+
False
|
|
177
|
+
|
|
178
|
+
>>> Meld(meld_type=Meld.PON, tiles=TilesConverter.string_to_136_array(man="111"), opened=False).is_valid()
|
|
179
|
+
False
|
|
180
|
+
|
|
181
|
+
:return: True if the meld is valid
|
|
182
|
+
"""
|
|
183
|
+
tiles_34 = self._sorted_tiles_34
|
|
184
|
+
match self.type:
|
|
185
|
+
case Meld.CHI:
|
|
186
|
+
is_valid_shape = is_chi(tiles_34) and not is_honor(tiles_34[0]) and tiles_34[0] // 9 == tiles_34[2] // 9
|
|
187
|
+
case Meld.PON:
|
|
188
|
+
is_valid_shape = is_pon(tiles_34)
|
|
189
|
+
case Meld.KAN | Meld.SHOUMINKAN:
|
|
190
|
+
is_valid_shape = len(tiles_34) == 4 and len(set(tiles_34)) == 1
|
|
191
|
+
case Meld.NUKI:
|
|
192
|
+
is_valid_shape = 0 < len(tiles_34) <= 4 and all(tile == NORTH for tile in tiles_34)
|
|
193
|
+
case _:
|
|
194
|
+
return False
|
|
195
|
+
|
|
196
|
+
return is_valid_shape and (self.opened or self.type in (Meld.KAN, Meld.NUKI))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mahjong
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.0.dev1
|
|
4
4
|
Summary: Mahjong hands calculation
|
|
5
5
|
Author-email: Alexey Lisikhin <alexey@nihisil.com>
|
|
6
6
|
Maintainer: Apricot S.
|
|
@@ -55,36 +55,10 @@ So, we can say that our hand calculator works the same way that tenhou.net hand
|
|
|
55
55
|
pip install mahjong
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
-
##
|
|
58
|
+
## Contributing
|
|
59
59
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
1. Clone the repository:
|
|
63
|
-
|
|
64
|
-
```bash
|
|
65
|
-
git clone https://github.com/MahjongRepository/mahjong.git
|
|
66
|
-
cd mahjong
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
2. Setup env using [uv](https://github.com/astral-sh/uv):
|
|
70
|
-
|
|
71
|
-
```bash
|
|
72
|
-
uv sync
|
|
73
|
-
```
|
|
74
|
-
|
|
75
|
-
3. Run tests to verify setup:
|
|
76
|
-
|
|
77
|
-
```bash
|
|
78
|
-
make tests
|
|
79
|
-
# Or directly:
|
|
80
|
-
uv run pytest
|
|
81
|
-
```
|
|
82
|
-
|
|
83
|
-
4. Run full checks before committing:
|
|
84
|
-
|
|
85
|
-
```bash
|
|
86
|
-
make check # Runs format, lint, and tests
|
|
87
|
-
```
|
|
60
|
+
For guidance on setting up a development environment and contributing to mahjong,
|
|
61
|
+
see [Contributing to mahjong](CONTRIBUTING.md).
|
|
88
62
|
|
|
89
63
|
## License
|
|
90
64
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "mahjong"
|
|
3
|
-
version = "2.
|
|
3
|
+
version = "2.2.0.dev1"
|
|
4
4
|
description = "Mahjong hands calculation"
|
|
5
5
|
authors = [
|
|
6
6
|
{ name = "Alexey Lisikhin", email = "alexey@nihisil.com" },
|
|
@@ -51,18 +51,24 @@ mahjong = ["py.typed"]
|
|
|
51
51
|
|
|
52
52
|
[dependency-groups]
|
|
53
53
|
dev = [
|
|
54
|
-
{ include-group = "
|
|
55
|
-
{ include-group = "typing" },
|
|
54
|
+
{ include-group = "hooks" },
|
|
56
55
|
{ include-group = "test" },
|
|
57
56
|
{ include-group = "docs" },
|
|
58
57
|
]
|
|
58
|
+
hooks = [
|
|
59
|
+
"prek>=0.5.7,<0.6",
|
|
60
|
+
{ include-group = "lint" },
|
|
61
|
+
{ include-group = "typing" },
|
|
62
|
+
]
|
|
59
63
|
lint = [
|
|
60
64
|
"ruff>=0.17.1,<0.18",
|
|
61
65
|
]
|
|
62
66
|
typing = [
|
|
67
|
+
"tomli>=2.5.0",
|
|
63
68
|
"ty>=0.0.42",
|
|
64
69
|
]
|
|
65
70
|
test = [
|
|
71
|
+
"tomli>=2.5.0; python_version < '3.11'",
|
|
66
72
|
"pytest>=9.0.2,<10",
|
|
67
73
|
"pytest-cov>=7.0.0,<8",
|
|
68
74
|
]
|
|
@@ -115,6 +121,12 @@ convention = "pep257"
|
|
|
115
121
|
"T201", # print
|
|
116
122
|
"INP001", # implicit-namespace-package
|
|
117
123
|
]
|
|
124
|
+
"./scripts/*" = [
|
|
125
|
+
"D", # pydocstyle
|
|
126
|
+
"T201", # print
|
|
127
|
+
"INP001", # implicit-namespace-package
|
|
128
|
+
"S101", # assert
|
|
129
|
+
]
|
|
118
130
|
"./tests/*" = [
|
|
119
131
|
"D", # pydocstyle
|
|
120
132
|
"S101", # assert
|
|
@@ -124,7 +136,7 @@ convention = "pep257"
|
|
|
124
136
|
"mahjong/hand_calculating/yaku_list/*.py" = ["ARG002"] # for `is_condition_met()`
|
|
125
137
|
|
|
126
138
|
[tool.ty.src]
|
|
127
|
-
include = ["mahjong"]
|
|
139
|
+
include = ["mahjong", "scripts"]
|
|
128
140
|
|
|
129
141
|
[[tool.ty.overrides]]
|
|
130
142
|
include = ["mahjong/hand_calculating/yaku_list/**"] # for `is_condition_met()`
|
|
@@ -135,8 +147,6 @@ invalid-method-override = "ignore"
|
|
|
135
147
|
[tool.pytest]
|
|
136
148
|
python_files = ["tests_*.py"]
|
|
137
149
|
addopts = [
|
|
138
|
-
"-p",
|
|
139
|
-
"no:warnings",
|
|
140
150
|
"--no-header",
|
|
141
151
|
"--tb=short",
|
|
142
152
|
"-qq",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/daburu_open_riichi.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/chuuren_poutou.py
RENAMED
|
File without changes
|
|
File without changes
|
{mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/daburu_kokushi.py
RENAMED
|
File without changes
|
{mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/daichisei.py
RENAMED
|
File without changes
|
{mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/daisangen.py
RENAMED
|
File without changes
|
{mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/daisharin.py
RENAMED
|
File without changes
|
{mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/daisuushi.py
RENAMED
|
File without changes
|
{mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/paarenchan.py
RENAMED
|
File without changes
|
{mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/renhou_yakuman.py
RENAMED
|
File without changes
|
|
File without changes
|
{mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/sashikomi.py
RENAMED
|
File without changes
|
{mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/shosuushi.py
RENAMED
|
File without changes
|
|
File without changes
|
{mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/suuankou_tanki.py
RENAMED
|
File without changes
|
{mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/suukantsu.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|