From 78bd84e76914beddef200998e68013655988d32d Mon Sep 17 00:00:00 2001 From: Apricot-S Date: Sat, 18 Apr 2026 02:21:45 +0900 Subject: [PATCH 01/11] test: Add unit tests --- tests/tests_shanten.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/tests_shanten.py b/tests/tests_shanten.py index 8bbbe3e..e275670 100644 --- a/tests/tests_shanten.py +++ b/tests/tests_shanten.py @@ -196,3 +196,37 @@ def test_calculate_shanten_kokushi_should_be_ignored_when_melds_exist() -> None: # Expected: kokushi path must be ignored, so results should match assert shanten_with_kokushi == shanten_without_kokushi + + +@pytest.mark.parametrize( + ("sou", "pin", "man", "honors", "shanten_number"), + [ + ("111345677", "567", "1", "", 1), + ("111345677", "56", "", "", 0), + ("", "123456789", "", "1111", 1), + ("112233", "1111", "123", "", 1), + ("", "", "", "1111222333444", 1), + ("", "", "9999", "222333444", 1), + ("", "11", "", "11112222333", 2), + ("", "11", "11119999", "111", 2), + ("", "23", "", "11112222333", 2), + ("", "", "", "1111222233334", 3), + ("", "", "11119", "22223333", 3), + ], +) +def test_calculate_shanten_for_regular_hand_3p_for_not_completed_hand( + sou: str, + pin: str, + man: str, + honors: str, + shanten_number: int, +) -> None: + tiles = TilesConverter.string_to_34_array(sou=sou, pin=pin, man=man, honors=honors) + assert Shanten.calculate_shanten_for_regular_hand_3p(tiles) == shanten_number + + +@pytest.mark.parametrize("man", ["2", "3", "4", "5", "6", "7", "8"]) +def test_calculate_shanten_raises_error_for_manzu(man: str) -> None: + tiles = TilesConverter.string_to_34_array(man=man) + with pytest.raises(ValueError, match="Invalid tile for three player"): + Shanten.calculate_shanten_for_regular_hand_3p(tiles) From 49fdeb71c40a4e815884811b3ce3a9593260b4d0 Mon Sep 17 00:00:00 2001 From: Apricot-S Date: Sat, 18 Apr 2026 02:29:58 +0900 Subject: [PATCH 02/11] test: Fix an invalid test case --- tests/tests_shanten.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/tests_shanten.py b/tests/tests_shanten.py index e275670..457d801 100644 --- a/tests/tests_shanten.py +++ b/tests/tests_shanten.py @@ -204,7 +204,7 @@ def test_calculate_shanten_kokushi_should_be_ignored_when_melds_exist() -> None: ("111345677", "567", "1", "", 1), ("111345677", "56", "", "", 0), ("", "123456789", "", "1111", 1), - ("112233", "1111", "123", "", 1), + ("112233", "1111", "", "111", 1), ("", "", "", "1111222333444", 1), ("", "", "9999", "222333444", 1), ("", "11", "", "11112222333", 2), From 88c5e8b667777ff6dca1255bdd6159cea189c565 Mon Sep 17 00:00:00 2001 From: Apricot-S Date: Sat, 18 Apr 2026 02:37:39 +0900 Subject: [PATCH 03/11] feat: Add shanten calculation for three player --- mahjong/shanten.py | 75 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/mahjong/shanten.py b/mahjong/shanten.py index 0d0c596..130d016 100644 --- a/mahjong/shanten.py +++ b/mahjong/shanten.py @@ -156,6 +156,11 @@ def calculate_shanten_for_regular_hand(tiles_34: Sequence[int]) -> int: count_of_tiles = sum(tiles_34) return _RegularShanten(tiles_34).calculate(count_of_tiles) + @staticmethod + def calculate_shanten_for_regular_hand_3p(tiles_34: Sequence[int]) -> int: + count_of_tiles = sum(tiles_34) + return _RegularShanten(tiles_34).calculate_3p(count_of_tiles) + class _RegularShanten: def __init__(self, tiles_34: Sequence[int]) -> None: @@ -185,12 +190,38 @@ def calculate(self, count_of_tiles: int) -> int: return self._min_shanten + def calculate_3p(self, count_of_tiles: int) -> int: + if any(self._tiles[1:8]): + msg = "Invalid tile for three player" + raise ValueError(msg) + + if count_of_tiles > 14: + msg = f"Too many tiles = {count_of_tiles}" + raise ValueError(msg) + + if count_of_tiles % 3 == 0: + msg = f"Invalid tile count = {count_of_tiles}. Valid counts: 1, 2, 4, 5, 7, 8, 10, 11, 13, 14." + raise ValueError(msg) + + self._remove_character_tiles_3p(count_of_tiles) + + init_mentsu = (14 - count_of_tiles) // 3 + self._scan_3p(init_mentsu) + + return self._min_shanten + def _scan(self, init_mentsu: int) -> None: for i in range(27): self._flag_four_copies |= (self._tiles[i] == 4) << i self._number_melds += init_mentsu self._run(0) + def _scan_3p(self, init_mentsu: int) -> None: + for i in range(27): + self._flag_four_copies |= (self._tiles[i] == 4) << i + self._number_melds += init_mentsu + self._run(9) + def _run(self, depth: int) -> None: if self._min_shanten == Shanten.AGARI_STATE: return None @@ -416,3 +447,47 @@ def _remove_character_tiles(self, nc: int) -> None: self._flag_isolated_tiles |= 1 << 27 if (four_copies | isolated) == four_copies: self._flag_four_copies |= 1 << 27 + + def _remove_character_tiles_3p(self, nc: int) -> None: + four_copies = 0 + isolated = 0 + + for i in range(27, 34): + if self._tiles[i] == 4: + self._number_melds += 1 + self._number_jidahai += 1 + four_copies |= 1 << (i - 27) + isolated |= 1 << (i - 27) + + if self._tiles[i] == 3: + self._number_melds += 1 + + if self._tiles[i] == 2: + self._number_pairs += 1 + + if self._tiles[i] == 1: + isolated |= 1 << (i - 27) + + for index, i in enumerate([0, 8]): + if self._tiles[i] == 4: + self._number_melds += 1 + self._number_jidahai += 1 + four_copies |= 1 << (index + 7) + isolated |= 1 << (index + 7) + + if self._tiles[i] == 3: + self._number_melds += 1 + + if self._tiles[i] == 2: + self._number_pairs += 1 + + if self._tiles[i] == 1: + isolated |= 1 << (index + 7) + + if self._number_jidahai and (nc % 3) == 2: + self._number_jidahai -= 1 + + if isolated: + self._flag_isolated_tiles |= 1 << 27 + if (four_copies | isolated) == four_copies: + self._flag_four_copies |= 1 << 27 From 2ad303ce9a62f384aa37819a6d317a82f2ef4fb8 Mon Sep 17 00:00:00 2001 From: Apricot-S Date: Sat, 18 Apr 2026 02:46:47 +0900 Subject: [PATCH 04/11] refactor --- mahjong/shanten.py | 24 ++++-------------------- 1 file changed, 4 insertions(+), 20 deletions(-) diff --git a/mahjong/shanten.py b/mahjong/shanten.py index 130d016..1ba786c 100644 --- a/mahjong/shanten.py +++ b/mahjong/shanten.py @@ -452,28 +452,12 @@ def _remove_character_tiles_3p(self, nc: int) -> None: four_copies = 0 isolated = 0 - for i in range(27, 34): - if self._tiles[i] == 4: - self._number_melds += 1 - self._number_jidahai += 1 - four_copies |= 1 << (i - 27) - isolated |= 1 << (i - 27) - - if self._tiles[i] == 3: - self._number_melds += 1 - - if self._tiles[i] == 2: - self._number_pairs += 1 - - if self._tiles[i] == 1: - isolated |= 1 << (i - 27) - - for index, i in enumerate([0, 8]): + for flag_pos, i in enumerate([*range(27, 34), 0, 8]): if self._tiles[i] == 4: self._number_melds += 1 self._number_jidahai += 1 - four_copies |= 1 << (index + 7) - isolated |= 1 << (index + 7) + four_copies |= 1 << flag_pos + isolated |= 1 << flag_pos if self._tiles[i] == 3: self._number_melds += 1 @@ -482,7 +466,7 @@ def _remove_character_tiles_3p(self, nc: int) -> None: self._number_pairs += 1 if self._tiles[i] == 1: - isolated |= 1 << (index + 7) + isolated |= 1 << flag_pos if self._number_jidahai and (nc % 3) == 2: self._number_jidahai -= 1 From a5183faaf5f6db83dbafccdade4452025098045a Mon Sep 17 00:00:00 2001 From: Apricot-S Date: Sat, 18 Apr 2026 15:56:46 +0900 Subject: [PATCH 05/11] refactor: Use itertools.chain --- mahjong/shanten.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mahjong/shanten.py b/mahjong/shanten.py index 1ba786c..9c14df6 100644 --- a/mahjong/shanten.py +++ b/mahjong/shanten.py @@ -1,4 +1,5 @@ from collections.abc import Sequence +from itertools import chain from mahjong.constants import TERMINAL_AND_HONOR_INDICES @@ -452,7 +453,7 @@ def _remove_character_tiles_3p(self, nc: int) -> None: four_copies = 0 isolated = 0 - for flag_pos, i in enumerate([*range(27, 34), 0, 8]): + for flag_pos, i in enumerate(chain(range(27, 34), [0, 8])): if self._tiles[i] == 4: self._number_melds += 1 self._number_jidahai += 1 From e94e05428a330a839409eaa4cea02d8bc47fb85d Mon Sep 17 00:00:00 2001 From: Apricot-S Date: Tue, 21 Apr 2026 02:02:58 +0900 Subject: [PATCH 06/11] feat: Add is_three_player parameter --- mahjong/shanten.py | 84 +++++++++--------------------------------- tests/tests_shanten.py | 4 +- 2 files changed, 19 insertions(+), 69 deletions(-) diff --git a/mahjong/shanten.py b/mahjong/shanten.py index 9c14df6..8e741ea 100644 --- a/mahjong/shanten.py +++ b/mahjong/shanten.py @@ -27,7 +27,12 @@ class Shanten: """Hand is complete (agari).""" @staticmethod - def calculate_shanten(tiles_34: Sequence[int], use_chiitoitsu: bool = True, use_kokushi: bool = True) -> int: + def calculate_shanten( + tiles_34: Sequence[int], + use_chiitoitsu: bool = True, + use_kokushi: bool = True, + is_three_player: bool = False, + ) -> int: """ Return the minimum shanten number across regular, chiitoitsu, and kokushi hand types. @@ -53,7 +58,7 @@ def calculate_shanten(tiles_34: Sequence[int], use_chiitoitsu: bool = True, use_ :raises ValueError: if tile count exceeds 14 or is divisible by 3 """ count_of_tiles = sum(tiles_34) - shanten_results = [_RegularShanten(tiles_34).calculate(count_of_tiles)] + shanten_results = [_RegularShanten(tiles_34).calculate(count_of_tiles, is_three_player)] if count_of_tiles >= 13: if use_chiitoitsu: @@ -123,7 +128,7 @@ def calculate_shanten_for_kokushi_hand(tiles_34: Sequence[int]) -> int: return 13 - terminals - (1 if completed_terminals else 0) @staticmethod - def calculate_shanten_for_regular_hand(tiles_34: Sequence[int]) -> int: + def calculate_shanten_for_regular_hand(tiles_34: Sequence[int], is_three_player: bool = False) -> int: """ Calculate the shanten number for a regular hand (4 melds + 1 pair). @@ -155,12 +160,7 @@ def calculate_shanten_for_regular_hand(tiles_34: Sequence[int]) -> int: :raises ValueError: if tile count exceeds 14 or is divisible by 3 """ count_of_tiles = sum(tiles_34) - return _RegularShanten(tiles_34).calculate(count_of_tiles) - - @staticmethod - def calculate_shanten_for_regular_hand_3p(tiles_34: Sequence[int]) -> int: - count_of_tiles = sum(tiles_34) - return _RegularShanten(tiles_34).calculate_3p(count_of_tiles) + return _RegularShanten(tiles_34).calculate(count_of_tiles, is_three_player) class _RegularShanten: @@ -175,24 +175,8 @@ def __init__(self, tiles_34: Sequence[int]) -> None: self._flag_isolated_tiles = 0 self._min_shanten = 8 - def calculate(self, count_of_tiles: int) -> int: - if count_of_tiles > 14: - msg = f"Too many tiles = {count_of_tiles}" - raise ValueError(msg) - - if count_of_tiles % 3 == 0: - msg = f"Invalid tile count = {count_of_tiles}. Valid counts: 1, 2, 4, 5, 7, 8, 10, 11, 13, 14." - raise ValueError(msg) - - self._remove_character_tiles(count_of_tiles) - - init_mentsu = (14 - count_of_tiles) // 3 - self._scan(init_mentsu) - - return self._min_shanten - - def calculate_3p(self, count_of_tiles: int) -> int: - if any(self._tiles[1:8]): + def calculate(self, count_of_tiles: int, is_three_player: bool) -> int: + if is_three_player and any(self._tiles[1:8]): msg = "Invalid tile for three player" raise ValueError(msg) @@ -204,24 +188,18 @@ def calculate_3p(self, count_of_tiles: int) -> int: msg = f"Invalid tile count = {count_of_tiles}. Valid counts: 1, 2, 4, 5, 7, 8, 10, 11, 13, 14." raise ValueError(msg) - self._remove_character_tiles_3p(count_of_tiles) + self._remove_character_tiles(count_of_tiles, is_three_player) init_mentsu = (14 - count_of_tiles) // 3 - self._scan_3p(init_mentsu) + self._scan(init_mentsu, is_three_player) return self._min_shanten - def _scan(self, init_mentsu: int) -> None: + def _scan(self, init_mentsu: int, is_three_player: bool) -> None: for i in range(27): self._flag_four_copies |= (self._tiles[i] == 4) << i self._number_melds += init_mentsu - self._run(0) - - def _scan_3p(self, init_mentsu: int) -> None: - for i in range(27): - self._flag_four_copies |= (self._tiles[i] == 4) << i - self._number_melds += init_mentsu - self._run(9) + self._run(9 if is_three_player else 0) def _run(self, depth: int) -> None: if self._min_shanten == Shanten.AGARI_STATE: @@ -421,39 +399,11 @@ def _decrease_isolated_tile(self, k: int) -> None: self._tiles[k] += 1 self._flag_isolated_tiles &= ~(1 << k) - def _remove_character_tiles(self, nc: int) -> None: - four_copies = 0 - isolated = 0 - - for i in range(27, 34): - if self._tiles[i] == 4: - self._number_melds += 1 - self._number_jidahai += 1 - four_copies |= 1 << (i - 27) - isolated |= 1 << (i - 27) - - if self._tiles[i] == 3: - self._number_melds += 1 - - if self._tiles[i] == 2: - self._number_pairs += 1 - - if self._tiles[i] == 1: - isolated |= 1 << (i - 27) - - if self._number_jidahai and (nc % 3) == 2: - self._number_jidahai -= 1 - - if isolated: - self._flag_isolated_tiles |= 1 << 27 - if (four_copies | isolated) == four_copies: - self._flag_four_copies |= 1 << 27 - - def _remove_character_tiles_3p(self, nc: int) -> None: + def _remove_character_tiles(self, nc: int, is_three_player: bool) -> None: four_copies = 0 isolated = 0 - for flag_pos, i in enumerate(chain(range(27, 34), [0, 8])): + for flag_pos, i in enumerate(chain(range(27, 34), [0, 8] if is_three_player else [])): if self._tiles[i] == 4: self._number_melds += 1 self._number_jidahai += 1 diff --git a/tests/tests_shanten.py b/tests/tests_shanten.py index 457d801..3821288 100644 --- a/tests/tests_shanten.py +++ b/tests/tests_shanten.py @@ -222,11 +222,11 @@ def test_calculate_shanten_for_regular_hand_3p_for_not_completed_hand( shanten_number: int, ) -> None: tiles = TilesConverter.string_to_34_array(sou=sou, pin=pin, man=man, honors=honors) - assert Shanten.calculate_shanten_for_regular_hand_3p(tiles) == shanten_number + assert Shanten.calculate_shanten_for_regular_hand(tiles, True) == shanten_number @pytest.mark.parametrize("man", ["2", "3", "4", "5", "6", "7", "8"]) def test_calculate_shanten_raises_error_for_manzu(man: str) -> None: tiles = TilesConverter.string_to_34_array(man=man) with pytest.raises(ValueError, match="Invalid tile for three player"): - Shanten.calculate_shanten_for_regular_hand_3p(tiles) + Shanten.calculate_shanten(tiles, is_three_player=True) From 5b657351f0cd8a4948b528b078993cc5834f4112 Mon Sep 17 00:00:00 2001 From: Apricot-S Date: Tue, 21 Apr 2026 02:05:23 +0900 Subject: [PATCH 07/11] refactor: Rename a private method --- mahjong/shanten.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mahjong/shanten.py b/mahjong/shanten.py index 8e741ea..b6ecff0 100644 --- a/mahjong/shanten.py +++ b/mahjong/shanten.py @@ -188,7 +188,7 @@ def calculate(self, count_of_tiles: int, is_three_player: bool) -> int: msg = f"Invalid tile count = {count_of_tiles}. Valid counts: 1, 2, 4, 5, 7, 8, 10, 11, 13, 14." raise ValueError(msg) - self._remove_character_tiles(count_of_tiles, is_three_player) + self._remove_honor_tiles(count_of_tiles, is_three_player) init_mentsu = (14 - count_of_tiles) // 3 self._scan(init_mentsu, is_three_player) @@ -399,7 +399,7 @@ def _decrease_isolated_tile(self, k: int) -> None: self._tiles[k] += 1 self._flag_isolated_tiles &= ~(1 << k) - def _remove_character_tiles(self, nc: int, is_three_player: bool) -> None: + def _remove_honor_tiles(self, nc: int, is_three_player: bool) -> None: four_copies = 0 isolated = 0 From f2b1528ce0a4c014f4a042cf1fe4ec7cd3b19f4b Mon Sep 17 00:00:00 2001 From: Apricot-S Date: Sat, 25 Apr 2026 19:20:05 +0900 Subject: [PATCH 08/11] docs: Update docstrings --- mahjong/shanten.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/mahjong/shanten.py b/mahjong/shanten.py index b6ecff0..4462861 100644 --- a/mahjong/shanten.py +++ b/mahjong/shanten.py @@ -54,8 +54,10 @@ def calculate_shanten( :param tiles_34: hand in 34-format count array (length 34) :param use_chiitoitsu: include seven pairs pattern in calculation :param use_kokushi: include thirteen orphans pattern in calculation + :param is_three_player: if True, calculate using three-player rules where 2m-8m are unavailable :return: minimum shanten number (-1 for agari, 0 for tenpai, positive for tiles needed) - :raises ValueError: if tile count exceeds 14 or is divisible by 3 + :raises ValueError: if tile count exceeds 14, is divisible by 3, or contains 2m-8m + when ``is_three_player`` is True """ count_of_tiles = sum(tiles_34) shanten_results = [_RegularShanten(tiles_34).calculate(count_of_tiles, is_three_player)] @@ -155,9 +157,20 @@ def calculate_shanten_for_regular_hand(tiles_34: Sequence[int], is_three_player: >>> Shanten.calculate_shanten_for_regular_hand(tiles_34) 0 + Three-player shanten can differ from four-player shanten: + + >>> from mahjong.tile import TilesConverter + >>> tiles_34 = TilesConverter.one_line_string_to_34_array("1111m111122233z") + >>> Shanten.calculate_shanten_for_regular_hand(tiles_34) + 1 + >>> Shanten.calculate_shanten_for_regular_hand(tiles_34, is_three_player=True) + 2 + :param tiles_34: hand in 34-format count array (length 34) + :param is_three_player: if True, calculate using three-player rules where 2m-8m are unavailable :return: shanten number for regular hand (-1 for complete, 0+ otherwise) - :raises ValueError: if tile count exceeds 14 or is divisible by 3 + :raises ValueError: if tile count exceeds 14, is divisible by 3, or contains 2m-8m + when ``is_three_player`` is True """ count_of_tiles = sum(tiles_34) return _RegularShanten(tiles_34).calculate(count_of_tiles, is_three_player) From 1fc2bd848ee9b5153bd0882b0aa848edc2e72d2e Mon Sep 17 00:00:00 2001 From: Apricot-S Date: Sat, 25 Apr 2026 19:38:17 +0900 Subject: [PATCH 09/11] test: Add test cases --- tests/tests_shanten.py | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/tests/tests_shanten.py b/tests/tests_shanten.py index 3821288..8f38fe9 100644 --- a/tests/tests_shanten.py +++ b/tests/tests_shanten.py @@ -198,6 +198,27 @@ def test_calculate_shanten_kokushi_should_be_ignored_when_melds_exist() -> None: assert shanten_with_kokushi == shanten_without_kokushi +@pytest.mark.parametrize( + ("sou", "pin", "man", "honors", "shanten_number"), + [ + ("11123456788999", "", "", "", -1), + ("11122245679999", "", "", "", 0), + ("", "", "", "11112222333444", 1), + ("", "", "1111", "2222333444", 1), + ("", "11", "9999", "22223333", 2), + ], +) +def test_calculate_shanten_for_regular_hand_three_player( + sou: str, + pin: str, + man: str, + honors: str, + shanten_number: int, +) -> None: + tiles = TilesConverter.string_to_34_array(sou=sou, pin=pin, man=man, honors=honors) + assert Shanten.calculate_shanten_for_regular_hand(tiles, True) == shanten_number + + @pytest.mark.parametrize( ("sou", "pin", "man", "honors", "shanten_number"), [ @@ -214,7 +235,7 @@ def test_calculate_shanten_kokushi_should_be_ignored_when_melds_exist() -> None: ("", "", "11119", "22223333", 3), ], ) -def test_calculate_shanten_for_regular_hand_3p_for_not_completed_hand( +def test_calculate_shanten_for_regular_hand_three_player_for_not_completed_hand( sou: str, pin: str, man: str, @@ -226,7 +247,7 @@ def test_calculate_shanten_for_regular_hand_3p_for_not_completed_hand( @pytest.mark.parametrize("man", ["2", "3", "4", "5", "6", "7", "8"]) -def test_calculate_shanten_raises_error_for_manzu(man: str) -> None: +def test_calculate_shanten_raises_error_for_manzu_in_three_player(man: str) -> None: tiles = TilesConverter.string_to_34_array(man=man) with pytest.raises(ValueError, match="Invalid tile for three player"): Shanten.calculate_shanten(tiles, is_three_player=True) From 1ecb85b0bdbf3100fe722f545308f9ead5f5997f Mon Sep 17 00:00:00 2001 From: Apricot-S Date: Wed, 29 Apr 2026 19:38:41 +0900 Subject: [PATCH 10/11] test: Specify keyword arguments explicitly --- tests/tests_shanten.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/tests_shanten.py b/tests/tests_shanten.py index 8f38fe9..50642c1 100644 --- a/tests/tests_shanten.py +++ b/tests/tests_shanten.py @@ -45,7 +45,7 @@ def test_calculate_shanten_raises_error_too_many_tiles() -> None: # hand where three consecutive tiles each have 3 copies with neighbors >= 2, # triggering double syuntsu extraction in _run ("1111", "", "333444555", "", 1), - # hand with an honor pair, triggering pair counting in _remove_character_tiles + # hand with an honor pair, triggering pair counting in honor tile pre-processing ("111234567", "11", "", "77", 0), ], ) @@ -216,7 +216,7 @@ def test_calculate_shanten_for_regular_hand_three_player( shanten_number: int, ) -> None: tiles = TilesConverter.string_to_34_array(sou=sou, pin=pin, man=man, honors=honors) - assert Shanten.calculate_shanten_for_regular_hand(tiles, True) == shanten_number + assert Shanten.calculate_shanten_for_regular_hand(tiles, is_three_player=True) == shanten_number @pytest.mark.parametrize( @@ -243,7 +243,7 @@ def test_calculate_shanten_for_regular_hand_three_player_for_not_completed_hand( shanten_number: int, ) -> None: tiles = TilesConverter.string_to_34_array(sou=sou, pin=pin, man=man, honors=honors) - assert Shanten.calculate_shanten_for_regular_hand(tiles, True) == shanten_number + assert Shanten.calculate_shanten_for_regular_hand(tiles, is_three_player=True) == shanten_number @pytest.mark.parametrize("man", ["2", "3", "4", "5", "6", "7", "8"]) From dac0e8545adf0f60e2c26484ef1e5e03536a9889 Mon Sep 17 00:00:00 2001 From: Apricot-S Date: Wed, 29 Apr 2026 19:48:38 +0900 Subject: [PATCH 11/11] refactor: Address PR comments --- mahjong/shanten.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/mahjong/shanten.py b/mahjong/shanten.py index 4462861..3c3fd94 100644 --- a/mahjong/shanten.py +++ b/mahjong/shanten.py @@ -1,5 +1,4 @@ from collections.abc import Sequence -from itertools import chain from mahjong.constants import TERMINAL_AND_HONOR_INDICES @@ -201,7 +200,7 @@ def calculate(self, count_of_tiles: int, is_three_player: bool) -> int: msg = f"Invalid tile count = {count_of_tiles}. Valid counts: 1, 2, 4, 5, 7, 8, 10, 11, 13, 14." raise ValueError(msg) - self._remove_honor_tiles(count_of_tiles, is_three_player) + self._remove_honor_and_terminal_man_tiles(count_of_tiles, is_three_player) init_mentsu = (14 - count_of_tiles) // 3 self._scan(init_mentsu, is_three_player) @@ -212,6 +211,9 @@ def _scan(self, init_mentsu: int, is_three_player: bool) -> None: for i in range(27): self._flag_four_copies |= (self._tiles[i] == 4) << i self._number_melds += init_mentsu + # Four-player hands scan from 1m. Three-player hands skip the manzu suit, + # and start from 1p. The 1m and 9m are pre-processed with honors, + # and 2m-8m are unavailable. self._run(9 if is_three_player else 0) def _run(self, depth: int) -> None: @@ -412,11 +414,14 @@ def _decrease_isolated_tile(self, k: int) -> None: self._tiles[k] += 1 self._flag_isolated_tiles &= ~(1 << k) - def _remove_honor_tiles(self, nc: int, is_three_player: bool) -> None: + def _remove_honor_and_terminal_man_tiles(self, nc: int, is_three_player: bool) -> None: four_copies = 0 isolated = 0 + indices = list(range(27, 34)) + if is_three_player: + indices.extend([0, 8]) - for flag_pos, i in enumerate(chain(range(27, 34), [0, 8] if is_three_player else [])): + for flag_pos, i in enumerate(indices): if self._tiles[i] == 4: self._number_melds += 1 self._number_jidahai += 1