From 1018fb585d8737e5d0e93ce31e64936511c323da Mon Sep 17 00:00:00 2001 From: Apricot-S Date: Sun, 10 May 2026 22:56:11 +0900 Subject: [PATCH 1/3] feat: Support nuki dora (WIP) --- mahjong/hand_calculating/hand.py | 3 ++- tests/hand_calculating/tests_yaku_calculation.py | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/mahjong/hand_calculating/hand.py b/mahjong/hand_calculating/hand.py index e7a9166..96f3fa9 100644 --- a/mahjong/hand_calculating/hand.py +++ b/mahjong/hand_calculating/hand.py @@ -101,6 +101,7 @@ def estimate_hand_value( config: HandConfig | None = None, scores_calculator_factory: type[ScoresCalculator] = ScoresCalculator, ura_dora_indicators: Collection[int] | None = None, + num_nuki_dora: int = 0, ) -> HandResponse: """ Estimate the point value of a winning hand. @@ -280,7 +281,7 @@ def estimate_hand_value( # precompute dora counts, invariant across all hand decompositions dora_count_map = build_dora_count_map(dora_indicators) - precomputed_dora = count_dora_for_hand(tiles_34, dora_count_map) + precomputed_dora = count_dora_for_hand(tiles_34, dora_count_map) + max(num_nuki_dora, 0) precomputed_aka_dora = 0 if config.options.has_aka_dora: diff --git a/tests/hand_calculating/tests_yaku_calculation.py b/tests/hand_calculating/tests_yaku_calculation.py index 4debe52..5541731 100644 --- a/tests/hand_calculating/tests_yaku_calculation.py +++ b/tests/hand_calculating/tests_yaku_calculation.py @@ -1375,6 +1375,18 @@ def test_aka_dora() -> None: assert hand_calculation.han == 6 +@pytest.mark.parametrize(("num_nuki_dora", "han"), [(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (-1, 1)]) +def test_nuki_dora(num_nuki_dora: int, han: int) -> None: + tiles = TilesConverter.string_to_136_array(sou="345", pin="456", man="12355599", has_aka_dora=True) + win_tile = _string_to_136_tile(man="9") + + hand_config = HandConfig(is_tsumo=True, options=OptionalRules(has_aka_dora=True)) + + result = HandCalculator.estimate_hand_value(tiles, win_tile, config=hand_config, num_nuki_dora=num_nuki_dora) + assert result.error is None + assert result.han == han + + class TestYakuBaseClass: """ Test the abstract Yaku base class methods From 4c4a1617587428adf76c1fecb7bcdb4cf27e1659 Mon Sep 17 00:00:00 2001 From: Apricot-S Date: Sun, 10 May 2026 23:28:47 +0900 Subject: [PATCH 2/3] feat: Support nuki dora --- mahjong/hand_calculating/hand.py | 8 ++- .../tests_yaku_calculation.py | 68 ++++++++++++++++++- 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/mahjong/hand_calculating/hand.py b/mahjong/hand_calculating/hand.py index 96f3fa9..f42f839 100644 --- a/mahjong/hand_calculating/hand.py +++ b/mahjong/hand_calculating/hand.py @@ -1,7 +1,7 @@ from collections.abc import Collection from typing import TypedDict -from mahjong.constants import AKA_DORAS, CHUN, HAKU, HATSU +from mahjong.constants import AKA_DORAS, CHUN, HAKU, HATSU, NORTH from mahjong.hand_calculating.divider import HandDivider from mahjong.hand_calculating.fu import FuCalculator, FuDetail from mahjong.hand_calculating.hand_config import HandConfig @@ -281,7 +281,9 @@ def estimate_hand_value( # precompute dora counts, invariant across all hand decompositions dora_count_map = build_dora_count_map(dora_indicators) - precomputed_dora = count_dora_for_hand(tiles_34, dora_count_map) + max(num_nuki_dora, 0) + precomputed_dora = count_dora_for_hand(tiles_34, dora_count_map) + if num_nuki_dora > 0: + precomputed_dora += dora_count_map.get(NORTH, 0) * num_nuki_dora + num_nuki_dora precomputed_aka_dora = 0 if config.options.has_aka_dora: @@ -291,6 +293,8 @@ def estimate_hand_value( if config.is_riichi or config.is_daburu_riichi: ura_count_map = build_dora_count_map(ura_dora_indicators) precomputed_ura_dora = count_dora_for_hand(tiles_34, ura_count_map) + if num_nuki_dora > 0: + precomputed_ura_dora += ura_count_map.get(NORTH, 0) * num_nuki_dora yakuhai_seat_wind_yaku = ( config.yaku.seat_wind_east, diff --git a/tests/hand_calculating/tests_yaku_calculation.py b/tests/hand_calculating/tests_yaku_calculation.py index 5541731..375a068 100644 --- a/tests/hand_calculating/tests_yaku_calculation.py +++ b/tests/hand_calculating/tests_yaku_calculation.py @@ -1375,14 +1375,76 @@ def test_aka_dora() -> None: assert hand_calculation.han == 6 -@pytest.mark.parametrize(("num_nuki_dora", "han"), [(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (-1, 1)]) -def test_nuki_dora(num_nuki_dora: int, han: int) -> None: +@pytest.mark.parametrize( + ("num_nuki_dora", "num_dora_indicators", "han"), + [ + (0, 0, 1), + (1, 0, 2), + (2, 0, 3), + (3, 0, 4), + (4, 0, 5), + (-1, 0, 1), + (0, 1, 1), + (1, 1, 3), + (2, 1, 5), + (4, 1, 9), + (0, 2, 1), + (1, 2, 4), + (4, 4, 21), + (-1, 4, 1), + ], +) +def test_nuki_dora_with_dora_indicators(num_nuki_dora: int, num_dora_indicators: int, han: int) -> None: tiles = TilesConverter.string_to_136_array(sou="345", pin="456", man="12355599", has_aka_dora=True) win_tile = _string_to_136_tile(man="9") hand_config = HandConfig(is_tsumo=True, options=OptionalRules(has_aka_dora=True)) + dora_indicators = TilesConverter.string_to_136_array(honors="3" * num_dora_indicators) + + result = HandCalculator.estimate_hand_value( + tiles, + win_tile, + config=hand_config, + dora_indicators=dora_indicators, + num_nuki_dora=num_nuki_dora, + ) + assert result.error is None + assert result.han == han + - result = HandCalculator.estimate_hand_value(tiles, win_tile, config=hand_config, num_nuki_dora=num_nuki_dora) +@pytest.mark.parametrize( + ("num_nuki_dora", "num_ura_dora_indicators", "han"), + [ + (0, 0, 1), + (1, 0, 2), + (2, 0, 3), + (3, 0, 4), + (4, 0, 5), + (-1, 0, 1), + (0, 1, 1), + (1, 1, 3), + (2, 1, 5), + (4, 1, 9), + (0, 2, 1), + (1, 2, 4), + (4, 4, 21), + (-1, 4, 1), + ], +) +def test_nuki_dora_with_ura_dora_indicators(num_nuki_dora: int, num_ura_dora_indicators: int, han: int) -> None: + tiles = TilesConverter.string_to_136_array(sou="345", pin="456", man="12355599", has_aka_dora=True) + win_tile = _string_to_136_tile(man="9") + + hand_config = HandConfig(is_riichi=True, options=OptionalRules(has_aka_dora=True)) + ura_dora_indicators = TilesConverter.string_to_136_array(honors="3" * num_ura_dora_indicators) + + result = HandCalculator.estimate_hand_value( + tiles, + win_tile, + config=hand_config, + ura_dora_indicators=ura_dora_indicators, + num_nuki_dora=num_nuki_dora, + ) assert result.error is None assert result.han == han From 4cac812ae0ceef094aa79c97f6621d16e46a2c3c Mon Sep 17 00:00:00 2001 From: Apricot-S Date: Sun, 10 May 2026 23:43:50 +0900 Subject: [PATCH 3/3] docs: Add docstring --- mahjong/hand_calculating/hand.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mahjong/hand_calculating/hand.py b/mahjong/hand_calculating/hand.py index f42f839..23c63cf 100644 --- a/mahjong/hand_calculating/hand.py +++ b/mahjong/hand_calculating/hand.py @@ -178,6 +178,7 @@ def estimate_hand_value( :class:`~mahjong.hand_calculating.scores.Aotenjou` for aotenjou (limitless) scoring :param ura_dora_indicators: ura dora indicator tile indices in 136-format (counted only when riichi or double riichi is declared) + :param num_nuki_dora: the number of nuki dora (north wind extraction) :return: :class:`~mahjong.hand_calculating.hand_response.HandResponse` with scoring details on success, or with :attr:`~mahjong.hand_calculating.hand_response.HandResponse.error` set on failure