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.
Files changed (92) hide show
  1. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/MANIFEST.in +1 -0
  2. {mahjong-2.1.0/mahjong.egg-info → mahjong-2.2.0.dev1}/PKG-INFO +4 -30
  3. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/README.md +3 -29
  4. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/divider.py +18 -17
  5. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/hand.py +77 -16
  6. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/hand_config.py +2 -2
  7. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/sanankou.py +1 -1
  8. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/kokushi.py +2 -2
  9. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/meld.py +52 -3
  10. {mahjong-2.1.0 → mahjong-2.2.0.dev1/mahjong.egg-info}/PKG-INFO +4 -30
  11. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/pyproject.toml +16 -6
  12. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/LICENSE.txt +0 -0
  13. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/__init__.py +0 -0
  14. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/agari.py +0 -0
  15. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/constants.py +0 -0
  16. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/__init__.py +0 -0
  17. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/fu.py +0 -0
  18. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/hand_response.py +0 -0
  19. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/scores.py +0 -0
  20. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku.py +0 -0
  21. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_config.py +0 -0
  22. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/__init__.py +0 -0
  23. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/aka_dora.py +0 -0
  24. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/chankan.py +0 -0
  25. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/chantai.py +0 -0
  26. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/chiitoitsu.py +0 -0
  27. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/chinitsu.py +0 -0
  28. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/chun.py +0 -0
  29. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/daburu_open_riichi.py +0 -0
  30. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/daburu_riichi.py +0 -0
  31. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/dora.py +0 -0
  32. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/haitei.py +0 -0
  33. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/haku.py +0 -0
  34. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/hatsu.py +0 -0
  35. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/honitsu.py +0 -0
  36. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/honroto.py +0 -0
  37. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/houtei.py +0 -0
  38. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/iipeiko.py +0 -0
  39. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/ippatsu.py +0 -0
  40. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/ittsu.py +0 -0
  41. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/junchan.py +0 -0
  42. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/nagashi_mangan.py +0 -0
  43. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/open_riichi.py +0 -0
  44. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/pinfu.py +0 -0
  45. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/renhou.py +0 -0
  46. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/riichi.py +0 -0
  47. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/rinshan.py +0 -0
  48. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/round_wind_east.py +0 -0
  49. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/round_wind_north.py +0 -0
  50. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/round_wind_south.py +0 -0
  51. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/round_wind_west.py +0 -0
  52. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/ryanpeiko.py +0 -0
  53. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/sankantsu.py +0 -0
  54. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/sanshoku.py +0 -0
  55. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/sanshoku_douko.py +0 -0
  56. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/seat_wind_east.py +0 -0
  57. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/seat_wind_north.py +0 -0
  58. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/seat_wind_south.py +0 -0
  59. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/seat_wind_west.py +0 -0
  60. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/shosangen.py +0 -0
  61. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/tanyao.py +0 -0
  62. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/toitoi.py +0 -0
  63. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/tsumo.py +0 -0
  64. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/ura_dora.py +0 -0
  65. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/__init__.py +0 -0
  66. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/chiihou.py +0 -0
  67. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/chinroto.py +0 -0
  68. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/chuuren_poutou.py +0 -0
  69. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/daburu_chuuren_poutou.py +0 -0
  70. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/daburu_kokushi.py +0 -0
  71. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/daichisei.py +0 -0
  72. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/daisangen.py +0 -0
  73. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/daisharin.py +0 -0
  74. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/daisuushi.py +0 -0
  75. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/paarenchan.py +0 -0
  76. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/renhou_yakuman.py +0 -0
  77. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/ryuisou.py +0 -0
  78. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/sashikomi.py +0 -0
  79. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/shosuushi.py +0 -0
  80. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/suuankou.py +0 -0
  81. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/suuankou_tanki.py +0 -0
  82. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/suukantsu.py +0 -0
  83. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/tenhou.py +0 -0
  84. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/hand_calculating/yaku_list/yakuman/tsuisou.py +0 -0
  85. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/py.typed +0 -0
  86. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/shanten.py +0 -0
  87. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/tile.py +0 -0
  88. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong/utils.py +0 -0
  89. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong.egg-info/SOURCES.txt +0 -0
  90. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong.egg-info/dependency_links.txt +0 -0
  91. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/mahjong.egg-info/top_level.txt +0 -0
  92. {mahjong-2.1.0 → mahjong-2.2.0.dev1}/setup.cfg +0 -0
@@ -1,2 +1,3 @@
1
1
  prune examples
2
+ prune scripts
2
3
  prune tests
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mahjong
3
- Version: 2.1.0
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
- ## Local development setup
58
+ ## Contributing
59
59
 
60
- To set up the project locally for development:
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
- ## Local development setup
29
+ ## Contributing
30
30
 
31
- To set up the project locally for development:
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.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]
@@ -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 (East player)."""
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
- opened_melds = [x.tiles_34 for x in melds if x.opened]
212
- 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)
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
- # raise error only when player wind is defined (and is *not* EAST)
253
- 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:
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, opened_melds)
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 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):
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 _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]]:
691
752
  win_tile_34 = (win_tile or 0) // 4
692
753
 
693
- # track which opened melds have been matched using a consumed flags array
694
- consumed = [False] * len(opened_melds)
754
+ # track which declared melds have been matched using a consumed flags array
755
+ consumed = [False] * len(meld_blocks)
695
756
 
696
- # 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
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 opened meld
701
- is_opened_meld = False
702
- 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):
703
764
  if not consumed[i] and x == meld:
704
765
  consumed[i] = True
705
- is_opened_meld = True
766
+ is_declared_meld = True
706
767
  break
707
768
 
708
- if is_opened_meld:
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
@@ -34,7 +34,7 @@ class Sanankou(Yaku):
34
34
  open_sets: set[tuple[int, ...]] = set()
35
35
  for m in melds:
36
36
  if m.opened:
37
- open_sets.add(tuple(m.tiles_34))
37
+ open_sets.add(tuple(m._sorted_tiles_34)) # noqa: SLF001
38
38
 
39
39
  has_chi_with_win_tile = False
40
40
  closed_pon_count = 0
@@ -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 contains one of each terminal and honor with exactly one duplicate
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 cache when tiles are reassigned."""
113
+ """Invalidate the tiles_34 caches when tiles are reassigned."""
112
114
  super().__setattr__(name, value)
113
- if name == "tiles" and "tiles_34" in self.__dict__:
114
- del self.__dict__["tiles_34"]
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.1.0
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
- ## Local development setup
58
+ ## Contributing
59
59
 
60
- To set up the project locally for development:
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.1.0"
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 = "lint" },
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