diff --git a/mahjong/hand_calculating/scores.py b/mahjong/hand_calculating/scores.py index 363f63e..cf2be9d 100644 --- a/mahjong/hand_calculating/scores.py +++ b/mahjong/hand_calculating/scores.py @@ -1,3 +1,13 @@ +""" +Score calculation for winning hands. + +.. rubric:: Classes + +* :class:`ScoresResult` - typed dictionary holding the score breakdown +* :class:`ScoresCalculator` - standard scoring with han/fu, honba, and kyoutaku bonuses +* :class:`Aotenjou` - variant scoring with no mangan cap (aotenjou rule) +""" + from collections.abc import MutableSequence, MutableSet from typing import TypedDict @@ -6,6 +16,31 @@ class ScoresResult(TypedDict): + """ + Score breakdown for a winning hand. + + Each field represents a component of the final payment between players. + The meaning of ``main`` and ``additional`` depends on the win method: + + - **Ron**: ``main`` is the full payment from the discarding player; + ``additional`` is always 0. + - **Dealer tsumo**: ``main`` and ``additional`` are equal — each + non-dealer pays the same amount. + - **Non-dealer tsumo**: ``main`` is the dealer's payment; + ``additional`` is the payment from each non-dealer. + + :param main: base cost (before honba bonus) + :param additional: base cost for each non-dealer (before honba bonus); + 0 for ron + :param main_bonus: honba bonus added to ``main`` + :param additional_bonus: honba bonus added to ``additional`` + :param kyoutaku_bonus: points from accumulated riichi deposits + (1000 per deposit) + :param total: total points the winner earns + :param yaku_level: scoring tier label (e.g. ``"mangan"``, + ``"yakuman"``, ``""`` for below mangan) + """ + main: int additional: int main_bonus: int @@ -16,45 +51,61 @@ class ScoresResult(TypedDict): class ScoresCalculator: + """ + Calculate scores for a winning hand using standard Japanese mahjong rules. + + Scores are determined by han and fu values, then adjusted for honba (tsumi) + counters and kyoutaku (riichi deposit) bonuses. Hands at or above 5 han + receive fixed-tier payouts (mangan through yakuman). + """ + @staticmethod def calculate_scores(han: int, fu: int, config: HandConfig, is_yakuman: bool = False) -> ScoresResult: """ - Calculate how much scores cost a hand with given han and fu - :param han: int - :param fu: int - :param config: HandConfig object - :param is_yakuman: boolean - :return: ScoresResult with the following keys: - 'main': main cost (honba number / tsumi bou not included) - 'additional': additional cost (honba number not included) - 'main_bonus': extra cost due to honba number to be added on main cost - 'additional_bonus': extra cost due to honba number to be added on additional cost - 'kyoutaku_bonus': the points taken from accumulated riichi 1000-point bous (kyoutaku) - 'total': the total points the winner is to earn - 'yaku_level': level of yaku (e.g. yakuman, mangan, nagashi mangan, etc) - - for ron, main cost is the cost for the player who triggers the ron, and additional cost is always = 0 - for dealer tsumo, main cost is the same as additional cost, which is the cost for any other player - for non-dealer (player) tsumo, main cost is cost for dealer and additional is cost for player - - examples: - 1. dealer tsumo 2000 ALL in 2 honba, with 3 riichi bous on desk - {'main': 2000, 'additional': 2000, - 'main_bonus': 200, 'additional_bonus': 200, - 'kyoutaku_bonus': 3000, 'total': 9600, 'yaku_level': ''} - - 2. player tsumo 3900-2000 in 4 honba, with 1 riichi bou on desk - {'main': 3900, 'additional': 2000, - 'main_bonus': 400, 'additional_bonus': 400, - 'kyoutaku_bonus': 1000, 'total': 10100, 'yaku_level': ''} - - 3. dealer (or player) ron 12000 in 5 honba, with no riichi bou on desk - {'main': 12000, 'additional': 0, - 'main_bonus': 1500, 'additional_bonus': 0, - 'kyoutaku_bonus': 0, 'total': 13500} - + Calculate score payment for a hand with the given han and fu. + + Determine the base payment from han/fu, apply scoring tier caps + (mangan, haneman, baiman, sanbaiman, yakuman), then add honba + and kyoutaku bonuses. + + A non-dealer ron at 3 han 30 fu: + + >>> from mahjong.hand_calculating.scores import ScoresCalculator + >>> from mahjong.hand_calculating.hand_config import HandConfig + >>> result = ScoresCalculator.calculate_scores(han=3, fu=30, config=HandConfig()) + >>> result["main"] + 3900 + >>> result["additional"] + 0 + >>> result["total"] + 3900 + + A dealer tsumo mangan with 2 honba and 3 riichi deposits: + + >>> from mahjong.constants import EAST + >>> config = HandConfig(is_tsumo=True, player_wind=EAST, tsumi_number=2, kyoutaku_number=3) + >>> result = ScoresCalculator.calculate_scores(han=5, fu=30, config=config) + >>> result["main"] + 4000 + >>> result["additional"] + 4000 + >>> result["total"] + 15600 + + Mangan (5 han): + + >>> result = ScoresCalculator.calculate_scores(han=5, fu=30, config=HandConfig()) + >>> result["yaku_level"] + 'mangan' + >>> result["main"] + 8000 + + :param han: number of han (doubles) + :param fu: fu (minipoints), rounded to nearest 10 + :param config: hand configuration with win method, dealer status, and bonuses + :param is_yakuman: True if the hand contains yakuman yaku (bypasses kazoe limit) + :return: :class:`ScoresResult` with the full score breakdown """ - yaku_level = "" # kazoe hand @@ -166,8 +217,40 @@ def calculate_scores(han: int, fu: int, config: HandConfig, is_yakuman: bool = F class Aotenjou(ScoresCalculator): + """ + Variant scoring calculator for the aotenjou (blue ceiling) rule. + + Under aotenjou, there is no mangan cap — the base-points formula + ``fu * 2^(2+han)`` is applied directly regardless of han count. + Honba and kyoutaku bonuses are not applied. Yakuman yaku are treated + as normal yaku and contribute their han values rather than triggering + fixed payouts. + """ + @staticmethod def calculate_scores(han: int, fu: int, config: HandConfig, is_yakuman: bool = False) -> ScoresResult: # noqa: ARG004 + """ + Calculate score payment under aotenjou rules. + + Apply the base-points formula without any mangan cap or scoring tiers. + Honba and kyoutaku bonuses are not included. + + A non-dealer ron at 13 han 40 fu under aotenjou: + + >>> from mahjong.hand_calculating.scores import Aotenjou + >>> from mahjong.hand_calculating.hand_config import HandConfig + >>> result = Aotenjou.calculate_scores(han=13, fu=40, config=HandConfig()) + >>> result["main"] + 5242900 + >>> result["yaku_level"] + '' + + :param han: number of han (doubles) + :param fu: fu (minipoints) + :param config: hand configuration with win method and dealer status + :param is_yakuman: unused (aotenjou treats yakuman as normal yaku) + :return: :class:`ScoresResult` with the score breakdown (no bonuses) + """ base_points: int = fu * pow(2, 2 + han) rounded = (base_points + 99) // 100 * 100 double_rounded = (2 * base_points + 99) // 100 * 100 @@ -194,8 +277,16 @@ def calculate_scores(han: int, fu: int, config: HandConfig, is_yakuman: bool = F @staticmethod def aotenjou_filter_yaku(hand_yaku: MutableSequence[Yaku] | MutableSet[Yaku], config: HandConfig) -> None: - # in aotenjou yakumans are normal yaku - # but we need to filter lower yaku that are precursors to yakumans + """ + Remove lower yaku that are precursors to yakuman yaku in aotenjou mode. + + Under aotenjou, yakuman are scored as normal yaku with their han values. + When a yakuman is present, its precursor yaku (e.g. shosangen for daisangen, + toitoi for chinroto) must be removed to avoid double-counting. + + :param hand_yaku: mutable collection of yaku in the hand; modified in place + :param config: hand configuration providing yaku definitions + """ if config.yaku.daisangen in hand_yaku: # for daisangen precursors are all dragons and shosangen hand_yaku.remove(config.yaku.chun) diff --git a/pyproject.toml b/pyproject.toml index d48793d..b4536bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -116,7 +116,6 @@ convention = "pep257" # temporary "./mahjong/hand_calculating/__init__.py" = ["D"] "./mahjong/hand_calculating/hand.py" = ["D"] -"./mahjong/hand_calculating/scores.py" = ["D"] "./mahjong/hand_calculating/yaku_config.py" = ["D"] "./mahjong/hand_calculating/yaku.py" = ["D"] "./mahjong/hand_calculating/yaku_list/*" = ["D"]