mahjong 2.0.0.dev2__py3-none-any.whl → 2.2.0.dev1__py3-none-any.whl
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/__init__.py +25 -6
- mahjong/hand_calculating/__init__.py +23 -0
- mahjong/hand_calculating/divider.py +18 -17
- mahjong/hand_calculating/fu.py +1 -1
- mahjong/hand_calculating/hand.py +226 -45
- mahjong/hand_calculating/hand_config.py +210 -11
- mahjong/hand_calculating/hand_response.py +78 -7
- mahjong/hand_calculating/scores.py +133 -41
- mahjong/hand_calculating/yaku.py +66 -6
- mahjong/hand_calculating/yaku_config.py +110 -55
- mahjong/hand_calculating/yaku_list/__init__.py +12 -0
- mahjong/hand_calculating/yaku_list/aka_dora.py +3 -3
- mahjong/hand_calculating/yaku_list/chankan.py +2 -4
- mahjong/hand_calculating/yaku_list/chantai.py +4 -2
- mahjong/hand_calculating/yaku_list/chiitoitsu.py +2 -3
- mahjong/hand_calculating/yaku_list/chinitsu.py +2 -3
- mahjong/hand_calculating/yaku_list/chun.py +2 -3
- mahjong/hand_calculating/yaku_list/daburu_open_riichi.py +2 -4
- mahjong/hand_calculating/yaku_list/daburu_riichi.py +2 -4
- mahjong/hand_calculating/yaku_list/dora.py +4 -0
- mahjong/hand_calculating/yaku_list/haitei.py +2 -4
- mahjong/hand_calculating/yaku_list/haku.py +2 -3
- mahjong/hand_calculating/yaku_list/hatsu.py +2 -3
- mahjong/hand_calculating/yaku_list/honitsu.py +2 -3
- mahjong/hand_calculating/yaku_list/honroto.py +2 -3
- mahjong/hand_calculating/yaku_list/houtei.py +2 -4
- mahjong/hand_calculating/yaku_list/iipeiko.py +2 -3
- mahjong/hand_calculating/yaku_list/ippatsu.py +2 -4
- mahjong/hand_calculating/yaku_list/ittsu.py +2 -3
- mahjong/hand_calculating/yaku_list/junchan.py +4 -3
- mahjong/hand_calculating/yaku_list/nagashi_mangan.py +2 -4
- mahjong/hand_calculating/yaku_list/open_riichi.py +3 -0
- mahjong/hand_calculating/yaku_list/pinfu.py +2 -3
- mahjong/hand_calculating/yaku_list/renhou.py +2 -4
- mahjong/hand_calculating/yaku_list/riichi.py +3 -0
- mahjong/hand_calculating/yaku_list/rinshan.py +2 -4
- mahjong/hand_calculating/yaku_list/round_wind_east.py +8 -3
- mahjong/hand_calculating/yaku_list/round_wind_north.py +8 -3
- mahjong/hand_calculating/yaku_list/round_wind_south.py +8 -3
- mahjong/hand_calculating/yaku_list/round_wind_west.py +8 -3
- mahjong/hand_calculating/yaku_list/ryanpeiko.py +2 -3
- mahjong/hand_calculating/yaku_list/sanankou.py +11 -4
- mahjong/hand_calculating/yaku_list/sankantsu.py +8 -3
- mahjong/hand_calculating/yaku_list/sanshoku.py +2 -3
- mahjong/hand_calculating/yaku_list/sanshoku_douko.py +2 -3
- mahjong/hand_calculating/yaku_list/seat_wind_east.py +8 -3
- mahjong/hand_calculating/yaku_list/seat_wind_north.py +8 -3
- mahjong/hand_calculating/yaku_list/seat_wind_south.py +8 -3
- mahjong/hand_calculating/yaku_list/seat_wind_west.py +8 -3
- mahjong/hand_calculating/yaku_list/shosangen.py +2 -3
- mahjong/hand_calculating/yaku_list/tanyao.py +2 -3
- mahjong/hand_calculating/yaku_list/toitoi.py +2 -3
- mahjong/hand_calculating/yaku_list/tsumo.py +2 -4
- mahjong/hand_calculating/yaku_list/ura_dora.py +4 -0
- mahjong/hand_calculating/yaku_list/yakuman/__init__.py +9 -0
- mahjong/hand_calculating/yaku_list/yakuman/chiihou.py +3 -0
- mahjong/hand_calculating/yaku_list/yakuman/chinroto.py +3 -5
- mahjong/hand_calculating/yaku_list/yakuman/chuuren_poutou.py +2 -3
- mahjong/hand_calculating/yaku_list/yakuman/daburu_chuuren_poutou.py +3 -1
- mahjong/hand_calculating/yaku_list/yakuman/daburu_kokushi.py +3 -1
- mahjong/hand_calculating/yaku_list/yakuman/daichisei.py +2 -3
- mahjong/hand_calculating/yaku_list/yakuman/daisangen.py +2 -3
- mahjong/hand_calculating/yaku_list/yakuman/daisharin.py +18 -4
- mahjong/hand_calculating/yaku_list/yakuman/daisuushi.py +2 -3
- mahjong/hand_calculating/yaku_list/yakuman/kokushi.py +11 -3
- mahjong/hand_calculating/yaku_list/yakuman/paarenchan.py +4 -4
- mahjong/hand_calculating/yaku_list/yakuman/renhou_yakuman.py +2 -4
- mahjong/hand_calculating/yaku_list/yakuman/ryuisou.py +2 -3
- mahjong/hand_calculating/yaku_list/yakuman/sashikomi.py +2 -4
- mahjong/hand_calculating/yaku_list/yakuman/shosuushi.py +2 -3
- mahjong/hand_calculating/yaku_list/yakuman/suuankou.py +9 -3
- mahjong/hand_calculating/yaku_list/yakuman/suuankou_tanki.py +3 -0
- mahjong/hand_calculating/yaku_list/yakuman/suukantsu.py +8 -3
- mahjong/hand_calculating/yaku_list/yakuman/tenhou.py +2 -4
- mahjong/hand_calculating/yaku_list/yakuman/tsuisou.py +2 -8
- mahjong/meld.py +52 -3
- mahjong/shanten.py +57 -21
- mahjong/tile.py +2 -0
- mahjong/utils.py +28 -4
- {mahjong-2.0.0.dev2.dist-info → mahjong-2.2.0.dev1.dist-info}/METADATA +13 -34
- mahjong-2.2.0.dev1.dist-info/RECORD +87 -0
- {mahjong-2.0.0.dev2.dist-info → mahjong-2.2.0.dev1.dist-info}/WHEEL +1 -1
- mahjong-2.0.0.dev2.dist-info/RECORD +0 -87
- {mahjong-2.0.0.dev2.dist-info → mahjong-2.2.0.dev1.dist-info}/licenses/LICENSE.txt +0 -0
- {mahjong-2.0.0.dev2.dist-info → mahjong-2.2.0.dev1.dist-info}/top_level.txt +0 -0
mahjong/__init__.py
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
"""
|
|
2
|
-
|
|
2
|
+
Japanese riichi mahjong hand calculator library.
|
|
3
|
+
|
|
4
|
+
Compute hand cost (han, fu, yaku, and scores), validate winning hands, and calculate shanten (minimum tiles to tenpai).
|
|
5
|
+
|
|
6
|
+
.. rubric:: Main entry points
|
|
7
|
+
|
|
8
|
+
* :class:`~mahjong.hand_calculating.hand.HandCalculator` — estimate hand value (han, fu, cost,
|
|
9
|
+
and yaku list) given tiles, win tile, melds, and dora indicators.
|
|
10
|
+
* :class:`~mahjong.agari.Agari` — check whether tiles form a complete (winning) hand structure.
|
|
11
|
+
* :class:`~mahjong.shanten.Shanten` — calculate how many tiles away a hand is from winning.
|
|
12
|
+
|
|
13
|
+
.. rubric:: Tile and meld utilities
|
|
14
|
+
|
|
15
|
+
* :class:`~mahjong.tile.TilesConverter` — convert between mpsz-notation strings, 34-format count
|
|
16
|
+
arrays, and 136-format tile index lists.
|
|
17
|
+
* :class:`~mahjong.meld.Meld` — represent declared melds (chi, pon, kan).
|
|
18
|
+
|
|
19
|
+
.. rubric:: Configuration and results
|
|
20
|
+
|
|
21
|
+
* :class:`~mahjong.hand_calculating.hand_config.HandConfig` — configure win conditions (tsumo/ron),
|
|
22
|
+
wind context, and optional rule variants (open tanyao, aka dora, double yakuman, etc.).
|
|
23
|
+
* :class:`~mahjong.hand_calculating.hand_response.HandResponse` — structured result containing
|
|
24
|
+
han, fu, fu details, cost breakdown, and yaku list.
|
|
3
25
|
|
|
4
26
|
Tile representation
|
|
5
27
|
###################
|
|
@@ -30,11 +52,8 @@ Honors (`z`) use ranks to distinguish individual tiles:
|
|
|
30
52
|
* `6` - Green dragon (發, hatsu)
|
|
31
53
|
* `7` - Red dragon (中, chun)
|
|
32
54
|
|
|
33
|
-
Multiple tiles may be concatenated without separators to form a string that represents a collection
|
|
34
|
-
|
|
35
|
-
``123m406p789s11555z``
|
|
36
|
-
|
|
37
|
-
is equivalent to ``1m2m3m4p0p6p7s8s9s1z1z5z5z5z``.
|
|
55
|
+
Multiple tiles may be concatenated without separators to form a string that represents a collection
|
|
56
|
+
(e.g., ``123m406p789s11555z`` is equivalent to ``1m2m3m4p0p6p7s8s9s1z1z5z5z5z``).
|
|
38
57
|
|
|
39
58
|
34-format
|
|
40
59
|
---------
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Hand value calculation pipeline.
|
|
3
|
+
|
|
4
|
+
This subpackage evaluates a winning hand and produces its han, fu, yaku list,
|
|
5
|
+
and payment amounts. The calculation flows through several stages:
|
|
6
|
+
|
|
7
|
+
1. :class:`~mahjong.hand_calculating.divider.HandDivider` decomposes the hand into all
|
|
8
|
+
valid block combinations (4 melds + 1 pair, or 7 pairs for chiitoitsu).
|
|
9
|
+
2. :class:`~mahjong.hand_calculating.yaku.Yaku` subclasses test each decomposition for
|
|
10
|
+
matching yaku patterns (e.g., tanyao, pinfu, honitsu).
|
|
11
|
+
3. :class:`~mahjong.hand_calculating.fu.FuCalculator` computes minipoints from the meld
|
|
12
|
+
structure, wait type, pair, and winning method.
|
|
13
|
+
4. :class:`~mahjong.hand_calculating.scores.ScoresCalculator` converts han and fu into
|
|
14
|
+
payment amounts, accounting for honba and kyoutaku bonuses.
|
|
15
|
+
|
|
16
|
+
:class:`~mahjong.hand_calculating.hand.HandCalculator` orchestrates the full pipeline and
|
|
17
|
+
returns a :class:`~mahjong.hand_calculating.hand_response.HandResponse` with the
|
|
18
|
+
highest-scoring result.
|
|
19
|
+
|
|
20
|
+
Behavior is controlled by :class:`~mahjong.hand_calculating.hand_config.HandConfig`, which
|
|
21
|
+
specifies win conditions (tsumo/ron), wind context, and optional rule variants such as
|
|
22
|
+
open tanyao, aka dora, and double yakuman.
|
|
23
|
+
"""
|
|
@@ -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]
|
mahjong/hand_calculating/fu.py
CHANGED
|
@@ -169,7 +169,7 @@ class FuCalculator:
|
|
|
169
169
|
as tile indices in 34-format
|
|
170
170
|
:param config: hand configuration with win method and optional rule settings
|
|
171
171
|
:param valued_tiles: tile indices in 34-format for tiles that grant pair fu
|
|
172
|
-
(dragons,
|
|
172
|
+
(dragons, seat wind, round wind); pass the same index twice for double-valued
|
|
173
173
|
:param melds: declared melds (chi, pon, kan)
|
|
174
174
|
:return: tuple of (fu component list, total fu rounded up to nearest 10)
|
|
175
175
|
"""
|
mahjong/hand_calculating/hand.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from collections.abc import Collection
|
|
2
2
|
from typing import TypedDict
|
|
3
3
|
|
|
4
|
-
from mahjong.constants import AKA_DORAS, CHUN, HAKU, HATSU
|
|
4
|
+
from mahjong.constants import AKA_DORAS, CHUN, HAKU, HATSU, NORTH
|
|
5
5
|
from mahjong.hand_calculating.divider import HandDivider
|
|
6
6
|
from mahjong.hand_calculating.fu import FuCalculator, FuDetail
|
|
7
7
|
from mahjong.hand_calculating.hand_config import HandConfig
|
|
@@ -22,36 +22,108 @@ class _CalculatedHand(TypedDict):
|
|
|
22
22
|
fu_details: list[FuDetail]
|
|
23
23
|
|
|
24
24
|
|
|
25
|
+
def _hand_priority(hand: _CalculatedHand) -> tuple[int, bool, int, int]:
|
|
26
|
+
"""
|
|
27
|
+
Rank a hand decomposition, the most expensive one is used.
|
|
28
|
+
|
|
29
|
+
The highest score wins, and on an equal score a real yakuman wins over counted (kazoe)
|
|
30
|
+
yakuman built from regular yaku, as on Tenhou.
|
|
31
|
+
"""
|
|
32
|
+
total = hand["cost"]["total"] if hand["cost"] else -1
|
|
33
|
+
has_yakuman = any(yaku.is_yakuman for yaku in hand["hand_yaku"])
|
|
34
|
+
return total, has_yakuman, hand["han"], hand["fu"]
|
|
35
|
+
|
|
36
|
+
|
|
25
37
|
# suit bitmask: sou=1, pin=2, man=4
|
|
26
38
|
_ALL_SUITS_MASK = 7
|
|
27
39
|
|
|
28
|
-
_DEFAULT_CONFIG = HandConfig()
|
|
29
|
-
|
|
30
40
|
|
|
31
41
|
class HandCalculator:
|
|
42
|
+
"""
|
|
43
|
+
Hand value estimator.
|
|
44
|
+
|
|
45
|
+
Evaluate a winning hand's han, fu, yaku, and payment amounts. Accepts tiles in
|
|
46
|
+
136-format, an optional :class:`~mahjong.hand_calculating.hand_config.HandConfig` for
|
|
47
|
+
win conditions and rule variants, and optional melds and dora/ura-dora indicators. When multiple
|
|
48
|
+
valid decompositions exist, the highest-scoring result is returned.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
# basic hand validation
|
|
32
52
|
ERR_NO_WINNING_TILE = "winning_tile_not_in_hand"
|
|
53
|
+
"""``win_tile`` index is not present in the ``tiles`` array."""
|
|
54
|
+
ERR_HAND_NOT_WINNING = "hand_not_winning"
|
|
55
|
+
"""No valid decomposition exists; the hand is not a winning hand."""
|
|
56
|
+
ERR_NO_YAKU = "no_yaku"
|
|
57
|
+
"""Hand can be decomposed but has zero han (no yaku applies)."""
|
|
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
|
+
|
|
78
|
+
# riichi constraints
|
|
33
79
|
ERR_OPEN_HAND_RIICHI = "open_hand_riichi_not_allowed"
|
|
80
|
+
"""Riichi declared on an open hand (open melds present)."""
|
|
34
81
|
ERR_OPEN_HAND_DABURI = "open_hand_daburi_not_allowed"
|
|
82
|
+
"""Double riichi declared on an open hand."""
|
|
35
83
|
ERR_IPPATSU_WITHOUT_RIICHI = "ippatsu_without_riichi_not_allowed"
|
|
36
|
-
|
|
37
|
-
|
|
84
|
+
"""Ippatsu claimed without riichi or double riichi."""
|
|
85
|
+
|
|
86
|
+
# win-condition conflicts
|
|
38
87
|
ERR_CHANKAN_WITH_TSUMO = "chankan_with_tsumo_not_allowed"
|
|
88
|
+
"""Chankan (robbing a kan) requires a ron win."""
|
|
39
89
|
ERR_RINSHAN_WITHOUT_TSUMO = "rinshan_without_tsumo_not_allowed"
|
|
90
|
+
"""Rinshan kaihou (win after kan) requires a tsumo win."""
|
|
40
91
|
ERR_HAITEI_WITHOUT_TSUMO = "haitei_without_tsumo_not_allowed"
|
|
92
|
+
"""Haitei raoyue (last-tile draw) requires a tsumo win."""
|
|
41
93
|
ERR_HOUTEI_WITH_TSUMO = "houtei_with_tsumo_not_allowed"
|
|
94
|
+
"""Houtei raoyui (last-tile discard) requires a ron win."""
|
|
42
95
|
ERR_HAITEI_WITH_RINSHAN = "haitei_with_rinshan_not_allowed"
|
|
96
|
+
"""Haitei and rinshan are mutually exclusive (different last-tile sources)."""
|
|
43
97
|
ERR_HOUTEI_WITH_CHANKAN = "houtei_with_chankan_not_allowed"
|
|
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)."""
|
|
103
|
+
|
|
104
|
+
# tenhou (blessing of heaven) constraints
|
|
44
105
|
ERR_TENHOU_NOT_AS_DEALER = "tenhou_not_as_dealer_not_allowed"
|
|
106
|
+
"""Tenhou is exclusive to the dealer: ``player_wind`` must be East."""
|
|
45
107
|
ERR_TENHOU_WITHOUT_TSUMO = "tenhou_without_tsumo_not_allowed"
|
|
108
|
+
"""Tenhou requires a tsumo win on the dealer's first draw."""
|
|
46
109
|
ERR_TENHOU_WITH_MELD = "tenhou_with_meld_not_allowed"
|
|
110
|
+
"""Tenhou requires a closed hand with no declared melds."""
|
|
111
|
+
|
|
112
|
+
# chiihou (blessing of earth) constraints
|
|
47
113
|
ERR_CHIIHOU_AS_DEALER = "chiihou_as_dealer_not_allowed"
|
|
114
|
+
"""Chiihou is exclusive to non-dealer players."""
|
|
48
115
|
ERR_CHIIHOU_WITHOUT_TSUMO = "chiihou_without_tsumo_not_allowed"
|
|
116
|
+
"""Chiihou requires a tsumo win on the player's first draw."""
|
|
49
117
|
ERR_CHIIHOU_WITH_MELD = "chiihou_with_meld_not_allowed"
|
|
118
|
+
"""Chiihou requires a closed hand with no declared melds."""
|
|
119
|
+
|
|
120
|
+
# renhou (blessing of man) constraints
|
|
50
121
|
ERR_RENHOU_AS_DEALER = "renhou_as_dealer_not_allowed"
|
|
122
|
+
"""Renhou is exclusive to non-dealer players."""
|
|
51
123
|
ERR_RENHOU_WITH_TSUMO = "renhou_with_tsumo_not_allowed"
|
|
124
|
+
"""Renhou requires a ron win before the player's first draw."""
|
|
52
125
|
ERR_RENHOU_WITH_MELD = "renhou_with_meld_not_allowed"
|
|
53
|
-
|
|
54
|
-
# more possible errors, like tenhou and haitei can't be together (so complicated :<)
|
|
126
|
+
"""Renhou requires a closed hand with no declared melds."""
|
|
55
127
|
|
|
56
128
|
@staticmethod
|
|
57
129
|
def estimate_hand_value(
|
|
@@ -64,16 +136,84 @@ class HandCalculator:
|
|
|
64
136
|
ura_dora_indicators: Collection[int] | None = None,
|
|
65
137
|
) -> HandResponse:
|
|
66
138
|
"""
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
139
|
+
Estimate the point value of a winning hand.
|
|
140
|
+
|
|
141
|
+
Validate the hand and win conditions, decompose the hand into all possible
|
|
142
|
+
block combinations, evaluate yaku and fu for each decomposition, and return
|
|
143
|
+
the highest-scoring result as a :class:`~mahjong.hand_calculating.hand_response.HandResponse`.
|
|
144
|
+
|
|
145
|
+
Basic closed ron with tanyao:
|
|
146
|
+
|
|
147
|
+
>>> from mahjong.hand_calculating.hand import HandCalculator
|
|
148
|
+
>>> from mahjong.tile import TilesConverter
|
|
149
|
+
>>> tiles = TilesConverter.string_to_136_array(man="22444", pin="333567", sou="444")
|
|
150
|
+
>>> win_tile = TilesConverter.string_to_136_array(sou="4")[0]
|
|
151
|
+
>>> result = HandCalculator.estimate_hand_value(tiles, win_tile)
|
|
152
|
+
>>> result.han
|
|
153
|
+
1
|
|
154
|
+
>>> result.fu
|
|
155
|
+
40
|
|
156
|
+
>>> result.cost["main"]
|
|
157
|
+
1300
|
|
158
|
+
|
|
159
|
+
Tsumo win with riichi and pinfu:
|
|
160
|
+
|
|
161
|
+
>>> from mahjong.hand_calculating.hand_config import HandConfig
|
|
162
|
+
>>> tiles = TilesConverter.string_to_136_array(man="234789", pin="12345666")
|
|
163
|
+
>>> win_tile = TilesConverter.string_to_136_array(pin="6")[0]
|
|
164
|
+
>>> config = HandConfig(is_tsumo=True, is_riichi=True)
|
|
165
|
+
>>> result = HandCalculator.estimate_hand_value(tiles, win_tile, config=config)
|
|
166
|
+
>>> result.han
|
|
167
|
+
3
|
|
168
|
+
>>> result.fu
|
|
169
|
+
20
|
|
170
|
+
>>> result.cost["main"]
|
|
171
|
+
1300
|
|
172
|
+
>>> result.cost["additional"]
|
|
173
|
+
700
|
|
174
|
+
|
|
175
|
+
Dealer tsumo with riichi and ippatsu:
|
|
176
|
+
|
|
177
|
+
>>> from mahjong.constants import EAST
|
|
178
|
+
>>> config = HandConfig(is_tsumo=True, is_riichi=True, is_ippatsu=True, player_wind=EAST)
|
|
179
|
+
>>> result = HandCalculator.estimate_hand_value(tiles, win_tile, config=config)
|
|
180
|
+
>>> result.error is None
|
|
181
|
+
True
|
|
182
|
+
|
|
183
|
+
Open hand with melds and dora:
|
|
184
|
+
|
|
185
|
+
>>> from mahjong.meld import Meld
|
|
186
|
+
>>> tiles = TilesConverter.string_to_136_array(man="234567", pin="22", sou="234", honors="555")
|
|
187
|
+
>>> win_tile = TilesConverter.string_to_136_array(man="7")[0]
|
|
188
|
+
>>> melds = [Meld(meld_type=Meld.PON, tiles=TilesConverter.string_to_136_array(honors="555"))]
|
|
189
|
+
>>> dora_indicators = [TilesConverter.string_to_136_array(man="6")[0]]
|
|
190
|
+
>>> result = HandCalculator.estimate_hand_value(tiles, win_tile, melds=melds, dora_indicators=dora_indicators)
|
|
191
|
+
>>> result.han
|
|
192
|
+
2
|
|
193
|
+
|
|
194
|
+
Invalid hand returns an error:
|
|
195
|
+
|
|
196
|
+
>>> tiles = TilesConverter.string_to_136_array(man="12345")
|
|
197
|
+
>>> win_tile = TilesConverter.string_to_136_array(man="1")[0]
|
|
198
|
+
>>> result = HandCalculator.estimate_hand_value(tiles, win_tile)
|
|
199
|
+
>>> result.error == HandCalculator.ERR_HAND_NOT_WINNING
|
|
200
|
+
True
|
|
201
|
+
|
|
202
|
+
:param tiles: hand tiles in 136-format (14 tiles including the winning tile;
|
|
203
|
+
15 with one kan, 16 with two, etc.)
|
|
204
|
+
:param win_tile: the winning tile index in 136-format (must be present in ``tiles``)
|
|
205
|
+
:param melds: declared melds (:class:`~mahjong.meld.Meld` objects for chi, pon, kan)
|
|
206
|
+
:param dora_indicators: dora indicator tile indices in 136-format
|
|
207
|
+
:param config: hand configuration with win conditions, wind context, and optional rules;
|
|
208
|
+
defaults to a closed ron with no special conditions
|
|
209
|
+
:param scores_calculator_factory: scoring calculator class; pass
|
|
210
|
+
:class:`~mahjong.hand_calculating.scores.Aotenjou` for aotenjou (limitless) scoring
|
|
211
|
+
:param ura_dora_indicators: ura dora indicator tile indices in 136-format
|
|
212
|
+
(counted only when riichi or double riichi is declared)
|
|
213
|
+
:return: :class:`~mahjong.hand_calculating.hand_response.HandResponse` with scoring
|
|
214
|
+
details on success, or with :attr:`~mahjong.hand_calculating.hand_response.HandResponse.error`
|
|
215
|
+
set on failure
|
|
75
216
|
"""
|
|
76
|
-
|
|
77
217
|
if not melds:
|
|
78
218
|
melds = []
|
|
79
219
|
|
|
@@ -83,16 +223,27 @@ class HandCalculator:
|
|
|
83
223
|
if not ura_dora_indicators:
|
|
84
224
|
ura_dora_indicators = []
|
|
85
225
|
|
|
86
|
-
config = config or
|
|
226
|
+
config = config or HandConfig()
|
|
87
227
|
|
|
88
|
-
hand_yaku = []
|
|
228
|
+
hand_yaku: list[Yaku] = []
|
|
89
229
|
scores_calculator = scores_calculator_factory()
|
|
90
230
|
tiles_34 = TilesConverter.to_34_array(tiles)
|
|
91
231
|
|
|
92
232
|
is_aotenjou = isinstance(scores_calculator, Aotenjou)
|
|
93
233
|
|
|
94
|
-
|
|
95
|
-
|
|
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)
|
|
96
247
|
|
|
97
248
|
# special situation
|
|
98
249
|
if config.is_nagashi_mangan:
|
|
@@ -105,6 +256,10 @@ class HandCalculator:
|
|
|
105
256
|
if win_tile not in tiles:
|
|
106
257
|
return HandResponse(error=HandCalculator.ERR_NO_WINNING_TILE)
|
|
107
258
|
|
|
259
|
+
for meld in melds:
|
|
260
|
+
if meld_error := HandCalculator._validate_meld(meld, tiles):
|
|
261
|
+
return HandResponse(error=meld_error)
|
|
262
|
+
|
|
108
263
|
if config.is_riichi and not config.is_daburu_riichi and is_open_hand:
|
|
109
264
|
return HandResponse(error=HandCalculator.ERR_OPEN_HAND_RIICHI)
|
|
110
265
|
|
|
@@ -132,8 +287,17 @@ class HandCalculator:
|
|
|
132
287
|
if config.is_houtei and config.is_chankan:
|
|
133
288
|
return HandResponse(error=HandCalculator.ERR_HOUTEI_WITH_CHANKAN)
|
|
134
289
|
|
|
135
|
-
|
|
136
|
-
|
|
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:
|
|
137
301
|
return HandResponse(error=HandCalculator.ERR_TENHOU_NOT_AS_DEALER)
|
|
138
302
|
|
|
139
303
|
if config.is_tenhou and not config.is_tsumo:
|
|
@@ -172,8 +336,10 @@ class HandCalculator:
|
|
|
172
336
|
hand_options = HandDivider.divide_hand(tiles_34, melds)
|
|
173
337
|
|
|
174
338
|
# precompute dora counts, invariant across all hand decompositions
|
|
175
|
-
dora_count_map = build_dora_count_map(dora_indicators)
|
|
339
|
+
dora_count_map = build_dora_count_map(dora_indicators, config.options.is_three_player)
|
|
176
340
|
precomputed_dora = count_dora_for_hand(tiles_34, dora_count_map)
|
|
341
|
+
if config.num_nuki_dora > 0:
|
|
342
|
+
precomputed_dora += dora_count_map.get(NORTH, 0) * config.num_nuki_dora + config.num_nuki_dora
|
|
177
343
|
|
|
178
344
|
precomputed_aka_dora = 0
|
|
179
345
|
if config.options.has_aka_dora:
|
|
@@ -181,8 +347,10 @@ class HandCalculator:
|
|
|
181
347
|
|
|
182
348
|
precomputed_ura_dora = 0
|
|
183
349
|
if config.is_riichi or config.is_daburu_riichi:
|
|
184
|
-
ura_count_map = build_dora_count_map(ura_dora_indicators)
|
|
350
|
+
ura_count_map = build_dora_count_map(ura_dora_indicators, config.options.is_three_player)
|
|
185
351
|
precomputed_ura_dora = count_dora_for_hand(tiles_34, ura_count_map)
|
|
352
|
+
if config.num_nuki_dora > 0:
|
|
353
|
+
precomputed_ura_dora += ura_count_map.get(NORTH, 0) * config.num_nuki_dora
|
|
186
354
|
|
|
187
355
|
yakuhai_seat_wind_yaku = (
|
|
188
356
|
config.yaku.seat_wind_east,
|
|
@@ -198,6 +366,7 @@ class HandCalculator:
|
|
|
198
366
|
)
|
|
199
367
|
|
|
200
368
|
calculated_hands: list[_CalculatedHand] = []
|
|
369
|
+
meld_blocks = [meld._sorted_tiles_34 for meld in melds] # noqa: SLF001
|
|
201
370
|
for hand in hand_options:
|
|
202
371
|
is_chiitoitsu = config.yaku.chiitoitsu.is_condition_met(hand)
|
|
203
372
|
valued_tiles = [HAKU, HATSU, CHUN, config.player_wind, config.round_wind]
|
|
@@ -221,7 +390,7 @@ class HandCalculator:
|
|
|
221
390
|
suit_mask, honor_count = classify_hand_suits(hand)
|
|
222
391
|
has_honors = honor_count > 0
|
|
223
392
|
|
|
224
|
-
win_groups = HandCalculator._find_win_groups(win_tile, hand,
|
|
393
|
+
win_groups = HandCalculator._find_win_groups(win_tile, hand, meld_blocks)
|
|
225
394
|
for win_group in win_groups:
|
|
226
395
|
cost = None
|
|
227
396
|
error = None
|
|
@@ -424,7 +593,7 @@ class HandCalculator:
|
|
|
424
593
|
if not is_aotenjou:
|
|
425
594
|
hand_yaku = yakuman_list
|
|
426
595
|
else:
|
|
427
|
-
scores_calculator.aotenjou_filter_yaku(hand_yaku, config)
|
|
596
|
+
scores_calculator.aotenjou_filter_yaku(hand_yaku, config)
|
|
428
597
|
yakuman_list = []
|
|
429
598
|
|
|
430
599
|
# calculate han
|
|
@@ -475,7 +644,7 @@ class HandCalculator:
|
|
|
475
644
|
calculated_hands.append(calculated_hand)
|
|
476
645
|
|
|
477
646
|
# exception hand
|
|
478
|
-
if not
|
|
647
|
+
if not melds and config.yaku.kokushi.is_condition_met(None, tiles_34):
|
|
479
648
|
if tiles_34[win_tile // 4] == 2:
|
|
480
649
|
hand_yaku.append(config.yaku.daburu_kokushi)
|
|
481
650
|
else:
|
|
@@ -515,7 +684,7 @@ class HandCalculator:
|
|
|
515
684
|
|
|
516
685
|
count_of_dora = 0
|
|
517
686
|
for tile in tiles_for_dora:
|
|
518
|
-
count_of_dora += plus_dora(tile, dora_indicators)
|
|
687
|
+
count_of_dora += plus_dora(tile, dora_indicators, is_three_player=config.options.is_three_player)
|
|
519
688
|
|
|
520
689
|
if count_of_dora:
|
|
521
690
|
config.yaku.dora.han_open = count_of_dora
|
|
@@ -526,7 +695,9 @@ class HandCalculator:
|
|
|
526
695
|
if config.is_riichi or config.is_daburu_riichi:
|
|
527
696
|
count_of_ura_dora = 0
|
|
528
697
|
for tile in tiles_for_dora:
|
|
529
|
-
count_of_ura_dora += plus_dora(
|
|
698
|
+
count_of_ura_dora += plus_dora(
|
|
699
|
+
tile, ura_dora_indicators, is_three_player=config.options.is_three_player
|
|
700
|
+
)
|
|
530
701
|
|
|
531
702
|
if count_of_ura_dora:
|
|
532
703
|
config.yaku.ura_dora.han_closed = count_of_ura_dora
|
|
@@ -542,14 +713,11 @@ class HandCalculator:
|
|
|
542
713
|
return HandResponse(error=HandCalculator.ERR_HAND_NOT_WINNING)
|
|
543
714
|
|
|
544
715
|
# find most expensive hand
|
|
545
|
-
calculated_hands
|
|
716
|
+
calculated_hands.sort(key=_hand_priority, reverse=True)
|
|
546
717
|
# correctly sort expensive hands by fu details
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
if x["han"] == calculated_hands[0]["han"] and x["fu"] == calculated_hands[0]["fu"]
|
|
551
|
-
]
|
|
552
|
-
calculated_hands = sorted(calculated_hands, key=lambda x: sum([y["fu"] for y in x["fu_details"]]), reverse=True)
|
|
718
|
+
top_priority = _hand_priority(calculated_hands[0])
|
|
719
|
+
calculated_hands = [x for x in calculated_hands if _hand_priority(x) == top_priority]
|
|
720
|
+
calculated_hands.sort(key=lambda x: sum([y["fu"] for y in x["fu_details"]]), reverse=True)
|
|
553
721
|
calculated_hand = calculated_hands[0]
|
|
554
722
|
|
|
555
723
|
error = calculated_hand["error"]
|
|
@@ -567,25 +735,38 @@ class HandCalculator:
|
|
|
567
735
|
)
|
|
568
736
|
|
|
569
737
|
@staticmethod
|
|
570
|
-
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]]:
|
|
571
752
|
win_tile_34 = (win_tile or 0) // 4
|
|
572
753
|
|
|
573
|
-
# track which
|
|
574
|
-
consumed = [False] * len(
|
|
754
|
+
# track which declared melds have been matched using a consumed flags array
|
|
755
|
+
consumed = [False] * len(meld_blocks)
|
|
575
756
|
|
|
576
|
-
# collect
|
|
757
|
+
# collect undeclared sets and find unique win groups in a single pass
|
|
577
758
|
seen: set[tuple[int, ...]] = set()
|
|
578
759
|
win_groups: list[list[int]] = []
|
|
579
760
|
for x in hand:
|
|
580
|
-
# check if this set matches an unconsumed
|
|
581
|
-
|
|
582
|
-
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):
|
|
583
764
|
if not consumed[i] and x == meld:
|
|
584
765
|
consumed[i] = True
|
|
585
|
-
|
|
766
|
+
is_declared_meld = True
|
|
586
767
|
break
|
|
587
768
|
|
|
588
|
-
if
|
|
769
|
+
if is_declared_meld:
|
|
589
770
|
continue
|
|
590
771
|
|
|
591
772
|
# for forms like 45666 and ron on 6
|