Skip to content
60 changes: 44 additions & 16 deletions mahjong/shanten.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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:
Expand Down Expand Up @@ -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).

Expand All @@ -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:
Expand All @@ -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)
Expand All @@ -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)
Comment thread
Apricot-S marked this conversation as resolved.

def _run(self, depth: int) -> None:
if self._min_shanten == Shanten.AGARI_STATE:
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
57 changes: 56 additions & 1 deletion tests/tests_shanten.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
],
)
Expand Down Expand Up @@ -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)
Loading