Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 128 additions & 37 deletions mahjong/hand_calculating/scores.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down