diff --git a/mahjong/shanten.py b/mahjong/shanten.py index 0d0c596..3c3fd94 100644 --- a/mahjong/shanten.py +++ b/mahjong/shanten.py @@ -26,7 +26,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. @@ -48,11 +53,13 @@ def calculate_shanten(tiles_34: Sequence[int], use_chiitoitsu: bool = True, use_ :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)] + shanten_results = [_RegularShanten(tiles_34).calculate(count_of_tiles, is_three_player)] if count_of_tiles >= 13: if use_chiitoitsu: @@ -122,7 +129,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). @@ -149,12 +156,23 @@ def calculate_shanten_for_regular_hand(tiles_34: Sequence[int]) -> int: >>> 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) + return _RegularShanten(tiles_34).calculate(count_of_tiles, is_three_player) class _RegularShanten: @@ -169,7 +187,11 @@ 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: + 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) + if count_of_tiles > 14: msg = f"Too many tiles = {count_of_tiles}" raise ValueError(msg) @@ -178,18 +200,21 @@ def calculate(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(count_of_tiles) + self._remove_honor_and_terminal_man_tiles(count_of_tiles, is_three_player) init_mentsu = (14 - count_of_tiles) // 3 - self._scan(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) + # 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: if self._min_shanten == Shanten.AGARI_STATE: @@ -389,16 +414,19 @@ 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: + 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 i in range(27, 34): + for flag_pos, i in enumerate(indices): if self._tiles[i] == 4: self._number_melds += 1 self._number_jidahai += 1 - four_copies |= 1 << (i - 27) - isolated |= 1 << (i - 27) + four_copies |= 1 << flag_pos + isolated |= 1 << flag_pos if self._tiles[i] == 3: self._number_melds += 1 @@ -407,7 +435,7 @@ def _remove_character_tiles(self, nc: int) -> None: self._number_pairs += 1 if self._tiles[i] == 1: - isolated |= 1 << (i - 27) + isolated |= 1 << flag_pos if self._number_jidahai and (nc % 3) == 2: self._number_jidahai -= 1 diff --git a/tests/tests_shanten.py b/tests/tests_shanten.py index 8bbbe3e..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), ], ) @@ -196,3 +196,58 @@ 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"), + [ + ("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, is_three_player=True) == shanten_number + + +@pytest.mark.parametrize( + ("sou", "pin", "man", "honors", "shanten_number"), + [ + ("111345677", "567", "1", "", 1), + ("111345677", "56", "", "", 0), + ("", "123456789", "", "1111", 1), + ("112233", "1111", "", "111", 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_three_player_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(tiles, is_three_player=True) == shanten_number + + +@pytest.mark.parametrize("man", ["2", "3", "4", "5", "6", "7", "8"]) +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)