A complete implementation of Japanese Mahjong (Riichi Mahjong) rules engine
Project description
PyRiichi - Python Riichi Mahjong Engine
A full-featured Python Japanese riichi mahjong game engine with rule implementation, yaku detection, score calculation, and game-flow management.
Features
- 🎴 Complete tile system - Supports the standard 136-tile mahjong set, including Red Dora and dora calculation.
- 🎯 Winning-hand detection - Accurate winning-hand detection for standard and special shapes.
- 🏆 Yaku system - Implements standard yaku such as Riichi, Tanyao, Pinfu, and yakuman.
- 💰 Score calculation - Accurate fu, han, and point calculation following Japanese riichi mahjong rules.
- 🎮 Game engine - Complete game-flow control, including chi, pon, kan, riichi, and related operations.
- 📊 State management - Round Number, winds, honba, kyoutaku, and other game-state management.
- 🤖 AI players - Built-in AI strategies: random, simple heuristic, and defensive, with automatic game support.
- ⚙️ Ruleset configuration - Supports standard competitive rules and custom rulesets.
- 🔧 Easy integration - Clear API design for integration into other applications.
Project Info
- Project status: Development Status :: 3 - Alpha
- Keywords: mahjong, riichi, japanese, game, engine
- Homepage: https://github.com/d4n1elchen/pyriichi
- Documentation: https://github.com/d4n1elchen/pyriichi#readme
- Issues: https://github.com/d4n1elchen/pyriichi/issues
- Source: https://github.com/d4n1elchen/pyriichi
Installation
pip install pyriichi
Or install from source:
git clone https://github.com/d4n1elchen/pyriichi.git
cd pyriichi
pip install -e .
Quick Start
Basic Usage
from pyriichi.rules import RuleEngine, GamePhase
from pyriichi.player import RandomPlayer
# Initialize the game and players.
engine = RuleEngine(num_players=4)
players = [RandomPlayer(f"Player {i}") for i in range(4)]
engine.start_game()
engine.start_round()
engine.deal()
print(f"Game started. Current phase: {engine.get_phase()}")
# The engine exposes the current legal actions through waiting_for_actions.
# After deal, the dealer is waiting to discard or declare another legal action.
while engine.get_phase() == GamePhase.PLAYING:
if not engine.waiting_for_actions:
break
current_player_idx = next(iter(engine.waiting_for_actions))
player = players[current_player_idx]
actions = engine.get_available_actions(current_player_idx)
# Let the AI decide an action.
action, tile = player.decide_action(
engine.game_state,
current_player_idx,
engine.get_hand(current_player_idx),
actions,
)
print(f"Player {current_player_idx} executes: {action.name}" + (f" {tile}" if tile else ""))
# Execute the action.
result = engine.execute_action(current_player_idx, action, tile)
# Check the result.
if result.winners:
print(f"Win! Winners: {result.winners}")
break
if result.ryuukyoku:
print(f"Ryuukyoku: {result.ryuukyoku.ryuukyoku_type.en}")
break
Tile Representation and Operations
String Notation
PyRiichi uses compact string notation for mahjong tiles, making input and display convenient.
Basic format: number + suit letter
-
Manzu: use
m.1m= one manzu,2m= two manzu, ...,9m= nine manzu.
-
Pinzu: use
p.1p= one pinzu,2p= two pinzu, ...,9p= nine pinzu.
-
Souzu: use
s.1s= one souzu,2s= two souzu, ...,9s= nine souzu.
-
Honors: use
z.1z= east,2z= south,3z= west,4z= north.5z= haku,6z= hatsu,7z= chun.
Red Dora notation: use the r prefix.
r5p= red five pinzu.r5s= red five souzu.r5m= red five manzu.
Note: This is the standard format widely used in the Japanese mahjong community. Input and output both use the r5p style.
Examples:
from pyriichi import Tile, Suit, TileSet, parse_tiles, format_tiles
# Create one tile.
tile = Tile(Suit.MANZU, 1)
print(tile) # Output: 1m
# Parse tiles from a string.
tiles = parse_tiles("1m2m3m4p5p6p7s8s9s")
print(format_tiles(tiles)) # Output: 1m2m3m4p5p6p7s8s9s
# Parse tiles with Red Dora, using the standard r5p format.
red_dora_tiles = parse_tiles("r5p6p7p")
print(format_tiles(red_dora_tiles)) # Output: r5p6p7p
# Parse honors.
honor_tiles = parse_tiles("1z2z3z5z6z7z")
print(format_tiles(honor_tiles)) # Output: 1z2z3z5z6z7z
# Create and shuffle a tile set.
tile_set = TileSet()
tile_set.shuffle()
hands = tile_set.deal() # Deal to 4 players.
Notes:
- Strings may contain spaces or other characters;
parse_tiles()skips invalid characters automatically. - Multiple tiles can be written continuously, such as
"1m2m3m"for three manzu tiles. - Use
format_tiles()to convert a tile list back to string notation. - Red Dora format: use the standard
r5pformat with anrprefix. Input and output are consistent and support round-trip conversion.
Game Flow Control
from pyriichi import RuleEngine, GameAction
engine = RuleEngine()
engine.start_game()
engine.start_round()
engine.deal()
# After deal, the dealer is the current player and already has 14 tiles.
current_player = engine.get_current_player()
print(f"Current player: {current_player}")
# Discard. If nobody calls the discard, the engine advances the turn and draws
# for the next player automatically.
hand = engine.get_hand(current_player)
if hand.tiles:
discard_tile = hand.tiles[0]
result = engine.execute_action(current_player, GameAction.DISCARD, tile=discard_tile)
if result.drawn_tile is not None:
print(f"Next player drew: {result.drawn_tile}")
# Check the current player's legal actions, including tsumo if available.
next_player = engine.get_current_player()
actions = engine.get_available_actions(next_player)
if GameAction.TSUMO in actions:
result = engine.execute_action(next_player, GameAction.TSUMO)
print(f"Win! Winners: {result.winners}")
Hand Operations
from pyriichi import Hand, parse_tiles
# Create a hand.
tiles = parse_tiles("1m2m3m4p5p6p7s8s9s1z2z3z4z")
hand = Hand(tiles)
# Draw.
from pyriichi import Tile, Suit
new_tile = Tile(Suit.MANZU, 5)
hand.add_tile(new_tile)
# Discard.
hand.discard(new_tile)
# Check tenpai.
if hand.is_tenpai():
machi_tiles = hand.get_machi_tiles()
print(f"Machi tiles: {machi_tiles}")
# Check winning hand.
winning_tile = Tile(Suit.MANZU, 1)
if hand.is_winning_hand(winning_tile):
combinations = hand.get_winning_combinations(winning_tile)
print(f"Number of winning combinations: {len(combinations)}")
if combinations:
# get_winning_combinations returns List[List[Combination]].
winning_combination = combinations[0]
print("First winning combination:", winning_combination)
Rule Engine Hints
from pyriichi import RuleEngine, Suit, Tile
engine = RuleEngine(num_players=4)
engine.start_game()
engine.start_round()
engine.deal()
player = engine.get_current_player()
discard_tile = Tile(Suit.MANZU, 5)
hint = engine.get_tenpai_hint_after_discard(player, discard_tile)
if hint:
waits = ", ".join(f"{wait.tile}: {wait.remaining}" for wait in hint.waits)
print(f"Tenpai waits after discard: {waits}")
if hint.furiten:
print("This discard leaves the hand furiten.")
Calls
from pyriichi import Hand, Tile, Suit
hand = Hand([...]) # Hand tiles.
# Check pon.
tile = Tile(Suit.PINZU, 5)
if hand.can_pon(tile):
meld = hand.pon(tile)
print(f"Pon: {meld}")
# Check chi, which can only be called from kamicha.
if hand.can_chi(tile, from_player=0): # 0 means kamicha.
sequences = hand.can_chi(tile, from_player=0)
if sequences:
meld = hand.chi(tile, sequences[0])
print(f"Chi: {meld}")
Yaku Detection
from pyriichi import YakuChecker, Hand, GameState, parse_tiles
from pyriichi.tiles import Tile, Suit
yaku_checker = YakuChecker()
# Create a winning hand.
tiles = parse_tiles("1m2m3m4p5p6p7s8s9s2m3m4m5p")
hand = Hand(tiles)
winning_tile = Tile(Suit.PINZU, 5)
# Get winning combinations. Convert the first combination to a list when needed.
winning_combinations = hand.get_winning_combinations(winning_tile)
if winning_combinations:
winning_combination = list(winning_combinations[0])
game_state = GameState(num_players=4)
# Check all yaku.
yaku_results = yaku_checker.check_all(
hand=hand,
winning_tile=winning_tile,
winning_combination=winning_combination,
game_state=game_state,
is_tsumo=True,
player_position=0,
)
for result in yaku_results:
print(f"{result.yaku.en}: {result.han} han")
# Check a specific yaku.
riichi_results = yaku_checker.check_riichi(hand, game_state, is_ippatsu=True)
for result in riichi_results:
print(f"{result.yaku.en}: {result.han} han")
Score Calculation
from pyriichi import ScoreCalculator, YakuChecker, Hand, GameState, parse_tiles
from pyriichi.tiles import Tile, Suit
score_calculator = ScoreCalculator()
yaku_checker = YakuChecker()
# Create a winning hand.
tiles = parse_tiles("1m2m3m4p5p6p7s8s9s2m3m4m5p")
hand = Hand(tiles)
winning_tile = Tile(Suit.PINZU, 5)
# Get winning combinations. Convert the first combination to a list when needed.
winning_combinations = hand.get_winning_combinations(winning_tile)
if winning_combinations:
winning_combination = winning_combinations[0]
game_state = GameState(num_players=4)
# Check yaku first.
yaku_results = yaku_checker.check_all(
hand=hand,
winning_tile=winning_tile,
winning_combination=winning_combination,
game_state=game_state,
is_tsumo=True,
player_position=0,
)
dora_count = 0
is_tsumo = True
# Calculate score.
score_result = score_calculator.calculate(
hand=hand,
winning_tile=winning_tile,
winning_combination=winning_combination,
yaku_results=yaku_results,
dora_count=dora_count,
game_state=game_state,
is_tsumo=is_tsumo,
player_position=0,
)
print(f"Han: {score_result.han}")
print(f"Fu: {score_result.fu}")
print(f"Base points: {score_result.base_points}")
print(f"Total points: {score_result.total_points}")
print(f"Yakuman: {score_result.is_yakuman}")
print(f"Tsumo: {score_result.is_tsumo}")
Game State Management
from pyriichi import GameState, Wind
# Create a game state with the default standard competitive rules.
game_state = GameState(num_players=4)
# Set the round.
game_state.set_round(Wind.EAST, 1) # East 1.
game_state.set_dealer(0) # Player 0 is dealer.
# Query state.
print(f"Current round: {game_state.round_wind} {game_state.round_number}")
print(f"Dealer: Player {game_state.dealer}")
print(f"Honba: {game_state.honba}")
print(f"Riichi sticks: {game_state.riichi_sticks}")
# Update score.
game_state.update_score(0, 1000) # Player 0 gains 1000 points.
print(f"Player scores: {game_state.scores}")
# Advance to the next round.
game_state.next_round()
Ruleset Configuration
PyRiichi supports standard competitive rules and custom ruleset configuration.
from pyriichi import GameState, RulesetConfig
from pyriichi.rules_config import RenhouPolicy
# 1. Use the default standard competitive rules.
game_state = GameState(num_players=4)
# game_state.ruleset is already RulesetConfig.standard().
# 2. Custom ruleset configuration.
custom_ruleset = RulesetConfig(
renhou_policy=RenhouPolicy.YAKUMAN, # Renhou is yakuman.
pinfu_require_ryanmen=False, # Pinfu does not require ryanmen.
chanta_enabled=True,
chanta_closed_han=2, # Chanta closed: 2 han.
chanta_open_han=1, # Chanta open: 1 han.
junchan_closed_han=3, # Junchan closed: 3 han.
junchan_open_han=2, # Junchan open: 2 han.
suuankou_tanki_double=False, # Suuankou Tanki is single yakuman.
kokushi_musou_juusanmen_double=False, # Kokushi Musou Juusanmen is single yakuman.
pure_chuuren_poutou_double=False, # Pure Chuuren Poutou is single yakuman.
)
game_state_custom = GameState(num_players=4, ruleset=custom_ruleset)
# Ruleset configuration affects yaku detection.
print(f"Renhou policy: {game_state.ruleset.renhou_policy.value}") # Standard: "two_han".
print(f"Pinfu requires ryanmen: {game_state.ruleset.pinfu_require_ryanmen}") # Standard: True.
Standard competitive rule characteristics:
- Renhou is 2 han, not yakuman.
- Pinfu must be ryanmen.
- Chanta: closed 2 han, open 1 han.
- Junchan: closed 3 han, open 2 han.
- Suuankou Tanki is double yakuman, 26 han.
- Kokushi Musou Juusanmen is double yakuman, 26 han.
- Four Returns is not part of the canonical ruleset.
Complete Game Example
from pyriichi import RuleEngine, GamePhase
from pyriichi.player import SimplePlayer
# Initialize the game.
engine = RuleEngine(num_players=4)
players = [SimplePlayer(f"Player {i}") for i in range(4)]
engine.start_game()
engine.start_round()
engine.deal()
# Main game loop. The engine draws automatically after a discard that is not
# interrupted, then places the next player in waiting_for_actions.
max_steps = 100
for _ in range(max_steps):
if engine.get_phase() != GamePhase.PLAYING:
break
if not engine.waiting_for_actions:
break
player_index = next(iter(engine.waiting_for_actions))
actions = engine.get_available_actions(player_index)
action, tile = players[player_index].decide_action(
engine.game_state,
player_index,
engine.get_hand(player_index),
actions,
)
result = engine.execute_action(player_index, action, tile)
if result.winners:
for winner, win_result in result.win_results.items():
print(
f"Player {winner} wins: "
f"{win_result.han} han, {win_result.fu} fu, "
f"{win_result.points} points"
)
break
if result.ryuukyoku:
print(f"Ryuukyoku: {result.ryuukyoku.ryuukyoku_type.en}")
break
print("Game ended")
Core API
Main Classes
RuleEngine- Game rule engine that manages the full game flow.Hand- Hand manager that handles hand operations and detection.TileSet- Tile set manager that handles dealing and shuffling.GameState- Game state manager for rounds, scores, and related state.YakuChecker- Yaku detector that checks all yaku.ScoreCalculator- Score calculator for fu, han, and points.RulesetConfig- Ruleset configuration class for standard competitive rules and custom rules.BasePlayer- Base class for AI players.
AI Players
PyRiichi includes several built-in AI strategies for testing or play.
RandomPlayer: completely random actions, useful for fuzz testing.SimplePlayer: simple heuristic strategy: prioritize win, then riichi, then discard honors.DefensivePlayer: defensive AI that prioritizes genbutsu when another player has declared riichi.
from pyriichi.player import SimplePlayer, DefensivePlayer
# Create players with different strategies.
p1 = SimplePlayer("Attacker")
p2 = DefensivePlayer("Defender")
Main Enums
GameAction- Game action types, such as draw, discard, chi, and pon.GamePhase- Game phases, such as initialization, dealing, playing, and ended.Suit- Suits: manzu, pinzu, souzu, honors.Wind- Winds: east, south, west, north.MeldType- Meld types: chi, pon, kan, closed kan.
Utility Functions
parse_tiles(tile_string)- Parse tiles from a string.format_tiles(tiles)- Format a tile list as a string.is_winning_hand(tiles, winning_tile)- Quickly check whether the tiles form a winning hand.
Complete Feature List
Implemented Features
- ✅ Tile set system, standard 136 tiles.
- ✅ Basic hand operations: draw and discard.
- ✅ Game flow control: dealing and turn management.
- ✅ Game state management: Round Number, winds, and scores.
- ✅ Winning-hand detection algorithm for standard and special shapes.
- ✅ Tenpai detection and discard-to-tenpai hint helpers.
- ✅ Chi, pon, and kan operations.
- ✅ Yaku detection system, including all standard yaku and yakuman.
- ✅ Score calculation system: fu, han, and points.
- ✅ Ryuukyoku handling, including Kyuushu Kyuuhai.
- ✅ Ruleset configuration system for standard competitive rules and custom rules.
- ✅ Basic API structure.
Notes
get_winning_combinations()returnsList[List[Combination]]and can be used directly:combinations = hand.get_winning_combinations(winning_tile) if combinations: winning_combination = combinations[0]
Documentation
- API quick reference - Quick API reference guide.
- Requirements - Project-level requirements and links to rule requirements.
- Rule requirements - Split riichi rule requirements and implementation audit.
- Glossary - Canonical code, Japanese, English, and Traditional Chinese terms.
- Development plan - Development plan and timeline.
Examples
See the examples/ directory for more complete examples:
basic_usage.py- Basic usage example.demo_ui.py- Terminal game UI with language, difficulty, ruleset configuration, action popups, and tenpai hints.
Run the terminal UI from a source checkout:
python examples/demo_ui.py
System Requirements
- Python 3.8 to 3.12, officially supported versions.
- Core features have no external dependencies.
Development and Testing
- Install project dependencies in a virtual environment.
- Install the full development toolchain:
pip install ".[dev]".- Includes pytest>=7.0.0, pytest-cov>=4.0.0, black>=23.0.0, flake8>=6.0.0, and mypy>=1.0.0.
- Install only test tools:
pip install ".[test]".- Includes pytest>=7.0.0 and pytest-cov>=4.0.0.
- Run tests with the virtual environment's Python:
.venv/bin/python -m pytest
Contributing
Issues and pull requests are welcome. Use the dev and test extras to help maintain test quality.
License
This project is licensed under the MIT License. See LICENSE for details.
Related Resources
Note: This project is under active development, and some features may not be fully implemented yet. See the development plan for details.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pyriichi-0.1.1.tar.gz.
File metadata
- Download URL: pyriichi-0.1.1.tar.gz
- Upload date:
- Size: 99.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.8
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
231244a9ae109a03e74bce8bb75d41bf31fb945519544b116aa916e054777fce
|
|
| MD5 |
4fbf906c00b0f27576b5ca3c97a31031
|
|
| BLAKE2b-256 |
53e5f69533cb6f3a84358d18cd56164f43f6812d2feda5c2c6bfd0041cf6a09f
|
File details
Details for the file pyriichi-0.1.1-py3-none-any.whl.
File metadata
- Download URL: pyriichi-0.1.1-py3-none-any.whl
- Upload date:
- Size: 63.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.8
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9db3a2cb36faad143ccade8a95e9331c1b1889fc24301a6326531bcadd7d8f6f
|
|
| MD5 |
663c8d1cee4d2488fd6ff8963b152bf6
|
|
| BLAKE2b-256 |
e35a9291277e9259fe3275ea9938a591b41f43b918824051e0f48def8f951cc1
|