Source code for mahjong.hand_calculating.fu

from collections.abc import Collection, Sequence
from typing import TypedDict

from mahjong.constants import TERMINAL_AND_HONOR_INDICES
from mahjong.hand_calculating.hand_config import HandConfig
from mahjong.meld import Meld


[docs] class FuDetail(TypedDict): fu: int reason: str
# terminal/honor membership lookup for tile_34 indices 0..33 _IS_TERMINAL_OR_HONOR = [i in TERMINAL_AND_HONOR_INDICES for i in range(34)] # meld state bit flags _OPENED = 1 _IS_KAN = 2 class FuCalculator: BASE = "base" PENCHAN = "penchan" KANCHAN = "kanchan" VALUED_PAIR = "valued_pair" DOUBLE_VALUED_PAIR = "double_valued_pair" PAIR_WAIT = "pair_wait" TSUMO = "tsumo" HAND_WITHOUT_FU = "hand_without_fu" CLOSED_PON = "closed_pon" OPEN_PON = "open_pon" CLOSED_TERMINAL_PON = "closed_terminal_pon" OPEN_TERMINAL_PON = "open_terminal_pon" CLOSED_KAN = "closed_kan" OPEN_KAN = "open_kan" CLOSED_TERMINAL_KAN = "closed_terminal_kan" OPEN_TERMINAL_KAN = "open_terminal_kan" # lookup table indexed by [is_terminal_or_honor][is_kan][is_open] -> (fu, reason) _PON_KAN_FU_TABLE = ( ( # simple tiles ((4, CLOSED_PON), (2, OPEN_PON)), # pon ((16, CLOSED_KAN), (8, OPEN_KAN)), # kan ), ( # terminal or honor tiles ((8, CLOSED_TERMINAL_PON), (4, OPEN_TERMINAL_PON)), # pon ((32, CLOSED_TERMINAL_KAN), (16, OPEN_TERMINAL_KAN)), # kan ), ) @staticmethod def calculate_fu( hand: Collection[Sequence[int]], win_tile: int, win_group: Sequence[int], config: HandConfig, valued_tiles: Sequence[int | None] | None = None, melds: Collection[Meld] | None = None, ) -> tuple[list[FuDetail], int]: """ Calculate hand fu with explanations :param hand: :param win_tile: 136 tile format :param win_group: one set where win tile exists :param config: HandConfig object :param valued_tiles: dragons, player wind, round wind :param melds: opened sets :return: """ # chiitoitsu: always 25 fu if len(hand) == 7: return [FuDetail(fu=25, reason=FuCalculator.BASE)], 25 win_tile_34 = win_tile >> 2 if not valued_tiles: valued_tiles = () if not melds: melds = () fu_details: list[FuDetail] = [] fu_total = 0 is_tsumo = config.is_tsumo opts = config.options win_group_len = len(win_group) # detect if win_group is a chi (sequential meld) win_group_is_chi = False wg0 = wg1 = wg2 = -1 if win_group_len == 3: wg0 = win_group[0] wg1 = win_group[1] wg2 = win_group[2] win_group_is_chi = wg0 != wg1 # single pass over hand: find pair, collect pon/kan sets, # count chi groups matching win_group pair_tile = -1 pon_sets: list[Sequence[int]] = [] win_group_chi_count_in_hand = 0 for grp in hand: grp_len = len(grp) if grp_len == 2: pair_tile = grp[0] continue # pon/kan: all tiles are the same if grp[0] == grp[1]: pon_sets.append(grp) continue # chi: count matches for win_group open/closed detection if win_group_is_chi and grp_len == 3 and grp[0] == wg0 and grp[1] == wg1 and grp[2] == wg2: win_group_chi_count_in_hand += 1 # preprocess melds into a fixed-size state table (tile_34 -> bit flags) is_open_hand = False win_group_open_chi_count = 0 meld_state = [0] * 34 for m in melds: if m.opened: is_open_hand = True if m.type == Meld.CHI: if win_group_is_chi: t0 = m.tiles[0] >> 2 t1 = m.tiles[1] >> 2 t2 = m.tiles[2] >> 2 if t0 == wg0 and t1 == wg1 and t2 == wg2: win_group_open_chi_count += 1 else: tile = m.tiles[0] >> 2 state = 0 if m.opened: state |= _OPENED if m.type in (Meld.KAN, Meld.SHOUMINKAN): state |= _IS_KAN meld_state[tile] = state # win_group chi is closed if hand has more of this pattern than open melds win_group_is_closed_chi = win_group_is_chi and win_group_chi_count_in_hand > win_group_open_chi_count # wait fu: penchan and kanchan (only for closed chi containing win tile) if win_group_is_closed_chi: start_rank = wg0 % 9 # penchan: edge wait completing 1-2-3 with the 3, or 7-8-9 with the 7 is_penchan = (start_rank == 0 and win_tile_34 == wg2) or (start_rank == 6 and win_tile_34 == wg0) if is_penchan: fu_details.append(FuDetail(fu=2, reason=FuCalculator.PENCHAN)) fu_total += 2 # kanchan: wait on the middle tile if win_tile_34 == wg1: fu_details.append(FuDetail(fu=2, reason=FuCalculator.KANCHAN)) fu_total += 2 # valued pair fu if pair_tile >= 0 and valued_tiles: valued_count = valued_tiles.count(pair_tile) if valued_count == 1: fu_details.append(FuDetail(fu=2, reason=FuCalculator.VALUED_PAIR)) fu_total += 2 elif valued_count >= 2: # e.g. east-east pair when player is east fu_details.append(FuDetail(fu=4, reason=FuCalculator.DOUBLE_VALUED_PAIR)) fu_total += 4 # pair wait fu if win_group_len == 2: fu_details.append(FuDetail(fu=2, reason=FuCalculator.PAIR_WAIT)) fu_total += 2 # pon/kan fu via lookup table for set_item in pon_sets: tile = set_item[0] state = meld_state[tile] set_was_open = (state & _OPENED) != 0 is_kan_set = len(set_item) == 4 or (state & _IS_KAN) != 0 # ron on the third pon tile counts pon as open if not is_tsumo and win_group_len == len(set_item) and win_group[0] == tile and win_group[1] == tile: set_was_open = True fu, reason = FuCalculator._PON_KAN_FU_TABLE[_IS_TERMINAL_OR_HONOR[tile]][is_kan_set][set_was_open] fu_details.append(FuDetail(fu=fu, reason=reason)) fu_total += fu # tsumo fu (not for pinfu unless option is enabled) if is_tsumo and (fu_total > 0 or opts.fu_for_pinfu_tsumo): fu_details.append(FuDetail(fu=2, reason=FuCalculator.TSUMO)) fu_total += 2 # open pinfu: no 1-20 hands exist, add 2 fu if is_open_hand and fu_total == 0 and opts.fu_for_open_pinfu: fu_details.append(FuDetail(fu=2, reason=FuCalculator.HAND_WITHOUT_FU)) fu_total += 2 # base fu: 30 for closed ron, 20 for open or tsumo base_fu = 20 if is_open_hand or is_tsumo else 30 fu_details.append(FuDetail(fu=base_fu, reason=FuCalculator.BASE)) fu_total += base_fu # round up to the nearest 10 return fu_details, (fu_total + 9) // 10 * 10