Source code for mahjong.hand_calculating.hand

from collections.abc import Collection
from typing import TypedDict

from mahjong.constants import AKA_DORAS, CHUN, HAKU, HATSU
from mahjong.hand_calculating.divider import HandDivider
from mahjong.hand_calculating.fu import FuCalculator, FuDetail
from mahjong.hand_calculating.hand_config import HandConfig
from mahjong.hand_calculating.hand_response import HandResponse
from mahjong.hand_calculating.scores import Aotenjou, ScoresCalculator, ScoresResult
from mahjong.hand_calculating.yaku import Yaku
from mahjong.meld import Meld
from mahjong.tile import TilesConverter
from mahjong.utils import build_dora_count_map, classify_hand_suits, count_dora_for_hand, plus_dora


class _CalculatedHand(TypedDict):
    cost: ScoresResult | None
    error: str | None
    hand_yaku: list[Yaku]
    han: int
    fu: int
    fu_details: list[FuDetail]


# suit bitmask: sou=1, pin=2, man=4
_ALL_SUITS_MASK = 7

_DEFAULT_CONFIG = HandConfig()


[docs] class HandCalculator: """ Hand value estimator. Evaluate a winning hand's han, fu, yaku, and payment amounts. Accepts tiles in 136-format, an optional :class:`~mahjong.hand_calculating.hand_config.HandConfig` for win conditions and rule variants, and optional melds and dora/ura-dora indicators. When multiple valid decompositions exist, the highest-scoring result is returned. """ # basic hand validation ERR_NO_WINNING_TILE = "winning_tile_not_in_hand" """``win_tile`` index is not present in the ``tiles`` array.""" ERR_HAND_NOT_WINNING = "hand_not_winning" """No valid decomposition exists; the hand is not a winning hand.""" ERR_NO_YAKU = "no_yaku" """Hand can be decomposed but has zero han (no yaku applies).""" # riichi constraints ERR_OPEN_HAND_RIICHI = "open_hand_riichi_not_allowed" """Riichi declared on an open hand (open melds present).""" ERR_OPEN_HAND_DABURI = "open_hand_daburi_not_allowed" """Double riichi declared on an open hand.""" ERR_IPPATSU_WITHOUT_RIICHI = "ippatsu_without_riichi_not_allowed" """Ippatsu claimed without riichi or double riichi.""" # win-condition conflicts ERR_CHANKAN_WITH_TSUMO = "chankan_with_tsumo_not_allowed" """Chankan (robbing a kan) requires a ron win.""" ERR_RINSHAN_WITHOUT_TSUMO = "rinshan_without_tsumo_not_allowed" """Rinshan kaihou (win after kan) requires a tsumo win.""" ERR_HAITEI_WITHOUT_TSUMO = "haitei_without_tsumo_not_allowed" """Haitei raoyue (last-tile draw) requires a tsumo win.""" ERR_HOUTEI_WITH_TSUMO = "houtei_with_tsumo_not_allowed" """Houtei raoyui (last-tile discard) requires a ron win.""" ERR_HAITEI_WITH_RINSHAN = "haitei_with_rinshan_not_allowed" """Haitei and rinshan are mutually exclusive (different last-tile sources).""" ERR_HOUTEI_WITH_CHANKAN = "houtei_with_chankan_not_allowed" """Houtei and chankan are mutually exclusive.""" # tenhou (blessing of heaven) constraints ERR_TENHOU_NOT_AS_DEALER = "tenhou_not_as_dealer_not_allowed" """Tenhou is exclusive to the dealer (East player).""" ERR_TENHOU_WITHOUT_TSUMO = "tenhou_without_tsumo_not_allowed" """Tenhou requires a tsumo win on the dealer's first draw.""" ERR_TENHOU_WITH_MELD = "tenhou_with_meld_not_allowed" """Tenhou requires a closed hand with no declared melds.""" # chiihou (blessing of earth) constraints ERR_CHIIHOU_AS_DEALER = "chiihou_as_dealer_not_allowed" """Chiihou is exclusive to non-dealer players.""" ERR_CHIIHOU_WITHOUT_TSUMO = "chiihou_without_tsumo_not_allowed" """Chiihou requires a tsumo win on the player's first draw.""" ERR_CHIIHOU_WITH_MELD = "chiihou_with_meld_not_allowed" """Chiihou requires a closed hand with no declared melds.""" # renhou (blessing of man) constraints ERR_RENHOU_AS_DEALER = "renhou_as_dealer_not_allowed" """Renhou is exclusive to non-dealer players.""" ERR_RENHOU_WITH_TSUMO = "renhou_with_tsumo_not_allowed" """Renhou requires a ron win before the player's first draw.""" ERR_RENHOU_WITH_MELD = "renhou_with_meld_not_allowed" """Renhou requires a closed hand with no declared melds."""
[docs] @staticmethod def estimate_hand_value( tiles: Collection[int], win_tile: int, melds: Collection[Meld] | None = None, dora_indicators: Collection[int] | None = None, config: HandConfig | None = None, scores_calculator_factory: type[ScoresCalculator] = ScoresCalculator, ura_dora_indicators: Collection[int] | None = None, ) -> HandResponse: """ Estimate the point value of a winning hand. Validate the hand and win conditions, decompose the hand into all possible block combinations, evaluate yaku and fu for each decomposition, and return the highest-scoring result as a :class:`~mahjong.hand_calculating.hand_response.HandResponse`. Basic closed ron with tanyao: >>> from mahjong.hand_calculating.hand import HandCalculator >>> from mahjong.tile import TilesConverter >>> tiles = TilesConverter.string_to_136_array(man="22444", pin="333567", sou="444") >>> win_tile = TilesConverter.string_to_136_array(sou="4")[0] >>> result = HandCalculator.estimate_hand_value(tiles, win_tile) >>> result.han 1 >>> result.fu 40 >>> result.cost["main"] 1300 Tsumo win with riichi and pinfu: >>> from mahjong.hand_calculating.hand_config import HandConfig >>> tiles = TilesConverter.string_to_136_array(man="234789", pin="12345666") >>> win_tile = TilesConverter.string_to_136_array(pin="6")[0] >>> config = HandConfig(is_tsumo=True, is_riichi=True) >>> result = HandCalculator.estimate_hand_value(tiles, win_tile, config=config) >>> result.han 3 >>> result.fu 20 >>> result.cost["main"] 1300 >>> result.cost["additional"] 700 Dealer tsumo with riichi and ippatsu: >>> from mahjong.constants import EAST >>> config = HandConfig(is_tsumo=True, is_riichi=True, is_ippatsu=True, player_wind=EAST) >>> result = HandCalculator.estimate_hand_value(tiles, win_tile, config=config) >>> result.error is None True Open hand with melds and dora: >>> from mahjong.meld import Meld >>> tiles = TilesConverter.string_to_136_array(man="234567", pin="22", sou="234", honors="555") >>> win_tile = TilesConverter.string_to_136_array(man="7")[0] >>> melds = [Meld(meld_type=Meld.PON, tiles=TilesConverter.string_to_136_array(honors="555"))] >>> dora_indicators = [TilesConverter.string_to_136_array(man="6")[0]] >>> result = HandCalculator.estimate_hand_value(tiles, win_tile, melds=melds, dora_indicators=dora_indicators) >>> result.han 2 Invalid hand returns an error: >>> tiles = TilesConverter.string_to_136_array(man="12345") >>> win_tile = TilesConverter.string_to_136_array(man="1")[0] >>> result = HandCalculator.estimate_hand_value(tiles, win_tile) >>> result.error == HandCalculator.ERR_HAND_NOT_WINNING True :param tiles: hand tiles in 136-format (14 tiles including the winning tile; 15 with one kan, 16 with two, etc.) :param win_tile: the winning tile index in 136-format (must be present in ``tiles``) :param melds: declared melds (:class:`~mahjong.meld.Meld` objects for chi, pon, kan) :param dora_indicators: dora indicator tile indices in 136-format :param config: hand configuration with win conditions, wind context, and optional rules; defaults to a closed ron with no special conditions :param scores_calculator_factory: scoring calculator class; pass :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) :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 """ if not melds: melds = [] if not dora_indicators: dora_indicators = [] if not ura_dora_indicators: ura_dora_indicators = [] config = config or _DEFAULT_CONFIG hand_yaku = [] scores_calculator = scores_calculator_factory() tiles_34 = TilesConverter.to_34_array(tiles) is_aotenjou = isinstance(scores_calculator, Aotenjou) opened_melds = [x.tiles_34 for x in melds if x.opened] is_open_hand = len(opened_melds) > 0 # special situation if config.is_nagashi_mangan: hand_yaku.append(config.yaku.nagashi_mangan) fu = 30 han = config.yaku.nagashi_mangan.han_closed cost = scores_calculator.calculate_scores(han, fu, config, False) return HandResponse(cost, han, fu, hand_yaku) if win_tile not in tiles: return HandResponse(error=HandCalculator.ERR_NO_WINNING_TILE) if config.is_riichi and not config.is_daburu_riichi and is_open_hand: return HandResponse(error=HandCalculator.ERR_OPEN_HAND_RIICHI) if config.is_daburu_riichi and is_open_hand: return HandResponse(error=HandCalculator.ERR_OPEN_HAND_DABURI) if config.is_ippatsu and not config.is_riichi and not config.is_daburu_riichi: return HandResponse(error=HandCalculator.ERR_IPPATSU_WITHOUT_RIICHI) if config.is_chankan and config.is_tsumo: return HandResponse(error=HandCalculator.ERR_CHANKAN_WITH_TSUMO) if config.is_rinshan and not config.is_tsumo: return HandResponse(error=HandCalculator.ERR_RINSHAN_WITHOUT_TSUMO) if config.is_haitei and not config.is_tsumo: return HandResponse(error=HandCalculator.ERR_HAITEI_WITHOUT_TSUMO) if config.is_houtei and config.is_tsumo: return HandResponse(error=HandCalculator.ERR_HOUTEI_WITH_TSUMO) if config.is_haitei and config.is_rinshan: return HandResponse(error=HandCalculator.ERR_HAITEI_WITH_RINSHAN) if config.is_houtei and config.is_chankan: return HandResponse(error=HandCalculator.ERR_HOUTEI_WITH_CHANKAN) # raise error only when player wind is defined (and is *not* EAST) if config.is_tenhou and config.player_wind and not config.is_dealer: return HandResponse(error=HandCalculator.ERR_TENHOU_NOT_AS_DEALER) if config.is_tenhou and not config.is_tsumo: return HandResponse(error=HandCalculator.ERR_TENHOU_WITHOUT_TSUMO) if config.is_tenhou and melds: return HandResponse(error=HandCalculator.ERR_TENHOU_WITH_MELD) # raise error only when player wind is defined (and is EAST) if config.is_chiihou and config.player_wind and config.is_dealer: return HandResponse(error=HandCalculator.ERR_CHIIHOU_AS_DEALER) if config.is_chiihou and not config.is_tsumo: return HandResponse(error=HandCalculator.ERR_CHIIHOU_WITHOUT_TSUMO) if config.is_chiihou and melds: return HandResponse(error=HandCalculator.ERR_CHIIHOU_WITH_MELD) # raise error only when player wind is defined (and is EAST) if config.is_renhou and config.player_wind and config.is_dealer: return HandResponse(error=HandCalculator.ERR_RENHOU_AS_DEALER) if config.is_renhou and config.is_tsumo: return HandResponse(error=HandCalculator.ERR_RENHOU_WITH_TSUMO) if config.is_renhou and melds: return HandResponse(error=HandCalculator.ERR_RENHOU_WITH_MELD) if not config.options.has_double_yakuman: config.yaku.daburu_kokushi.han_closed = 13 config.yaku.suuankou_tanki.han_closed = 13 config.yaku.daburu_chuuren_poutou.han_closed = 13 config.yaku.daisuushi.han_closed = 13 config.yaku.daisuushi.han_open = 13 hand_options = HandDivider.divide_hand(tiles_34, melds) # 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_aka_dora = 0 if config.options.has_aka_dora: precomputed_aka_dora = sum(t in AKA_DORAS for t in tiles) precomputed_ura_dora = 0 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) yakuhai_seat_wind_yaku = ( config.yaku.seat_wind_east, config.yaku.seat_wind_south, config.yaku.seat_wind_west, config.yaku.seat_wind_north, ) yakuhai_round_wind_yaku = ( config.yaku.round_wind_east, config.yaku.round_wind_south, config.yaku.round_wind_west, config.yaku.round_wind_north, ) calculated_hands: list[_CalculatedHand] = [] for hand in hand_options: is_chiitoitsu = config.yaku.chiitoitsu.is_condition_met(hand) valued_tiles = [HAKU, HATSU, CHUN, config.player_wind, config.round_wind] # precompute hand-level properties (invariant across win groups) # classify sets in single pass: chi (sequential), pon (triplet), kan (quad) chi_sets: list[list[int]] = [] pon_sets: list[list[int]] = [] kan_sets: list[list[int]] = [] for x in hand: length = len(x) if length == 4: kan_sets.append(x) elif length == 3: if x[0] == x[1]: pon_sets.append(x) else: chi_sets.append(x) is_tanyao_hand = config.yaku.tanyao.is_condition_met(hand) suit_mask, honor_count = classify_hand_suits(hand) has_honors = honor_count > 0 win_groups = HandCalculator._find_win_groups(win_tile, hand, opened_melds) for win_group in win_groups: cost = None error = None hand_yaku = [] han = 0 fu_details, fu = FuCalculator.calculate_fu(hand, win_tile, win_group, config, valued_tiles, melds) is_pinfu = len(fu_details) == 1 and not is_chiitoitsu and not is_open_hand if config.is_tsumo and not is_open_hand: hand_yaku.append(config.yaku.tsumo) if is_pinfu: hand_yaku.append(config.yaku.pinfu) if is_chiitoitsu: hand_yaku.append(config.yaku.chiitoitsu) if config.options.has_daisharin: is_daisharin = config.yaku.daisharin.is_condition_met( hand, config.options.has_daisharin_other_suits, ) if is_daisharin: config.yaku.daisharin.rename(hand) hand_yaku.append(config.yaku.daisharin) if config.options.has_daichisei and config.yaku.daichisei.is_condition_met(hand): hand_yaku.append(config.yaku.daichisei) if (not is_open_hand or config.options.has_open_tanyao) and is_tanyao_hand: hand_yaku.append(config.yaku.tanyao) if config.is_riichi and not config.is_daburu_riichi: if config.is_open_riichi: hand_yaku.append(config.yaku.open_riichi) else: hand_yaku.append(config.yaku.riichi) if config.is_daburu_riichi: if config.is_open_riichi: hand_yaku.append(config.yaku.daburu_open_riichi) else: hand_yaku.append(config.yaku.daburu_riichi) if ( not config.is_tsumo and config.options.has_sashikomi_yakuman and (config.yaku.daburu_open_riichi in hand_yaku or config.yaku.open_riichi in hand_yaku) ): hand_yaku.append(config.yaku.sashikomi) if config.is_ippatsu: hand_yaku.append(config.yaku.ippatsu) if config.is_rinshan: hand_yaku.append(config.yaku.rinshan) if config.is_chankan: hand_yaku.append(config.yaku.chankan) if config.is_haitei: hand_yaku.append(config.yaku.haitei) if config.is_houtei: hand_yaku.append(config.yaku.houtei) if config.is_renhou: if config.options.renhou_as_yakuman: hand_yaku.append(config.yaku.renhou_yakuman) else: hand_yaku.append(config.yaku.renhou) if config.is_tenhou: hand_yaku.append(config.yaku.tenhou) if config.is_chiihou: hand_yaku.append(config.yaku.chiihou) # chinitsu and honitsu are mutually exclusive if config.yaku.chinitsu.is_condition_met(hand): hand_yaku.append(config.yaku.chinitsu) elif config.yaku.honitsu.is_condition_met(hand): hand_yaku.append(config.yaku.honitsu) # tsuisou, honroto, chinroto require no chi sets (chi involves suited middle tiles) if not chi_sets: if has_honors and config.yaku.tsuisou.is_condition_met(hand): hand_yaku.append(config.yaku.tsuisou) if not is_tanyao_hand: if config.yaku.honroto.is_condition_met(hand): hand_yaku.append(config.yaku.honroto) if not has_honors and config.yaku.chinroto.is_condition_met(hand): hand_yaku.append(config.yaku.chinroto) if config.yaku.ryuisou.is_condition_met(hand): hand_yaku.append(config.yaku.ryuisou) if config.paarenchan > 0 and not config.options.paarenchan_needs_yaku: # if no yaku is even needed to win on paarenchan and it is paarenchan condition, just add paarenchan config.yaku.paarenchan.set_paarenchan_count(config.paarenchan) hand_yaku.append(config.yaku.paarenchan) # small optimization, try to detect yaku with chi required sets only if we have chi sets in hand if chi_sets: # chantai, junchan, ittsu require terminals, impossible with tanyao if not is_tanyao_hand: if config.yaku.chantai.is_condition_met(hand): hand_yaku.append(config.yaku.chantai) if config.yaku.junchan.is_condition_met(hand): hand_yaku.append(config.yaku.junchan) if config.yaku.ittsu.is_condition_met(hand): hand_yaku.append(config.yaku.ittsu) if not is_open_hand: if config.yaku.ryanpeiko.is_condition_met(hand): hand_yaku.append(config.yaku.ryanpeiko) elif config.yaku.iipeiko.is_condition_met(hand): hand_yaku.append(config.yaku.iipeiko) # sanshoku requires same sequence in all 3 suits if suit_mask == _ALL_SUITS_MASK and config.yaku.sanshoku.is_condition_met(hand): hand_yaku.append(config.yaku.sanshoku) # small optimization, try to detect yaku with pon required sets only if we have pon sets in hand if pon_sets or kan_sets: if config.yaku.toitoi.is_condition_met(hand): hand_yaku.append(config.yaku.toitoi) if config.yaku.sanankou.is_condition_met(hand, win_tile, melds, config.is_tsumo): hand_yaku.append(config.yaku.sanankou) # sanshoku douko requires same triplet in all 3 suits if suit_mask == _ALL_SUITS_MASK and config.yaku.sanshoku_douko.is_condition_met(hand): hand_yaku.append(config.yaku.sanshoku_douko) # yakuhai, dragon, and wind yaku all require honor tiles if has_honors: if config.yaku.shosangen.is_condition_met(hand): hand_yaku.append(config.yaku.shosangen) if config.yaku.haku.is_condition_met(hand): hand_yaku.append(config.yaku.haku) if config.yaku.hatsu.is_condition_met(hand): hand_yaku.append(config.yaku.hatsu) if config.yaku.chun.is_condition_met(hand): hand_yaku.append(config.yaku.chun) for yaku in yakuhai_seat_wind_yaku: if yaku.is_condition_met(hand, config.player_wind): hand_yaku.append(yaku) for yaku in yakuhai_round_wind_yaku: if yaku.is_condition_met(hand, config.round_wind): hand_yaku.append(yaku) if config.yaku.daisangen.is_condition_met(hand): hand_yaku.append(config.yaku.daisangen) if config.yaku.shosuushi.is_condition_met(hand): hand_yaku.append(config.yaku.shosuushi) if config.yaku.daisuushi.is_condition_met(hand): hand_yaku.append(config.yaku.daisuushi) # closed kan can't be used in chuuren_poutou; requires terminals if not melds and not is_tanyao_hand and config.yaku.chuuren_poutou.is_condition_met(hand): if tiles_34[win_tile // 4] == 2 or tiles_34[win_tile // 4] == 4: hand_yaku.append(config.yaku.daburu_chuuren_poutou) else: hand_yaku.append(config.yaku.chuuren_poutou) if not is_open_hand and config.yaku.suuankou.is_condition_met(hand, win_tile, config.is_tsumo): if tiles_34[win_tile // 4] == 2: hand_yaku.append(config.yaku.suuankou_tanki) else: hand_yaku.append(config.yaku.suuankou) if config.yaku.sankantsu.is_condition_met(hand, melds): hand_yaku.append(config.yaku.sankantsu) if config.yaku.suukantsu.is_condition_met(hand, melds): hand_yaku.append(config.yaku.suukantsu) if config.paarenchan > 0 and config.options.paarenchan_needs_yaku and len(hand_yaku) > 0: # we waited until here to add paarenchan yakuman only if there is any other yaku config.yaku.paarenchan.set_paarenchan_count(config.paarenchan) hand_yaku.append(config.yaku.paarenchan) # yakuman is not connected with other yaku yakuman_list = [x for x in hand_yaku if x.is_yakuman] if yakuman_list: if not is_aotenjou: hand_yaku = yakuman_list else: scores_calculator.aotenjou_filter_yaku(hand_yaku, config) # ty: ignore[unresolved-attribute] yakuman_list = [] # calculate han for item in hand_yaku: if is_open_hand and item.han_open: han += item.han_open else: han += item.han_closed if han == 0: error = HandCalculator.ERR_NO_YAKU cost = None # dora is not added to yakuman and hands without yaku if not yakuman_list and not error: if precomputed_dora: config.yaku.dora.han_open = precomputed_dora config.yaku.dora.han_closed = precomputed_dora hand_yaku.append(config.yaku.dora) han += precomputed_dora if precomputed_aka_dora: config.yaku.aka_dora.han_open = precomputed_aka_dora config.yaku.aka_dora.han_closed = precomputed_aka_dora hand_yaku.append(config.yaku.aka_dora) han += precomputed_aka_dora if precomputed_ura_dora: config.yaku.ura_dora.han_closed = precomputed_ura_dora hand_yaku.append(config.yaku.ura_dora) han += precomputed_ura_dora if not is_aotenjou and (config.options.limit_to_sextuple_yakuman and han > 78): han = 78 if not error: cost = scores_calculator.calculate_scores(han, fu, config, len(yakuman_list) > 0) calculated_hand = _CalculatedHand( cost=cost, error=error, hand_yaku=hand_yaku, han=han, fu=fu, fu_details=fu_details, ) calculated_hands.append(calculated_hand) # exception hand if not is_open_hand and config.yaku.kokushi.is_condition_met(None, tiles_34): if tiles_34[win_tile // 4] == 2: hand_yaku.append(config.yaku.daburu_kokushi) else: hand_yaku.append(config.yaku.kokushi) if ( not config.is_tsumo and config.options.has_sashikomi_yakuman and config.is_open_riichi and (config.is_daburu_riichi or config.is_riichi) ): hand_yaku.append(config.yaku.sashikomi) if config.is_renhou and config.options.renhou_as_yakuman: hand_yaku.append(config.yaku.renhou_yakuman) if config.is_tenhou: hand_yaku.append(config.yaku.tenhou) if config.is_chiihou: hand_yaku.append(config.yaku.chiihou) if config.paarenchan > 0: config.yaku.paarenchan.set_paarenchan_count(config.paarenchan) hand_yaku.append(config.yaku.paarenchan) # calculate han han = 0 for item in hand_yaku: han += item.han_closed fu = 0 if is_aotenjou: fu = 30 if config.is_tsumo else 40 tiles_for_dora = list(tiles) count_of_dora = 0 for tile in tiles_for_dora: count_of_dora += plus_dora(tile, dora_indicators) if count_of_dora: config.yaku.dora.han_open = count_of_dora config.yaku.dora.han_closed = count_of_dora hand_yaku.append(config.yaku.dora) han += count_of_dora if config.is_riichi or config.is_daburu_riichi: count_of_ura_dora = 0 for tile in tiles_for_dora: count_of_ura_dora += plus_dora(tile, ura_dora_indicators) if count_of_ura_dora: config.yaku.ura_dora.han_closed = count_of_ura_dora hand_yaku.append(config.yaku.ura_dora) han += count_of_ura_dora cost = scores_calculator.calculate_scores(han, fu, config, len(hand_yaku) > 0) calculated_hands.append( _CalculatedHand(cost=cost, error=None, hand_yaku=hand_yaku, han=han, fu=fu, fu_details=[]), ) if not calculated_hands: return HandResponse(error=HandCalculator.ERR_HAND_NOT_WINNING) # find most expensive hand calculated_hands = sorted(calculated_hands, key=lambda x: (x["han"], x["fu"]), reverse=True) # correctly sort expensive hands by fu details calculated_hands = [ x for x in calculated_hands if x["han"] == calculated_hands[0]["han"] and x["fu"] == calculated_hands[0]["fu"] ] calculated_hands = sorted(calculated_hands, key=lambda x: sum([y["fu"] for y in x["fu_details"]]), reverse=True) calculated_hand = calculated_hands[0] error = calculated_hand["error"] if error: return HandResponse(error=error) return HandResponse( calculated_hand["cost"], calculated_hand["han"], calculated_hand["fu"], calculated_hand["hand_yaku"], error, calculated_hand["fu_details"], is_open_hand, )
@staticmethod def _find_win_groups(win_tile: int, hand: list[list[int]], opened_melds: list[list[int]]) -> list[list[int]]: win_tile_34 = (win_tile or 0) // 4 # track which opened melds have been matched using a consumed flags array consumed = [False] * len(opened_melds) # collect closed sets and find unique win groups in a single pass seen: set[tuple[int, ...]] = set() win_groups: list[list[int]] = [] for x in hand: # check if this set matches an unconsumed opened meld is_opened_meld = False for i, meld in enumerate(opened_melds): if not consumed[i] and x == meld: consumed[i] = True is_opened_meld = True break if is_opened_meld: continue # for forms like 45666 and ron on 6 # we can assume that ron was on 456 form and on 66 form # and depends on form we will have different hand cost # so, we check all possible win groups if win_tile_34 in x: key = tuple(x) if key not in seen: seen.add(key) win_groups.append(x) return win_groups