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.
Files changed (85) hide show
  1. mahjong/__init__.py +25 -6
  2. mahjong/hand_calculating/__init__.py +23 -0
  3. mahjong/hand_calculating/divider.py +18 -17
  4. mahjong/hand_calculating/fu.py +1 -1
  5. mahjong/hand_calculating/hand.py +226 -45
  6. mahjong/hand_calculating/hand_config.py +210 -11
  7. mahjong/hand_calculating/hand_response.py +78 -7
  8. mahjong/hand_calculating/scores.py +133 -41
  9. mahjong/hand_calculating/yaku.py +66 -6
  10. mahjong/hand_calculating/yaku_config.py +110 -55
  11. mahjong/hand_calculating/yaku_list/__init__.py +12 -0
  12. mahjong/hand_calculating/yaku_list/aka_dora.py +3 -3
  13. mahjong/hand_calculating/yaku_list/chankan.py +2 -4
  14. mahjong/hand_calculating/yaku_list/chantai.py +4 -2
  15. mahjong/hand_calculating/yaku_list/chiitoitsu.py +2 -3
  16. mahjong/hand_calculating/yaku_list/chinitsu.py +2 -3
  17. mahjong/hand_calculating/yaku_list/chun.py +2 -3
  18. mahjong/hand_calculating/yaku_list/daburu_open_riichi.py +2 -4
  19. mahjong/hand_calculating/yaku_list/daburu_riichi.py +2 -4
  20. mahjong/hand_calculating/yaku_list/dora.py +4 -0
  21. mahjong/hand_calculating/yaku_list/haitei.py +2 -4
  22. mahjong/hand_calculating/yaku_list/haku.py +2 -3
  23. mahjong/hand_calculating/yaku_list/hatsu.py +2 -3
  24. mahjong/hand_calculating/yaku_list/honitsu.py +2 -3
  25. mahjong/hand_calculating/yaku_list/honroto.py +2 -3
  26. mahjong/hand_calculating/yaku_list/houtei.py +2 -4
  27. mahjong/hand_calculating/yaku_list/iipeiko.py +2 -3
  28. mahjong/hand_calculating/yaku_list/ippatsu.py +2 -4
  29. mahjong/hand_calculating/yaku_list/ittsu.py +2 -3
  30. mahjong/hand_calculating/yaku_list/junchan.py +4 -3
  31. mahjong/hand_calculating/yaku_list/nagashi_mangan.py +2 -4
  32. mahjong/hand_calculating/yaku_list/open_riichi.py +3 -0
  33. mahjong/hand_calculating/yaku_list/pinfu.py +2 -3
  34. mahjong/hand_calculating/yaku_list/renhou.py +2 -4
  35. mahjong/hand_calculating/yaku_list/riichi.py +3 -0
  36. mahjong/hand_calculating/yaku_list/rinshan.py +2 -4
  37. mahjong/hand_calculating/yaku_list/round_wind_east.py +8 -3
  38. mahjong/hand_calculating/yaku_list/round_wind_north.py +8 -3
  39. mahjong/hand_calculating/yaku_list/round_wind_south.py +8 -3
  40. mahjong/hand_calculating/yaku_list/round_wind_west.py +8 -3
  41. mahjong/hand_calculating/yaku_list/ryanpeiko.py +2 -3
  42. mahjong/hand_calculating/yaku_list/sanankou.py +11 -4
  43. mahjong/hand_calculating/yaku_list/sankantsu.py +8 -3
  44. mahjong/hand_calculating/yaku_list/sanshoku.py +2 -3
  45. mahjong/hand_calculating/yaku_list/sanshoku_douko.py +2 -3
  46. mahjong/hand_calculating/yaku_list/seat_wind_east.py +8 -3
  47. mahjong/hand_calculating/yaku_list/seat_wind_north.py +8 -3
  48. mahjong/hand_calculating/yaku_list/seat_wind_south.py +8 -3
  49. mahjong/hand_calculating/yaku_list/seat_wind_west.py +8 -3
  50. mahjong/hand_calculating/yaku_list/shosangen.py +2 -3
  51. mahjong/hand_calculating/yaku_list/tanyao.py +2 -3
  52. mahjong/hand_calculating/yaku_list/toitoi.py +2 -3
  53. mahjong/hand_calculating/yaku_list/tsumo.py +2 -4
  54. mahjong/hand_calculating/yaku_list/ura_dora.py +4 -0
  55. mahjong/hand_calculating/yaku_list/yakuman/__init__.py +9 -0
  56. mahjong/hand_calculating/yaku_list/yakuman/chiihou.py +3 -0
  57. mahjong/hand_calculating/yaku_list/yakuman/chinroto.py +3 -5
  58. mahjong/hand_calculating/yaku_list/yakuman/chuuren_poutou.py +2 -3
  59. mahjong/hand_calculating/yaku_list/yakuman/daburu_chuuren_poutou.py +3 -1
  60. mahjong/hand_calculating/yaku_list/yakuman/daburu_kokushi.py +3 -1
  61. mahjong/hand_calculating/yaku_list/yakuman/daichisei.py +2 -3
  62. mahjong/hand_calculating/yaku_list/yakuman/daisangen.py +2 -3
  63. mahjong/hand_calculating/yaku_list/yakuman/daisharin.py +18 -4
  64. mahjong/hand_calculating/yaku_list/yakuman/daisuushi.py +2 -3
  65. mahjong/hand_calculating/yaku_list/yakuman/kokushi.py +11 -3
  66. mahjong/hand_calculating/yaku_list/yakuman/paarenchan.py +4 -4
  67. mahjong/hand_calculating/yaku_list/yakuman/renhou_yakuman.py +2 -4
  68. mahjong/hand_calculating/yaku_list/yakuman/ryuisou.py +2 -3
  69. mahjong/hand_calculating/yaku_list/yakuman/sashikomi.py +2 -4
  70. mahjong/hand_calculating/yaku_list/yakuman/shosuushi.py +2 -3
  71. mahjong/hand_calculating/yaku_list/yakuman/suuankou.py +9 -3
  72. mahjong/hand_calculating/yaku_list/yakuman/suuankou_tanki.py +3 -0
  73. mahjong/hand_calculating/yaku_list/yakuman/suukantsu.py +8 -3
  74. mahjong/hand_calculating/yaku_list/yakuman/tenhou.py +2 -4
  75. mahjong/hand_calculating/yaku_list/yakuman/tsuisou.py +2 -8
  76. mahjong/meld.py +52 -3
  77. mahjong/shanten.py +57 -21
  78. mahjong/tile.py +2 -0
  79. mahjong/utils.py +28 -4
  80. {mahjong-2.0.0.dev2.dist-info → mahjong-2.2.0.dev1.dist-info}/METADATA +13 -34
  81. mahjong-2.2.0.dev1.dist-info/RECORD +87 -0
  82. {mahjong-2.0.0.dev2.dist-info → mahjong-2.2.0.dev1.dist-info}/WHEEL +1 -1
  83. mahjong-2.0.0.dev2.dist-info/RECORD +0 -87
  84. {mahjong-2.0.0.dev2.dist-info → mahjong-2.2.0.dev1.dist-info}/licenses/LICENSE.txt +0 -0
  85. {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
- _summary_.
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. For example:
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.tiles_34[0]
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
- honors = HandDivider._decompose_honors_hand(hand[27:34])
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
- for man in man_combinations:
171
- for pin in pin_combinations:
172
- for sou in sou_combinations:
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
- num_pair = sum(block.ty == _BlockType.PAIR for block in all_blocks)
177
- if num_pair != 1:
178
- continue
175
+ num_pair = sum(block.ty == _BlockType.PAIR for block in all_blocks)
176
+ if num_pair != 1:
177
+ continue
179
178
 
180
- all_blocks.extend(melds)
181
- if len(all_blocks) != 5:
182
- continue
179
+ all_blocks.extend(melds)
180
+ if len(all_blocks) != 5:
181
+ continue
183
182
 
184
- all_blocks.sort()
185
- combinations.append(all_blocks)
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]
@@ -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, player wind, round wind); pass the same index twice for double-valued
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
  """
@@ -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
- ERR_HAND_NOT_WINNING = "hand_not_winning"
37
- ERR_NO_YAKU = "no_yaku"
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
- :param tiles: array with 14 tiles in 136-tile format
68
- :param win_tile: 136-tile format tile that caused win (ron or tsumo)
69
- :param melds: array with Meld objects
70
- :param dora_indicators: array of tiles in 136-tile format
71
- :param config: HandConfig object
72
- :param scores_calculator_factory: ScoresCalculator class or subclass
73
- :param ura_dora_indicators: array of tiles in 136-tile format (only counted with riichi)
74
- :return: HandResponse object
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 _DEFAULT_CONFIG
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
- opened_melds = [x.tiles_34 for x in melds if x.opened]
95
- is_open_hand = len(opened_melds) > 0
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
- # raise error only when player wind is defined (and is *not* EAST)
136
- if config.is_tenhou and config.player_wind and not config.is_dealer:
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, opened_melds)
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) # ty: ignore[unresolved-attribute]
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 is_open_hand and config.yaku.kokushi.is_condition_met(None, tiles_34):
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(tile, ura_dora_indicators)
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 = sorted(calculated_hands, key=lambda x: (x["han"], x["fu"]), reverse=True)
716
+ calculated_hands.sort(key=_hand_priority, reverse=True)
546
717
  # correctly sort expensive hands by fu details
547
- calculated_hands = [
548
- x
549
- for x in calculated_hands
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 _find_win_groups(win_tile: int, hand: list[list[int]], opened_melds: list[list[int]]) -> list[list[int]]:
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 opened melds have been matched using a consumed flags array
574
- consumed = [False] * len(opened_melds)
754
+ # track which declared melds have been matched using a consumed flags array
755
+ consumed = [False] * len(meld_blocks)
575
756
 
576
- # collect closed sets and find unique win groups in a single pass
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 opened meld
581
- is_opened_meld = False
582
- for i, meld in enumerate(opened_melds):
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
- is_opened_meld = True
766
+ is_declared_meld = True
586
767
  break
587
768
 
588
- if is_opened_meld:
769
+ if is_declared_meld:
589
770
  continue
590
771
 
591
772
  # for forms like 45666 and ron on 6