├── mahjong_env ├── __init__.py ├── consts.py ├── player_data.py ├── base_bot.py ├── utils.py └── core.py ├── .gitignore ├── mahjong_cpp ├── mahjong.h ├── setup.py ├── mahjong_wrapper.cpp └── mahjong.cpp ├── __main__.py ├── test_cpp_bot.py ├── LICENSE ├── test_bot.py ├── test_mahjong.py ├── mahjong_data ├── preprocess.py └── processed_data_sample.json └── README.md /mahjong_env/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | .idea/ 3 | -------------------------------------------------------------------------------- /mahjong_cpp/mahjong.h: -------------------------------------------------------------------------------- 1 | #ifndef MAHJONG_H 2 | #define MAHJONG_H 3 | 4 | #include 5 | using namespace std; 6 | 7 | std::string action(std::string &requests); 8 | 9 | #endif //MAHJONG_H 10 | -------------------------------------------------------------------------------- /mahjong_cpp/setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, Extension 2 | 3 | module = Extension('MahjongBot', sources=[ 4 | 'mahjong_wrapper.cpp', # wrapper cpp 5 | 'mahjong.cpp' # source code 6 | ], language='c++', extra_compile_args=["-std=c++14"]) 7 | 8 | # run `python setup.py install` for installing custom module 9 | setup(name='MahjongBot', ext_modules=[module]) 10 | -------------------------------------------------------------------------------- /__main__.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from mahjong_env.base_bot import RandomMahjongBot 4 | from mahjong_env.utils import request2obs, act2response 5 | 6 | 7 | def main(): 8 | agent = RandomMahjongBot() 9 | request = json.loads(input()) 10 | obs = request2obs(request) 11 | act = agent.action(obs) 12 | response = act2response(act) 13 | print(json.dumps(response)) 14 | 15 | 16 | if __name__ == '__main__': 17 | main() 18 | -------------------------------------------------------------------------------- /test_cpp_bot.py: -------------------------------------------------------------------------------- 1 | from mahjong_env.core import Mahjong 2 | from mahjong_env.utils import response2act 3 | 4 | import MahjongBot 5 | 6 | 7 | def random_test(): 8 | env = Mahjong() 9 | res = env.init() 10 | print(res) 11 | 12 | while not env.done: 13 | print(env.observation()) 14 | print() 15 | obs = [env.request_simple(i) for i in range(4)] 16 | actions = [response2act(MahjongBot.action(ob), i) 17 | for i, ob in enumerate(obs)] 18 | res = env.step(actions) 19 | print(res) 20 | if env.fans is not None: 21 | print('Fans:', env.fans) 22 | print('Rewards:', env.rewards) 23 | print() 24 | 25 | 26 | def main(): 27 | random_test() 28 | 29 | 30 | if __name__ == '__main__': 31 | main() 32 | -------------------------------------------------------------------------------- /mahjong_env/consts.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | NUM_PLAYERS = 4 4 | NUM_HAND_TILES = 13 5 | 6 | 7 | # 风圈 8 | class RoundWind(Enum): 9 | EAST = 0 10 | SOUTH = 1 11 | WEST = 2 12 | NORTH = 3 13 | 14 | 15 | class TileSuit(Enum): 16 | CHARACTERS = 1 # 万 17 | BAMBOO = 2 # 条 18 | DOTS = 3 # 饼 19 | HONORS = 4 # 字 20 | 21 | 22 | class ActionType(Enum): 23 | PASS = 0 # 无操作 24 | DRAW = 1 # 摸牌 25 | PLAY = 2 # 打牌 26 | CHOW = 3 # 吃牌 27 | PUNG = 4 # 碰牌 28 | KONG = 5 # 杠牌 29 | MELD_KONG = 6 # 补杠 30 | HU = 7 # 和牌 31 | 32 | 33 | class ClaimingType: 34 | CHOW = "CHI" 35 | PUNG = "PENG" 36 | KONG = "GANG" 37 | 38 | 39 | TILE_SET = ( 40 | 'W1', 'W2', 'W3', 'W4', 'W5', 'W6', 'W7', 'W8', 'W9', # 万 41 | 'T1', 'T2', 'T3', 'T4', 'T5', 'T6', 'T7', 'T8', 'T9', # 条 42 | 'B1', 'B2', 'B3', 'B4', 'B5', 'B6', 'B7', 'B8', 'B9', # 筒 43 | 'F1', 'F2', 'F3', 'F4', 'J1', 'J2', 'J3', # 字牌 44 | ) 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 鸵小鸟 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /mahjong_cpp/mahjong_wrapper.cpp: -------------------------------------------------------------------------------- 1 | /* c++ wrapper for python */ 2 | // for more information, refers to https://docs.python.org/3/extending/extending.html 3 | #include 4 | #include 5 | #include "mahjong.h" // header that defines the `action` function 6 | 7 | using namespace std; 8 | 9 | static PyObject * 10 | MahjongBot_action(PyObject *self, PyObject *args) { 11 | std::string request = PyUnicode_AsUTF8(PyTuple_GetItem(args, 0)); 12 | std::string response = action(request); // call the function 13 | return PyUnicode_FromString(response.c_str()); 14 | } 15 | 16 | static PyMethodDef mahjongMethods[] = { 17 | // defines a method called `action` 18 | {"action", MahjongBot_action, METH_VARARGS, "make an action"}, 19 | {NULL, NULL, 0, NULL}, 20 | }; 21 | static PyModuleDef mahjongbotModule = { 22 | // defines a module called `MahjongBot` with the above methods 23 | PyModuleDef_HEAD_INIT, 24 | "MahjongBot", 25 | "A C++ Mahjong Bot", 26 | -1, 27 | mahjongMethods, 28 | }; 29 | 30 | PyMODINIT_FUNC 31 | PyInit_MahjongBot(void) { 32 | return PyModule_Create(&mahjongbotModule); 33 | } 34 | 35 | -------------------------------------------------------------------------------- /test_bot.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from mahjong_env.core import Mahjong 4 | from mahjong_env.consts import ActionType 5 | from mahjong_env.player_data import Action 6 | from mahjong_env.base_bot import RandomMahjongBot 7 | 8 | 9 | def play_round(rd, env): 10 | res = env.init(rd['tiles'], rd['round_wind'], rd['first_player']) 11 | print(res) 12 | for actions in zip(*rd['actions']): 13 | for player in env.players_data: 14 | print('Tile:', ' '.join(sorted(player.tiles)), 15 | ', Claiming:', '; '.join([str(c) for c in player.claimings]), 16 | ', Remain tiles:', len(player.tile_wall)) 17 | print() 18 | act_list = [Action(i, ActionType(act[0]), None if len(act) == 1 else act[1]) 19 | for i, act in enumerate(actions)] 20 | res = env.step(act_list) 21 | print(res) 22 | print('Done:', env.done) 23 | if env.fans is not None: 24 | print('Fans:', env.fans) 25 | print('Rewards:', env.rewards) 26 | print() 27 | 28 | 29 | def random_test(): 30 | env = Mahjong() 31 | res = env.init() 32 | print(res) 33 | agent = RandomMahjongBot() 34 | 35 | while not env.done: 36 | print(env.observation()) 37 | print() 38 | obs = [env.player_obs(i) for i in range(4)] 39 | actions = [agent.action(ob) for ob in obs] 40 | res = env.step(actions) 41 | print(res) 42 | if env.fans is not None: 43 | print('Fans:', env.fans) 44 | print('Rewards:', env.rewards) 45 | print() 46 | 47 | 48 | def play_test(): 49 | rounds = [] 50 | with open('mahjong_data/processed_data_sample.json') as f: 51 | for line in f: 52 | rounds.append(json.loads(line)) 53 | print(len(rounds)) 54 | 55 | env = Mahjong() 56 | for rd in rounds: 57 | play_round(rd, env) 58 | 59 | 60 | def main(): 61 | random_test() 62 | play_test() 63 | 64 | 65 | if __name__ == '__main__': 66 | main() 67 | -------------------------------------------------------------------------------- /mahjong_cpp/mahjong.cpp: -------------------------------------------------------------------------------- 1 | // Chinese Standard Mahjong sample bot 2 | // random strategy 3 | // modified from http://www.botzone.org/games#Chinese-Standard-Mahjong 4 | 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include 10 | 11 | #include "mahjong.h" 12 | 13 | using namespace std; 14 | 15 | string action(string &requests) { 16 | istringstream iss(requests); 17 | vector request, response; 18 | vector hand; 19 | 20 | int turnID; 21 | string stmp; 22 | iss >> turnID; 23 | turnID--; 24 | getline(iss, stmp); 25 | for(int i = 0; i < turnID; i++) { 26 | getline(iss, stmp); 27 | request.push_back(stmp); 28 | getline(iss, stmp); 29 | response.push_back(stmp); 30 | } 31 | getline(iss, stmp); 32 | request.push_back(stmp); 33 | 34 | if(turnID < 2) { 35 | response.push_back("PASS"); 36 | } else { 37 | int itmp, myPlayerID, quan; 38 | ostringstream sout; 39 | istringstream sin; 40 | sin.str(request[0]); 41 | sin >> itmp >> myPlayerID >> quan; 42 | sin.clear(); 43 | sin.str(request[1]); 44 | for(int j = 0; j < 5; j++) sin >> itmp; 45 | for(int j = 0; j < 13; j++) { 46 | sin >> stmp; 47 | hand.push_back(stmp); 48 | } 49 | for(int i = 2; i < turnID; i++) { 50 | sin.clear(); 51 | sin.str(request[i]); 52 | sin >> itmp; 53 | if(itmp == 2) { 54 | sin >> stmp; 55 | hand.push_back(stmp); 56 | sin.clear(); 57 | sin.str(response[i]); 58 | sin >> stmp >> stmp; 59 | hand.erase(find(hand.begin(), hand.end(), stmp)); 60 | } 61 | } 62 | sin.clear(); 63 | sin.str(request[turnID]); 64 | sin >> itmp; 65 | if(itmp == 2) { 66 | random_shuffle(hand.begin(), hand.end()); 67 | sout << "PLAY " << *hand.rbegin(); 68 | hand.pop_back(); 69 | } else { 70 | sout << "PASS"; 71 | } 72 | response.push_back(sout.str()); 73 | } 74 | return response[turnID]; 75 | } -------------------------------------------------------------------------------- /test_mahjong.py: -------------------------------------------------------------------------------- 1 | from mahjong_env import consts 2 | from mahjong_env.core import Mahjong 3 | from mahjong_env.player_data import Action 4 | 5 | 6 | def init() -> Mahjong: 7 | env = Mahjong(random_seed=42) 8 | res = env.init() 9 | print(res) 10 | return env 11 | 12 | 13 | def step(env: Mahjong, actions: list): 14 | print(env.observation()) 15 | print() 16 | request = env.step(actions) 17 | print(request) 18 | 19 | 20 | def test_false_hu(): 21 | env = init() 22 | actions = [Action(0, consts.ActionType.HU, None), Action(1, consts.ActionType.PASS, None), 23 | Action(2, consts.ActionType.PASS, None), Action(3, consts.ActionType.PASS, None)] 24 | step(env, actions) 25 | print() 26 | print(f"done: {env.done}") 27 | print(f"reward: {env.rewards}") 28 | assert env.done, True 29 | assert env.rewards, {"0": -30, "1": 10, "2": 10, "3": 10} 30 | 31 | 32 | def test_true_hu(): 33 | env = init() 34 | # 3 play F1 35 | actions = [Action(0, consts.ActionType.PASS, None), Action(1, consts.ActionType.PASS, None), 36 | Action(2, consts.ActionType.PASS, None), Action(3, consts.ActionType.PLAY, "F1")] 37 | step(env, actions) 38 | 39 | # 1 pung and play F2 40 | actions = [Action(0, consts.ActionType.PASS, None), Action(1, consts.ActionType.PUNG, "F2"), 41 | Action(2, consts.ActionType.PASS, None), Action(3, consts.ActionType.PASS, None)] 42 | step(env, actions) 43 | 44 | # all pass 45 | actions = [Action(0, consts.ActionType.PASS, None), Action(1, consts.ActionType.PASS, None), 46 | Action(2, consts.ActionType.PASS, None), Action(3, consts.ActionType.PASS, None)] 47 | step(env, actions) 48 | 49 | # 2 play W4 50 | actions = [Action(0, consts.ActionType.PASS, None), Action(1, consts.ActionType.PASS, None), 51 | Action(2, consts.ActionType.PLAY, "W4"), Action(3, consts.ActionType.PASS, None)] 52 | step(env, actions) 53 | 54 | # 3 chow W4 55 | actions = [Action(0, consts.ActionType.PASS, None), Action(1, consts.ActionType.PASS, None), 56 | Action(2, consts.ActionType.PASS, None), Action(3, consts.ActionType.CHOW, "W3 W3")] 57 | step(env, actions) 58 | 59 | # all pass 60 | actions = [Action(0, consts.ActionType.PASS, None), Action(1, consts.ActionType.PASS, None), 61 | Action(2, consts.ActionType.PASS, None), Action(3, consts.ActionType.PASS, None)] 62 | step(env, actions) 63 | 64 | # cheat, let 0 hu 😂 65 | env.players_data[0].tiles = ["W1", "W1", "W1", "W2", "W2", "W2", "W3", "W3", "W3", "W4", "W4", "W4", "W5"] 66 | env.last_tile = "W5" 67 | actions = [Action(0, consts.ActionType.HU, None), Action(1, consts.ActionType.PASS, None), 68 | Action(2, consts.ActionType.PASS, None), Action(3, consts.ActionType.PASS, None)] 69 | step(env, actions) 70 | 71 | print() 72 | print(f"done: {env.done}") 73 | print(f"reward: {env.rewards}") 74 | assert env.done, True 75 | assert env.rewards, {"0": 438, "1": -146, "2": -146, "3": -146} 76 | 77 | 78 | if __name__ == '__main__': 79 | test_false_hu() 80 | test_true_hu() 81 | -------------------------------------------------------------------------------- /mahjong_env/player_data.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from . import consts 4 | 5 | 6 | class Claiming: 7 | def __init__(self, claiming_type: int, tile: str, data: int): 8 | self.claiming_type = claiming_type 9 | self.tile = tile 10 | self.data = data 11 | 12 | def __repr__(self): 13 | return f'{self.claiming_type} {self.tile} {self.data}' 14 | 15 | 16 | class Action: 17 | def __init__(self, player: int, action: consts.ActionType, tile): 18 | self.player = player 19 | self.act_type = action 20 | self.tile = tile 21 | 22 | def __repr__(self): 23 | s = f'{self.player} {self.act_type.name}' 24 | if self.tile is not None: 25 | s += f' {self.tile}' 26 | return s 27 | 28 | 29 | class PlayerData: 30 | def __init__(self, index: int, tile_wall: List[str]): 31 | self.index = index 32 | self.tile_wall = tile_wall 33 | self.tiles = [] # type: List[str] 34 | self.claimings = [] # type: List[Claiming] 35 | self.response_hist = [] # type: List[Action] 36 | 37 | @property 38 | def claimings_and_tiles(self): 39 | claimings = [] 40 | for claiming in self.claimings: 41 | if claiming.claiming_type == consts.ClaimingType.CHOW: 42 | data = claiming.data 43 | else: 44 | data = (claiming.data - self.index + consts.NUM_PLAYERS) % consts.NUM_PLAYERS 45 | claimings.append((claiming.claiming_type, claiming.tile, data)) 46 | return tuple(claimings), tuple(self.tiles) 47 | 48 | def get_claimings(self, filter=True): 49 | def filter_kong(c): 50 | if c.claiming_type == consts.ClaimingType.KONG and c.tile == 0: 51 | return Claiming(c.claiming_type, '', 0) 52 | return c 53 | 54 | return [filter_kong(c) for c in self.claimings] if filter else self.claimings 55 | 56 | def play(self, tile: str) -> bool: 57 | if tile not in self.tiles: 58 | return False 59 | self.tiles.remove(tile) 60 | return True 61 | 62 | def pung(self, tile: str, offer_player: int) -> bool: 63 | if self.tiles.count(tile) < 2: 64 | return False 65 | for _ in range(2): 66 | self.tiles.remove(tile) 67 | self.claimings.append(Claiming(consts.ClaimingType.PUNG, tile, offer_player)) 68 | return True 69 | 70 | def kong(self, tile: str, offer_player: int) -> bool: 71 | n_kong = 4 if offer_player == self.index else 3 72 | if self.tiles.count(tile) < n_kong: 73 | return False 74 | for _ in range(n_kong): 75 | self.tiles.remove(tile) 76 | self.claimings.append(Claiming(consts.ClaimingType.KONG, tile, offer_player)) 77 | return True 78 | 79 | def meld_kong(self, tile: str) -> bool: 80 | claiming_index = -1 81 | for i in range(len(self.claimings)): 82 | claiming = self.claimings[i] 83 | if claiming.claiming_type == consts.ClaimingType.PUNG and claiming.tile == tile: 84 | claiming_index = i 85 | break 86 | if claiming_index == -1: 87 | return False 88 | if tile not in self.tiles: 89 | return False 90 | self.tiles.remove(tile) 91 | self.claimings[claiming_index].claiming_type = consts.ClaimingType.KONG 92 | return True 93 | 94 | def chow(self, tiles: List[str], data: int) -> bool: 95 | for tile in tiles: 96 | if tile not in self.tiles: 97 | return False 98 | for tile in tiles: 99 | self.tiles.remove(tile) 100 | self.claimings.append(Claiming(consts.ClaimingType.CHOW, tiles[1], data)) 101 | return True 102 | -------------------------------------------------------------------------------- /mahjong_data/preprocess.py: -------------------------------------------------------------------------------- 1 | import json 2 | import sys 3 | from zipfile import ZipFile, Path 4 | 5 | from tqdm import tqdm 6 | 7 | ROUND_WIND = {'东': 0, '南': 1, '西': 2, '北': 3} 8 | INIT_TILES_NUM = 13 9 | ACTION = {'过': 0, '摸牌': 1, '补花后摸牌': 1, '杠后摸牌': 1, '打牌': 2, 10 | '吃': 3, '碰': 4, '明杠': 5, '暗杠': 5, '补杠': 6, '和牌': 7, } 11 | NO_VALID_ACTION = ['补花'] 12 | NUM_PLAYERS = 4 13 | 14 | 15 | def add_DRAW(res, index, tile): 16 | for i in range(NUM_PLAYERS): 17 | res['actions'][i].append([ACTION['过']]) 18 | res['tiles'][index].append(tile) 19 | 20 | 21 | def add_PASS(res, index): 22 | for i in range(NUM_PLAYERS): 23 | if i == index: 24 | continue 25 | res['actions'][i].append([ACTION['过']]) 26 | 27 | 28 | def add_PLAY(res, index, tile): 29 | res['actions'][index].append([ACTION['打牌'], tile]) 30 | add_PASS(res, index) 31 | 32 | 33 | def add_CHOW(res, index, tile, new_tile): 34 | res['actions'][index].append([ACTION['吃'], f'{tile} {new_tile}']) 35 | add_PASS(res, index) 36 | 37 | 38 | def add_PENG(res, index, new_tile): 39 | res['actions'][index].append([ACTION['碰'], new_tile]) 40 | add_PASS(res, index) 41 | 42 | 43 | def add_GANG(res, index, tile): 44 | act = [ACTION['明杠']] 45 | if tile is not None: 46 | act.append(tile) 47 | res['actions'][index].append(act) 48 | add_PASS(res, index) 49 | 50 | 51 | def add_BUGANG(res, index): 52 | res['actions'][index].append([ACTION['补杠']]) 53 | add_PASS(res, index) 54 | 55 | 56 | def add_HU(res, index): 57 | res['actions'][index].append([ACTION['和牌']]) 58 | add_PASS(res, index) 59 | 60 | 61 | def preprocess_file(lines): 62 | res = { 63 | 'round_wind': 0, 64 | 'first_player': 0, 65 | 'tiles': [[] for _ in range(NUM_PLAYERS)], 66 | 'actions': [[] for _ in range(NUM_PLAYERS)] 67 | } 68 | length = len(lines) 69 | i = 1 70 | while i < length: 71 | line = lines[i] 72 | line = line.strip().split() 73 | if i == 1: 74 | res['round_wind'] = ROUND_WIND[line[0]] # 风圈 75 | elif 1 < i < 6: 76 | index = int(line[0]) 77 | init_tiles = eval(line[1]) 78 | if len(init_tiles) > INIT_TILES_NUM: 79 | res['first_player'] = index 80 | res['tiles'][index] = init_tiles 81 | else: 82 | index = int(line[0]) 83 | if line[1] in NO_VALID_ACTION: 84 | i += 1 85 | continue 86 | op = ACTION[line[1]] 87 | if op == 1: # 摸牌 88 | tile = line[2][2:-2] 89 | if tile[0] == 'H': 90 | i += 1 91 | continue 92 | add_DRAW(res, index, tile) 93 | elif op == 2: # 打牌 94 | tile = line[2][2:-2] 95 | add_PLAY(res, index, tile) 96 | elif op == 3: # 吃 97 | tile = eval(line[2])[1] 98 | i += 1 99 | new_line = lines[i].split() 100 | new_tile = new_line[2][2:-2] 101 | add_CHOW(res, index, tile, new_tile) 102 | elif op == 4: # 碰 103 | i += 1 104 | new_line = lines[i].split() 105 | new_tile = new_line[2][2:-2] 106 | add_PENG(res, index, new_tile) 107 | elif op == 5: # 杠 108 | gang_tile = None if line[1] == '明杠' else line[2][2:-2] 109 | add_GANG(res, index, gang_tile) 110 | elif op == 6: # 补杠 111 | add_BUGANG(res, index) 112 | elif op == 7: 113 | add_HU(res, index) 114 | i += 1 115 | for tile in res['tiles']: 116 | tile.extend([''] * (34 - len(tile))) 117 | return res 118 | 119 | 120 | def main(): 121 | data_path = '' if len(sys.argv) == 1 else sys.argv[1] 122 | zip_root = 'output2017/' 123 | names = [ 124 | # ('LIU', 12140) # 流局, uncomment if you want 125 | ('MO', 132994), # 自摸 126 | ('PLAY', 385324) # 点炮 127 | ] 128 | 129 | # zipfile.Path is buggy until python 3.9.1 130 | # see https://bugs.python.org/issue40564 for details 131 | # Here we have to open the zip file several times 132 | with open('processed_data_sample.json', 'w') as fo: 133 | for name, n_game in names: 134 | print(f'Processing {name} directory ...') 135 | with ZipFile(data_path + 'mjdata.zip') as zipf: 136 | path = Path(zipf, zip_root + name + '/') 137 | for file_name in tqdm(path.iterdir(), total=n_game): 138 | with file_name.open() as f: 139 | lines = [line.decode('utf-8').strip() for line in f] 140 | res = preprocess_file(lines) 141 | json.dump(res, fo) 142 | fo.write('\n') 143 | print(f'{name} processing complete with {n_game} games!') 144 | 145 | print('Finished!') 146 | 147 | 148 | if __name__ == '__main__': 149 | main() 150 | -------------------------------------------------------------------------------- /mahjong_env/base_bot.py: -------------------------------------------------------------------------------- 1 | import random 2 | from collections import Counter 3 | 4 | from typing import List 5 | 6 | from .consts import ActionType, ClaimingType 7 | from .player_data import Action 8 | 9 | from MahjongGB import MahjongFanCalculator 10 | 11 | 12 | class BaseMahjongBot: 13 | @staticmethod 14 | def check_hu(obs) -> int: 15 | player = obs['player_id'] 16 | win_tile = obs['last_tile'] 17 | claimings = [] 18 | for claiming in obs['claimings']: 19 | if claiming.claiming_type == ClaimingType.CHOW: 20 | data = claiming.data 21 | else: 22 | data = (claiming.data - player + 4) % 4 23 | claimings.append((claiming.claiming_type, claiming.tile, data)) 24 | claimings = tuple(claimings) 25 | tiles = tuple(obs['tiles']) 26 | 27 | flower_count = 0 # 补花数 28 | is_self_draw = obs['last_operation'] == ActionType.DRAW # 自摸 29 | is_fourth_tile = obs['played_tiles'][win_tile] == 4 # 绝张 30 | is_kong = obs['last_operation'] == ActionType.MELD_KONG # 杠 31 | is_kong |= len(obs['request_hist']) >= 2 \ 32 | and obs['request_hist'][-2].act_type == ActionType.KONG \ 33 | and obs['request_hist'][-2].player == player 34 | is_last_tile = obs['tile_count'][(player + 1) % 4] == 0 # 牌墙最后一张 35 | player_wind = player # 门风 36 | round_wind = obs['round_wind'] # 圈风 37 | 38 | try: 39 | result = MahjongFanCalculator(claimings, tiles, win_tile, flower_count, is_self_draw, is_fourth_tile, 40 | is_kong, is_last_tile, player_wind, round_wind) 41 | return sum([res[0] for res in result]) 42 | except Exception as exception: 43 | if str(exception) == "ERROR_NOT_WIN": 44 | return -1 45 | raise 46 | 47 | @staticmethod 48 | def check_kong(obs) -> bool: 49 | player = obs['player_id'] 50 | if obs['tile_count'][player] == 0 or obs['tile_count'][(player + 1) % 4] == 0: 51 | return False 52 | if obs['tiles'].count(obs['last_tile']) == 3: 53 | return True 54 | return False 55 | 56 | @staticmethod 57 | def check_meld_kong(obs) -> bool: 58 | player = obs['player_id'] 59 | if obs['tile_count'][player] == 0 or obs['tile_count'][(player + 1) % 4] == 0: 60 | return False 61 | for claiming in obs['claimings']: 62 | if claiming.claiming_type == ClaimingType.PUNG and claiming.tile == obs['last_tile']: 63 | return True 64 | return False 65 | 66 | @staticmethod 67 | def check_pung(obs) -> bool: 68 | if obs['tiles'].count(obs['last_tile']) == 2: 69 | return True 70 | return False 71 | 72 | @staticmethod 73 | def check_chow(obs) -> List[str]: 74 | if (obs['last_player'] - obs['player_id']) % 4 != 3: 75 | return [] 76 | tile_t, tile_v = obs['last_tile'] 77 | tile_v = int(tile_v) 78 | if tile_t in 'FJ': 79 | return [] 80 | 81 | tiles = obs['tiles'] 82 | chow_list = [] 83 | if tile_v >= 3: 84 | if tiles.count(f'{tile_t}{tile_v - 1}') and tiles.count(f'{tile_t}{tile_v - 2}'): 85 | chow_list.append(f'{tile_t}{tile_v - 1}') 86 | if 2 <= tile_v <= 8: 87 | if tiles.count(f'{tile_t}{tile_v - 1}') and tiles.count(f'{tile_t}{tile_v + 1}'): 88 | chow_list.append(f'{tile_t}{tile_v}') 89 | if tile_v <= 7: 90 | if tiles.count(f'{tile_t}{tile_v + 1}') and tiles.count(f'{tile_t}{tile_v + 2}'): 91 | chow_list.append(f'{tile_t}{tile_v + 1}') 92 | return chow_list 93 | 94 | def action(self, obs: dict) -> Action: 95 | raise NotImplementedError 96 | 97 | 98 | class RandomMahjongBot(BaseMahjongBot): 99 | @staticmethod 100 | def choose_play(tiles): 101 | cnt = Counter(tiles) 102 | single = [c for c, n in cnt.items() if n == 1] 103 | if len(single) == 0: 104 | double = [c for c, n in cnt.items() if n == 2] 105 | return random.choice(double) 106 | winds = [c for c in single if c[0] in 'FJ'] 107 | if len(winds) != 0: 108 | return random.choice(winds) 109 | return random.choice(single) 110 | 111 | def action(self, obs: dict) -> Action: 112 | if len(obs) == 0: 113 | return Action(0, ActionType.PASS, None) 114 | player = obs['player_id'] 115 | last_player = obs['last_player'] 116 | pass_action = Action(player, ActionType.PASS, None) 117 | 118 | if obs['last_operation'] == ActionType.DRAW: 119 | if last_player != player: 120 | return pass_action 121 | else: 122 | fan = self.check_hu(obs) 123 | if fan >= 8: 124 | return Action(player, ActionType.HU, None) 125 | 126 | if self.check_kong(obs): 127 | return Action(player, ActionType.KONG, obs['last_tile']) 128 | if self.check_meld_kong(obs): 129 | return Action(player, ActionType.MELD_KONG, obs['last_tile']) 130 | play_tile = self.choose_play(obs['tiles'] + [obs['last_tile']]) 131 | return Action(player, ActionType.PLAY, play_tile) 132 | 133 | if obs['last_operation'] == ActionType.KONG: 134 | return pass_action 135 | if last_player == player: 136 | return pass_action 137 | 138 | fan = self.check_hu(obs) 139 | if fan >= 8: 140 | return Action(player, ActionType.HU, None) 141 | if obs['last_operation'] == ActionType.MELD_KONG: 142 | return pass_action 143 | if self.check_kong(obs): 144 | return Action(player, ActionType.KONG, None) 145 | if self.check_pung(obs): 146 | tiles = obs['tiles'].copy() 147 | tiles.remove(obs['last_tile']) 148 | tiles.remove(obs['last_tile']) 149 | play_tile = self.choose_play(tiles) 150 | return Action(player, ActionType.PUNG, play_tile) 151 | 152 | chow_list = self.check_chow(obs) 153 | if len(chow_list) != 0: 154 | chow_tile = random.choice(chow_list) 155 | chow_t, chow_v = chow_tile[0], int(chow_tile[1]) 156 | tiles = obs['tiles'].copy() 157 | for i in range(chow_v - 1, chow_v + 2): 158 | if i == int(obs['last_tile'][1]): 159 | continue 160 | else: 161 | tiles.remove(f'{chow_t}{i}') 162 | play_tile = self.choose_play(tiles) 163 | return Action(player, ActionType.CHOW, f'{chow_tile} {play_tile}') 164 | return pass_action 165 | -------------------------------------------------------------------------------- /mahjong_env/utils.py: -------------------------------------------------------------------------------- 1 | from .consts import ActionType, ClaimingType, TILE_SET 2 | from .player_data import Action, Claiming 3 | 4 | str2act_dict = { 5 | 'PASS': ActionType.PASS, 6 | 'DRAW': ActionType.DRAW, 7 | 'PLAY': ActionType.PLAY, 8 | 'CHI': ActionType.CHOW, 9 | 'PENG': ActionType.PUNG, 10 | 'GANG': ActionType.KONG, 11 | 'BUGANG': ActionType.MELD_KONG, 12 | 'HU': ActionType.HU 13 | } 14 | act2str_dict = { 15 | ActionType.PASS: 'PASS', 16 | ActionType.DRAW: 'DRAW', 17 | ActionType.PLAY: 'PLAY', 18 | ActionType.CHOW: 'CHI', 19 | ActionType.PUNG: 'PENG', 20 | ActionType.KONG: 'GANG', 21 | ActionType.MELD_KONG: 'BUGANG', 22 | ActionType.HU: 'HU' 23 | } 24 | 25 | 26 | def str2act(s: str) -> ActionType: 27 | return str2act_dict[s] 28 | 29 | 30 | def act2str(act: ActionType) -> str: 31 | return act2str_dict[act] 32 | 33 | 34 | def response2str(act: Action) -> str: 35 | s = act2str(act.act_type) 36 | if act.tile is not None: 37 | s += f' {act.tile}' 38 | return s 39 | 40 | 41 | def request2str(act: Action, player_id: int) -> str: 42 | if act.act_type == ActionType.DRAW: 43 | if act.player == player_id: 44 | return f'2 {act.tile}' 45 | else: 46 | return f'3 {act.player} DRAW' 47 | s = f'3 {act.player} {act2str(act.act_type)}' 48 | if act.tile is not None: 49 | s += f' {act.tile}' 50 | return s 51 | 52 | 53 | def request2obs(request: dict) -> dict: 54 | if len(request['requests']) <= 2: 55 | # pass first two rounds 56 | return {} 57 | 58 | obs = { 59 | 'player_id': None, 60 | 'tiles': [], 61 | 'tile_count': [21] * 4, 62 | 'claimings': [], 63 | 'all_claimings': [[] for _ in range(4)], 64 | 'played_tiles': {t: 0 for t in TILE_SET}, 65 | 'last_player': None, 66 | 'last_tile': None, 67 | 'last_operation': None, 68 | 'round_wind': None, 69 | 'request_hist': [], 70 | 'response_hist': [] 71 | } 72 | 73 | request_hist = request['requests'] 74 | general_info = request_hist[0].split() 75 | player_id = obs['player_id'] = int(general_info[1]) 76 | obs['round_wind'] = int(general_info[2]) 77 | obs['tiles'] = request_hist[1].split()[5:] 78 | 79 | for act in request_hist[2:]: 80 | act = act.split() 81 | msgtype = int(act[0]) 82 | if msgtype == 2: # self draw 83 | obs['tiles'].append(act[1]) 84 | obs['tile_count'][player_id] -= 1 85 | obs['request_hist'].append(Action(player_id, ActionType.DRAW, act[1])) 86 | obs['last_player'] = player_id 87 | obs['last_operation'] = ActionType.DRAW 88 | obs['last_tile'] = act[1] 89 | continue 90 | 91 | player = int(act[1]) 92 | is_self = player == player_id 93 | act_type = str2act(act[2]) 94 | last_player = obs['last_player'] 95 | last_op = obs['last_operation'] 96 | last_tile = obs['last_tile'] 97 | obs['last_player'] = player 98 | obs['last_operation'] = act_type 99 | 100 | if len(act) == 3: 101 | # kong, others draw 102 | obs['request_hist'].append(Action(player, act_type, None)) 103 | if act_type == ActionType.KONG: 104 | claim = Claiming(ClaimingType.KONG, last_tile or '', last_player) 105 | obs['all_claimings'][player].append(claim) 106 | 107 | is_conceal = last_op == ActionType.DRAW 108 | if not is_conceal: 109 | obs['played_tiles'][last_tile] = 4 110 | if is_self: 111 | for _ in range(4 if is_conceal else 3): 112 | obs['tiles'].remove(last_tile) 113 | else: 114 | obs['tile_count'][player] -= 1 115 | obs['last_tile'] = None 116 | continue 117 | 118 | # play, chow, pung, meld kong 119 | obs['request_hist'].append(Action(player, act_type, ' '.join(act[3:]))) 120 | play_tile = act[-1] 121 | obs['played_tiles'][play_tile] += 1 122 | obs['last_tile'] = play_tile 123 | if is_self: 124 | obs['tiles'].remove(play_tile) 125 | 126 | if act_type == ActionType.PLAY: 127 | # already removed! 128 | pass 129 | elif act_type == ActionType.MELD_KONG: 130 | for claim in obs['all_claimings'][player]: 131 | if claim.tile == play_tile: 132 | claim.claiming_type = ClaimingType.KONG 133 | break 134 | elif act_type == ActionType.CHOW: 135 | chow_tile = act[-2] 136 | chow_t, chow_v = chow_tile[0], int(chow_tile[1]) 137 | offer_card = int(last_tile[1]) - chow_v + 2 138 | claim = Claiming(ClaimingType.CHOW, chow_tile, offer_card) 139 | obs['all_claimings'][player].append(claim) 140 | for v in range(chow_v - 1, chow_v + 2): 141 | cur_tile = f'{chow_t}{v}' 142 | if cur_tile != last_tile: 143 | obs['played_tiles'][cur_tile] += 1 144 | if is_self: 145 | obs['tiles'].remove(cur_tile) 146 | elif act_type == ActionType.PUNG: 147 | claim = Claiming(ClaimingType.PUNG, last_tile, last_player) 148 | obs['all_claimings'][player].append(claim) 149 | obs['played_tiles'][last_tile] += 2 150 | if is_self: 151 | for _ in range(2): 152 | obs['tiles'].remove(last_tile) 153 | else: 154 | raise TypeError(f"Wrong action {' '.join(act)}!") 155 | 156 | for res in request['responses']: 157 | res = res.split() 158 | act_type = str2act(res[0]) 159 | tile = None if len(res) == 1 else ' '.join(res[1:]) 160 | obs['response_hist'].append(Action(player_id, act_type, tile)) 161 | 162 | obs['tiles'].sort() 163 | obs['claimings'] = obs['all_claimings'][player_id] 164 | if obs['last_operation'] == ActionType.DRAW and obs['last_player'] == player_id: 165 | # remove last draw (for calculating fan) 166 | obs['tiles'].remove(obs['last_tile']) 167 | return obs 168 | 169 | 170 | def act2response(act: Action) -> dict: 171 | output = act2str(act.act_type) 172 | if act.tile is not None: 173 | output += f' {act.tile}' 174 | return {'response': output} 175 | 176 | 177 | def response2act(response: str, player_id: int) -> Action: 178 | act = response.split() 179 | tile = None if len(act) == 1 else ' '.join(act[1:]) 180 | return Action(player_id, str2act(act[0]), tile) 181 | 182 | 183 | def json2simple(request: dict) -> str: 184 | req_hist = request['requests'] 185 | res_hist = request['responses'] 186 | simple = [str(len(req_hist))] 187 | for req_act, res_act in zip(req_hist, res_hist): 188 | simple.append(req_act) 189 | simple.append(res_act) 190 | simple.append(req_hist[-1]) 191 | return '\n'.join(simple) 192 | -------------------------------------------------------------------------------- /mahjong_data/processed_data_sample.json: -------------------------------------------------------------------------------- 1 | {"round_wind": 0, "first_player": 2, "tiles": [["B1", "B6", "W6", "T6", "T6", "W2", "B7", "B8", "W1", "T9", "F4", "J3", "B3", "T3", "W1", "T1", "B2", "W3", "F1", "B6", "", "", "", "", "", "", "", "", "", "", "", "", "", ""], ["F3", "W1", "B5", "T4", "W3", "W5", "W6", "W2", "T4", "B4", "T1", "B9", "W6", "B7", "J2", "J1", "T2", "B2", "T7", "J2", "T8", "", "", "", "", "", "", "", "", "", "", "", "", ""], ["T8", "T8", "W7", "T3", "W4", "B1", "J1", "T7", "F2", "B1", "B8", "W8", "W9", "F4", "T4", "F1", "T5", "W1", "B7", "B3", "T2", "", "", "", "", "", "", "", "", "", "", "", "", ""], ["T9", "T5", "J2", "T3", "T7", "B5", "J2", "W7", "J1", "B4", "B3", "F3", "T6", "W3", "F2", "T6", "W6", "W5", "T9", "W7", "T2", "", "", "", "", "", "", "", "", "", "", "", "", ""]], "actions": [[[0], [0], [0], [0], [2, "F4"], [0], [0], [0], [0], [0], [0], [0], [2, "J3"], [0], [0], [0], [0], [2, "T9"], [0], [0], [0], [0], [0], [0], [0], [2, "W6"], [0], [0], [0], [0], [0], [0], [3, "W2 W1"], [0], [0], [0], [0], [0], [0], [0], [2, "W3"], [0], [0], [0], [0], [0], [0], [0], [2, "F1"], [0], [0], [0], [0], [0], [0], [0], [2, "B6"], [0], [0], [0], [0], [0], [0], [7]], [[0], [0], [0], [0], [0], [0], [2, "F3"], [0], [0], [0], [0], [0], [0], [0], [2, "J2"], [0], [0], [0], [0], [2, "J1"], [0], [0], [0], [0], [0], [0], [0], [2, "W6"], [0], [0], [0], [0], [0], [0], [2, "B2"], [0], [0], [0], [0], [0], [0], [0], [2, "T7"], [0], [0], [0], [0], [0], [0], [0], [2, "J2"], [0], [0], [0], [0], [0], [0], [0], [2, "T8"], [0], [0], [0], [0], [0]], [[2, "F2"], [0], [0], [0], [0], [0], [0], [0], [2, "F4"], [0], [0], [0], [0], [0], [0], [0], [0], [0], [0], [0], [0], [2, "J1"], [0], [0], [0], [0], [0], [0], [0], [2, "F1"], [0], [0], [0], [0], [0], [0], [2, "W1"], [0], [0], [0], [0], [0], [0], [0], [2, "T8"], [0], [0], [0], [0], [0], [0], [0], [2, "B3"], [0], [0], [0], [0], [0], [0], [0], [2, "W4"], [0], [0], [0]], [[0], [0], [2, "J1"], [0], [0], [0], [0], [0], [0], [0], [2, "T3"], [0], [0], [0], [0], [4, "T9"], [0], [0], [0], [0], [0], [0], [0], [2, "T6"], [0], [0], [0], [0], [0], [0], [0], [2, "W3"], [0], [0], [0], [0], [0], [0], [2, "F2"], [0], [0], [0], [0], [0], [0], [0], [2, "T9"], [0], [0], [0], [0], [0], [0], [0], [2, "W7"], [0], [0], [0], [0], [0], [0], [0], [2, "T2"], [0]]]} 2 | {"round_wind": 0, "first_player": 3, "tiles": [["J2", "W1", "T9", "B2", "T7", "B9", "F4", "F1", "B8", "W8", "B8", "T2", "T6", "W2", "B4", "W2", "W1", "J2", "W6", "W3", "F4", "B1", "W3", "J1", "W6", "F1", "T5", "T1", "", "", "", "", "", ""], ["J3", "W2", "B4", "T1", "B3", "W7", "T5", "W1", "B9", "F1", "T2", "J3", "W8", "T5", "F2", "W5", "B5", "J1", "J1", "F4", "W4", "J3", "T6", "W8", "B9", "T8", "W5", "B1", "W5", "F3", "W4", "", "", ""], ["J1", "W9", "B6", "T3", "J2", "F3", "W3", "B3", "B8", "W2", "T7", "T4", "T4", "T9", "W4", "J3", "B9", "B4", "B6", "W9", "F3", "T7", "F2", "T9", "T8", "B5", "T3", "", "", "", "", "", "", ""], ["B3", "T7", "F2", "B7", "W6", "W5", "T1", "T2", "T8", "T4", "J2", "W4", "B6", "T5", "B4", "B2", "B5", "T4", "W7", "T6", "F2", "F4", "T3", "F1", "B7", "T1", "W6", "B5", "", "", "", "", "", ""]], "actions": [[[0], [0], [2, "F4"], [0], [0], [0], [0], [0], [0], [0], [2, "J2"], [0], [0], [0], [0], [0], [0], [0], [2, "F1"], [0], [0], [0], [0], [0], [0], [0], [2, "B9"], [0], [0], [0], [0], [0], [0], [0], [2, "J2"], [0], [0], [0], [0], [0], [0], [0], [2, "B2"], [0], [0], [0], [0], [4, "T2"], [0], [0], [0], [0], [0], [0], [2, "T9"], [0], [0], [0], [0], [0], [0], [0], [2, "F4"], [0], [0], [0], [0], [0], [0], [0], [2, "B1"], [0], [0], [0], [0], [0], [0], [0], [2, "B4"], [0], [0], [0], [0], [0], [0], [0], [2, "J1"], [0], [0], [0], [0], [0], [0], [2, "W8"], [0], [0], [0], [0], [0], [4, "T7"], [0], [0], [0], [0], [0], [0], [0], [2, "T6"], [0], [0], [0], [0], [0], [0], [0], [2, "T5"], [0], [0], [4, "F1"], [0], [0], [0], [0], [0], [0], [0], [2, "T1"], [0], [0], [7]], [[0], [0], [0], [0], [2, "F1"], [0], [0], [0], [0], [0], [0], [0], [2, "F2"], [0], [0], [0], [0], [0], [0], [0], [2, "B9"], [0], [0], [0], [0], [0], [0], [0], [2, "J3"], [0], [0], [0], [0], [0], [0], [0], [2, "J3"], [0], [0], [0], [0], [0], [0], [0], [2, "T1"], [0], [0], [0], [0], [2, "T2"], [0], [0], [0], [0], [0], [0], [2, "F4"], [0], [0], [0], [0], [0], [0], [0], [2, "J3"], [0], [0], [0], [0], [0], [0], [0], [2, "J1"], [0], [0], [0], [0], [0], [0], [0], [2, "J1"], [0], [0], [0], [0], [0], [0], [0], [2, "B9"], [0], [0], [0], [0], [0], [0], [2, "T8"], [0], [0], [0], [0], [0], [2, "W8"], [0], [0], [0], [0], [0], [0], [0], [2, "B1"], [0], [0], [0], [0], [0], [0], [0], [2, "W1"], [0], [0], [2, "F3"], [0], [0], [0], [0], [0], [0], [0], [2, "W2"], [0]], [[0], [0], [0], [0], [0], [0], [2, "F3"], [0], [0], [0], [0], [0], [0], [0], [2, "J2"], [0], [0], [0], [0], [0], [0], [0], [2, "J3"], [0], [0], [0], [0], [0], [0], [0], [2, "J1"], [0], [0], [0], [0], [0], [0], [0], [2, "B9"], [0], [0], [0], [0], [0], [0], [0], [2, "B8"], [0], [0], [0], [3, "T3 W9"], [0], [0], [0], [0], [0], [0], [0], [2, "W9"], [0], [0], [0], [0], [0], [0], [0], [2, "F3"], [0], [0], [0], [0], [0], [0], [0], [2, "T4"], [0], [0], [0], [0], [0], [0], [0], [2, "F2"], [0], [0], [0], [0], [0], [0], [0], [2, "T9"], [0], [0], [0], [0], [0], [3, "T8 T7"], [0], [0], [0], [0], [0], [0], [2, "T8"], [0], [0], [0], [0], [0], [0], [0], [2, "B5"], [0], [0], [0], [0], [0], [0], [0], [0], [0], [0], [2, "T3"], [0], [0], [0], [0], [0], [0], [0]], [[2, "F2"], [0], [0], [0], [0], [0], [0], [0], [2, "J2"], [0], [0], [0], [0], [0], [0], [0], [2, "B7"], [0], [0], [0], [0], [0], [0], [0], [2, "B6"], [0], [0], [0], [0], [0], [0], [0], [2, "T4"], [0], [0], [0], [0], [0], [0], [0], [2, "W7"], [0], [0], [0], [0], [0], [0], [0], [0], [0], [0], [0], [2, "B5"], [0], [0], [0], [0], [0], [0], [0], [2, "F2"], [0], [0], [0], [0], [0], [0], [0], [2, "F4"], [0], [0], [0], [0], [0], [0], [0], [2, "W4"], [0], [0], [0], [0], [0], [0], [0], [2, "F1"], [0], [0], [0], [0], [0], [0], [3, "T8 W5"], [0], [0], [0], [0], [0], [0], [2, "W6"], [0], [0], [0], [0], [0], [0], [2, "T1"], [0], [0], [0], [0], [0], [0], [0], [2, "W6"], [0], [0], [0], [0], [0], [0], [0], [0], [0], [0], [2, "B7"], [0], [0], [0], [0], [0]]]} 3 | {"round_wind": 0, "first_player": 0, "tiles": [["T8", "W7", "T6", "B3", "F4", "T6", "T7", "F2", "T4", "W9", "T3", "B3", "B6", "T4", "B5", "B8", "J3", "T5", "B1", "W3", "B9", "B1", "T4", "W5", "", "", "", "", "", "", "", "", "", ""], ["W3", "T9", "W8", "B8", "W5", "B8", "F1", "W8", "T2", "W1", "W4", "B6", "W1", "W3", "J3", "B9", "T1", "T7", "T3", "F3", "T5", "B2", "", "", "", "", "", "", "", "", "", "", "", ""], ["T8", "B7", "T9", "B4", "T1", "J1", "W1", "T1", "W1", "T3", "W2", "T7", "J3", "F1", "T9", "T7", "T8", "J3", "W8", "F1", "W7", "B1", "F2", "J1", "", "", "", "", "", "", "", "", "", ""], ["F3", "W4", "W4", "W6", "F4", "B2", "W2", "B4", "T2", "B9", "B2", "B3", "T6", "B6", "T4", "B5", "W9", "T3", "B3", "B5", "T9", "B7", "W6", "", "", "", "", "", "", "", "", "", "", ""]], "actions": [[[2, "F4"], [0], [0], [0], [0], [0], [0], [2, "F2"], [0], [0], [0], [0], [0], [0], [0], [2, "B8"], [0], [0], [0], [0], [0], [0], [2, "J3"], [0], [0], [0], [0], [0], [0], [0], [0], [0], [0], [2, "W9"], [0], [0], [0], [0], [0], [0], [2, "B1"], [0], [0], [0], [0], [0], [0], [0], [0], [0], [2, "W7"], [0], [0], [0], [0], [0], [0], [0], [2, "B9"], [0], [0], [0], [0], [0], [0], [0], [2, "B1"], [0], [0], [0], [0], [0], [0], [0], [2, "W3"], [0], [0], [0], [0], [0], [3, "B6 T4"], [0], [0], [0], [0], [0], [0], [0], [2, "W5"], [0]], [[0], [0], [2, "T2"], [0], [0], [0], [0], [0], [0], [2, "B6"], [0], [0], [0], [0], [0], [0], [4, "T9"], [0], [0], [0], [0], [0], [0], [0], [2, "F1"], [0], [0], [4, "J3"], [0], [0], [0], [0], [0], [0], [0], [2, "B9"], [0], [0], [0], [0], [0], [0], [2, "T1"], [0], [0], [4, "T7"], [0], [0], [0], [0], [0], [0], [2, "T3"], [0], [0], [0], [0], [0], [0], [0], [2, "F3"], [0], [0], [0], [0], [0], [0], [0], [2, "T5"], [0], [0], [0], [0], [0], [0], [4, "W5"], [0], [0], [0], [0], [0], [0], [2, "W4"], [0], [0], [0], [0], [0], [0], [0]], [[0], [0], [0], [3, "T2 J3"], [0], [0], [0], [0], [0], [0], [0], [2, "F1"], [0], [0], [0], [0], [0], [0], [2, "J1"], [0], [0], [0], [0], [0], [0], [0], [2, "W1"], [0], [0], [2, "B4"], [0], [0], [0], [0], [0], [0], [0], [2, "B7"], [0], [0], [0], [0], [0], [0], [2, "W8"], [0], [3, "T8 W2"], [0], [0], [0], [0], [0], [0], [0], [2, "J3"], [0], [0], [0], [0], [0], [0], [0], [2, "W7"], [0], [0], [0], [0], [0], [0], [0], [2, "F1"], [0], [0], [0], [0], [0], [0], [2, "W1"], [0], [0], [0], [0], [0], [0], [2, "B1"], [0], [0], [0], [0], [0]], [[0], [0], [0], [0], [0], [2, "F3"], [0], [0], [0], [0], [0], [0], [0], [2, "F4"], [0], [0], [0], [0], [0], [0], [2, "B9"], [0], [0], [0], [0], [0], [0], [0], [0], [0], [0], [2, "W9"], [0], [0], [0], [0], [0], [0], [3, "B6 T6"], [0], [0], [0], [0], [0], [0], [0], [0], [0], [2, "W6"], [0], [0], [0], [0], [0], [0], [0], [2, "W4"], [0], [0], [0], [0], [0], [0], [0], [2, "B3"], [0], [0], [0], [0], [0], [0], [0], [2, "T9"], [0], [0], [0], [0], [0], [0], [2, "B7"], [0], [0], [0], [0], [0], [0], [2, "W2"], [0], [0], [7]]]} 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `mahjong_env`环境介绍 2 | 3 | ——by ccr 4 | 5 | 18th Jan. 2021 6 | 7 | 这是一个Botzone平台[Chinese Standard Mahjong](https://www.botzone.org.cn/game/Chinese-Standard-Mahjong)(国标麻将·复式)游戏的Python版本模拟器,提供与Botzone对应的接口以在本地运行Bot程序、进行强化学习等任务,并提供了简单的Python Bot和测试程序。 8 | 9 | 10 | 11 | ## 使用方法 12 | 13 | ### 本地模拟 14 | 15 | 以下代码创建了一个麻将模拟环境和一个随机策略Bot(由于策略相同,可以由一个Bot模拟所有四位玩家),并模拟一场随机牌局。详见`test_bot.py`。 16 | 17 | ```python 18 | from mahjong_env.core import Mahjong 19 | from mahjong_env.base_bot import RandomMahjongBot 20 | 21 | def main(): 22 | env = Mahjong() # 创建一个麻将模拟环境 23 | res = env.init() # 初始化,没有传参数即初始化一场随机牌局 24 | print(res) 25 | agent = RandomMahjongBot() # 创建一个随机Bot 26 | while not env.done: # 没有结束 27 | print(env.observation()) # 显示当前牌桌信息 28 | print() 29 | obs = [env.player_obs(i) for i in range(4)] # 得到每个玩家观测 30 | actions = [agent.action(ob) for ob in obs] # Bot做出决策 31 | res = env.step(actions) # 运行一步环境 32 | print(res) 33 | if env.fans is not None: # 和则打印番型 34 | print('Fans:', env.fans) 35 | print('Rewards:', env.rewards) 36 | print() 37 | 38 | if __name__ == '__main__': 39 | main() 40 | ``` 41 | 42 | 样例输出(部分): 43 | 44 | ``` 45 | Initilized to testing mode 46 | Round wind: 0, Round stage: 0 47 | 0 DRAW B7 48 | Tiles: B2 B3 B8 F3 F4 T1 T2 T2 T9 W2 W3 W3 W5; Claimings: ; Remain tiles: 20 49 | Tiles: B1 B1 B6 F1 F1 F4 T1 T1 T7 T7 T8 W1 W5; Claimings: ; Remain tiles: 21 50 | Tiles: B1 B7 B9 F2 J2 J3 T1 T2 T3 T5 T6 W1 W9; Claimings: ; Remain tiles: 21 51 | Tiles: B4 B6 B7 B8 F2 J1 J2 J3 T9 W2 W5 W6 W7; Claimings: ; Remain tiles: 21 52 | 53 | 0 PLAY F4 54 | Tiles: B2 B3 B7 B8 F3 T1 T2 T2 T9 W2 W3 W3 W5; Claimings: ; Remain tiles: 20 55 | Tiles: B1 B1 B6 F1 F1 F4 T1 T1 T7 T7 T8 W1 W5; Claimings: ; Remain tiles: 21 56 | Tiles: B1 B7 B9 F2 J2 J3 T1 T2 T3 T5 T6 W1 W9; Claimings: ; Remain tiles: 21 57 | Tiles: B4 B6 B7 B8 F2 J1 J2 J3 T9 W2 W5 W6 W7; Claimings: ; Remain tiles: 21 58 | 59 | ... 60 | 61 | 3 PLAY T7 62 | Tiles: T6; Claimings: CHI B7 1, GANG T2 2, GANG W2 3, GANG W3 1; Remain tiles: 1 63 | Tiles: F1 F1 F1 W4; Claimings: CHI T8 3, PENG T1 0, PENG W7 3; Remain tiles: 3 64 | Tiles: W1; Claimings: CHI T6 3, PENG T4 1, CHI B7 1, CHI B2 2; Remain tiles: 5 65 | Tiles: B2 B9 T9 T9; Claimings: CHI B8 3, GANG W9 2, PENG W6 0; Remain tiles: 3 66 | 67 | 0 DRAW T6 68 | Tiles: T6; Claimings: CHI B7 1, GANG T2 2, GANG W2 3, GANG W3 1; Remain tiles: 0 69 | Tiles: F1 F1 F1 W4; Claimings: CHI T8 3, PENG T1 0, PENG W7 3; Remain tiles: 3 70 | Tiles: W1; Claimings: CHI T6 3, PENG T4 1, CHI B7 1, CHI B2 2; Remain tiles: 5 71 | Tiles: B2 B9 T9 T9; Claimings: CHI B8 3, GANG W9 2, PENG W6 0; Remain tiles: 3 72 | 73 | 0 HU 74 | Fans: ((32, '三杠'), (2, '双同刻'), (2, '断幺'), (1, '单钓将'), (1, '自摸')) 75 | Rewards: {0: 138, 1: -46, 2: -46, 3: -46} 76 | ``` 77 | 78 | ### Botzone平台 79 | 80 | 由于Botzone平台每轮决策都会重新调用Bot,因此每一轮只需做出一次决策即可。代码如下,详见`__main__.py`: 81 | 82 | ```python 83 | import json 84 | from mahjong_env.base_bot import RandomMahjongBot 85 | from mahjong_env.utils import request2obs, act2response 86 | 87 | def main(): 88 | agent = RandomMahjongBot() # 创建Bot 89 | request = json.loads(input()) # 读取request 90 | obs = request2obs(request) # 转化为于mahjong_env相同的观测 91 | act = agent.action(obs) # 决策 92 | response = act2response(act) # 转化为Botzone输出 93 | print(json.dumps(response)) 94 | 95 | if __name__ == '__main__': 96 | main() 97 | ``` 98 | 99 | 格式化后的样例输入(**实际输入为一行**): 100 | 101 | ```json 102 | { 103 | "requests":[ 104 | "0 0 1","1 0 0 0 0 W6 B6 T9 F1 T9 J1 F2 T1 F1 T7 B7 T2 T6", 105 | "2 B8","3 0 PLAY J1","3 1 DRAW","3 1 PLAY F4","3 2 PENG T3", 106 | "3 3 DRAW","3 3 PLAY F4","2 B2" 107 | ], "responses":[ 108 | "PASS","PASS","PLAY J1","PASS","PASS","PASS","PASS","PASS","PASS" 109 | ] 110 | } 111 | ``` 112 | 113 | 样例输出: 114 | 115 | ```json 116 | {"response": "PLAY F2"} 117 | ``` 118 | 119 | 由于Botzone平台仅接收单个文件作为Bot程序,需要将源码打包成zip文件,并且创建`__main__.py`作为程序接入点。注意,`__main__.py`**必须在压缩包顶层**,详见[这里](https://wiki.Botzone.org.cn/index.php?title=BundleMultiPython)。例如如果你把源码文件夹打包成`mahjong.zip`你可以通过以下代码测试是否打包成功: 120 | 121 | ```bash 122 | python mahjong.zip 123 | ``` 124 | 125 | 以上运行应同直接在文件夹内运行`python __main__.py`一致。 126 | 127 | 128 | 129 | ## `mahjong_env`: 麻将模拟环境 130 | 131 | 环境部分架构参考了Botzone[官方本地模拟器](https://github.com/ailab-pku/Chinese-Standard-Mahjong/tree/local-simulator),但对其做了大量修改使之和Botzone接口尽可能接近,同时提供交互接口能够用于强化学习或其它出牌算法。除此之外,与官方提供的[裁判程序](https://github.com/ailab-pku/Chinese-Standard-Mahjong/blob/master/judge/main.cpp)不同,模拟环境存储了牌局的公共数据,使之不需要每轮调用程序,很大程度上提高了运行速度,并节约了内存开销。大部分接口都被封装在相应类中,详见以下的介绍。由于逻辑较为复杂,**请尽量避免直接修改环境接口!** 132 | 133 | ### `consts.py` 134 | 135 | 定义了麻将中的常量。例如风圈、动作类型、鸣牌类型、麻将牌等。 136 | 137 | - `class RoundWind(Enum)` 风圈。 138 | - `EAST = 0` 东风; 139 | - `SOUTH = 1` 南风; 140 | - `WEST = 2` 西风; 141 | - `NORTH = 3` 北风。 142 | - `class ActionType(Enum)` 动作类型。 143 | - `PASS = 0` 过; 144 | - `DRAW = 1` 摸牌,仅在request中出现; 145 | - `PLAY = 2` 打牌; 146 | - `CHOW = 3` 吃; 147 | - `PUNG = 4` 碰; 148 | - `KONG = 5` 杠; 149 | - `MELD_KONG = 6` 补杠; 150 | - `HU = 7` 和。 151 | - `class ClaimingType` 鸣牌类型。 152 | - `CHOW = "CHI"` 吃; 153 | - `PUNG = "PENG"` 碰; 154 | - `KONG = "GANG"` 杠。 155 | - `TILE_SET: tuple(str)` 含有34个元素的元组,表示34种牌。 156 | 157 | 158 | ### `player_data.py` 159 | 160 | 定义了重要的类,包括: 161 | 162 | - `class Claiming` 鸣牌类。 163 | - `claiming_type: int` 鸣牌类型。0-2表示吃碰杠。 164 | - `tile: str` 代表鸣牌的牌。吃为**顺子中间那张牌**,其他玩家的暗杠将以`''`代替。 165 | - `data: int` 鸣牌数据。碰杠时表示牌来自哪位玩家,0-3表示玩家id,如为自己id,则为暗杠;吃时123表示第几张是上家供牌。注意这里用的是**绝对id**,而算番器用的是相对id(即0123分别表示自家、下家、对家、上家),详见`BaseMahjongBot.check_hu`中的转换。 166 | 167 | - `class Action` 动作类。 168 | 169 | - `player: int` 做出动作的玩家id; 170 | - `act_type: ActionType` 动作类型; 171 | - `tile: Union[str, None]` 动作数据。 172 | 173 | Botzone平台request和response格式有所不同,而`Action`类统一表示request或response中的一个动作,其中`tile`格式基本于Botzone平台一致: 174 | 175 | - `PASS`: `None`; 176 | - `DRAW`: 仅在request中出现,摸牌玩家得到所摸牌,其他玩家为`None`; 177 | - `PLAY`: 所打牌; 178 | - `CHOW `: 空格分隔的吃牌顺子中间牌和所打牌; 179 | - `PUNG`: 所打牌; 180 | - `KONG `: request中总为`None`,response中明杠时为`None`,暗杠时为暗杠牌; 181 | - `MELD_KONG `: 补杠牌; 182 | - `HU`: `None`。 183 | 184 | - `class PlayerData` 内部玩家类,存储玩家数据,并维护出牌、吃、碰、杠、补杠操作。请避免使用和访问这一类;如需要获得玩家状态,请使用`Mahjong.player_obs`方法。 185 | 186 | ### `core.py` 187 | 188 | 核心代码,定义了`Mahjong`类维护每局数据,其方法包括: 189 | 190 | - `__init__(self, random_seed=None)` 191 | 192 | 构造函数。 193 | 194 | - `init(self, tile_wall=None, round_wind=None, round_stage=None) -> Action` 195 | 196 | 初始化函数。**必须**在调用`step`前调用。如不带参数调用,则随机生成一轮牌局;如带参数调用,三个参数都需要指定,这些数据可以通过下节处理人类玩家数据获得。 197 | 198 | - `tile_wall: list[list[str]]`:四个玩家的牌墙,形状为4 × 34,从而确保每轮摸牌无随机性; 199 | - `round_wind: int`:圈风; 200 | - `round_stage: int`:局风,即第一个出牌玩家; 201 | - 返回值:初始request,一定是第一个出牌玩家抽牌。 202 | 203 | - `step(self, actions: list[Action]) -> Action` 204 | 205 | 运行一步环境。注意在此步中会自动检测出牌的合法性,如不合法则设定`self.done`标识并设定`self.rewards`(第一个不合法玩家-30,其他玩家+10)。如某位玩家发出和牌动作,则会同时判定是否和牌与和牌番种并存储在`self.fans`中,并返回相应收益。 206 | 207 | - `actions`:四位玩家的动作; 208 | - 返回值:新一轮的request。 209 | 210 | `step`函数的运行逻辑概述: 211 | 212 | 1. 每一轮分为两个阶段,第一阶段为摸牌,第二阶段为玩家响应打出牌; 213 | 2. 第一阶段摸牌后,玩家决定动作; 214 | 3. 如和,进入检测程序,给出收益,结束牌局; 215 | 4. 如杠(暗杠、补杠),此玩家继续摸牌,返回2(如补杠,还要检测抢杠和); 216 | 5. 否则,玩家出牌,进入第二阶段; 217 | 6. 如和,返回3检测; 218 | 7. 如果有玩家杠(明杠),则返回2,此玩家摸牌; 219 | 8. 如果有玩家碰,则返回5; 220 | 9. 如果有玩家吃,则返回5; 221 | 10. 否则,返回2,下一位玩家摸牌; 222 | 11. 任意阶段出现不合法动作都将使牌局直接结束。 223 | 224 | - `player_obs(self, player_id: int) -> dict` 225 | 226 | 获取某位玩家的观测状态。 227 | 228 | - `player_id`:玩家id,从0到3; 229 | - 返回值:观测字典,确保以下所有值能通过Botzone平台所给的request还原,包括 230 | - `'player_id': int`:玩家id; 231 | - `'tiles': list[str]`:玩家手牌,**不包括刚抽到的牌**,已排序; 232 | - `'tile_count': list[int]`:四位玩家剩余牌墙长度,在算番和判定是否能吃碰杠时有用; 233 | - `'claimings': list[Claiming]`:玩家鸣牌; 234 | - `'all_claimings': list[list[Claiming]]`:所有玩家鸣牌(包括自己); 235 | - `'played_tiles': dict`:所有已打出牌计数,包括弃牌和除暗杠外的鸣牌; 236 | - `'last_player': int`:上一动作玩家id(可能是自己); 237 | - `'last_tile': str`:上一动作打出的牌,或**刚抽到的牌**,如别人抽牌或杠则为`None`; 238 | - `'last_operation': ActionType`:上一动作类型; 239 | - `'round_wind': int`:风圈,算番时有用; 240 | - `'request_hist': list[Action]`:request历史; 241 | - `'response_hist': list[Action]`:玩家动作(response)历史。 242 | 243 | 如需要扩充观测数据,**请不要直接修改这一函数**,而是写一个包装函数,这样能确保所用的观测可以通过request复原。例如,以下函数增加了上一回合是否为自家杠`'is_kong': bool`这一数据: 244 | 245 | ```python 246 | def my_obs(obs): 247 | is_kong = obs['last_operation'] == ActionType.MELD_KONG 248 | is_kong |= len(obs['request_hist']) >= 2 \ 249 | and obs['request_hist'][-2].act_type == ActionType.KONG \ 250 | and obs['request_hist'][-2].player == obs['player_id'] 251 | obs['is_kong'] = is_kong 252 | return obs 253 | ``` 254 | 255 | - `observation(self) -> str` 256 | 257 | 返回表示牌局的四行字符串,包括四位玩家的手牌、鸣牌、牌堆剩余数量。 258 | 259 | - `request_json(self, player_id: int) -> str` 260 | 261 | 返回Botzone平台json格式的request。 262 | 263 | - `request_simple(self, player_id: int) -> str` 264 | 265 | 返回Botzone平台simple格式的request。 266 | 267 | 其重要属性包括: 268 | 269 | - `done: bool`:本局是否结束; 270 | - `fans: list`:和牌结束时,记录所和番型; 271 | - `rewards: dict[int, int]`:结束时,记录四位玩家的收益; 272 | - `training: bool`:如果以现有牌局初始化,将会置为`True`。 273 | 274 | ### `utils.py` 275 | 276 | 定义了Botzone平台转换接口。 277 | 278 | - `str2act(s: str) -> ActionType` 279 | 280 | 将动作类型字符串转换为`ActionType`。 281 | 282 | - `act2str(act: ActionType) -> str` 283 | 284 | 将`ActionType`转换为相应字符串。 285 | 286 | - `response2str(act: Action)` 287 | 288 | 将response中的动作转换为Botzone平台字符串。 289 | 290 | - `request2str(act: Action, player_id: int)` 291 | 292 | 将request中的动作转换为Botzone平台字符串。由于Botzone中request和response格式是不同的,因此需要两个函数。 293 | 294 | - `request2obs(request: dict) -> dict` 295 | 296 | 将request字典(`json.loads`之后)转换为与`Mahjong.player_obs`返回值中格式相同的观测字典。 297 | 298 | - `act2response(act: Action) -> dict` 299 | 300 | 将response动作转换为字典(`json.dumps`之后即可输出)。 301 | 302 | - `response2act(response: str, player_id: int) -> Action` 303 | 304 | 将response字符串转换为`Action`。 305 | 306 | - `json2simple(request: dict) -> str` 307 | 308 | 将Botzone平台json格式request转换为simple格式request。 309 | 310 | ### `base_bot.py` 311 | 312 | 定义了麻将Bot基类和一个简单的随机Bot。 313 | 314 | - `BaseMahjongBot`:麻将Bot基类。 315 | 316 | - `staticmethod check_hu(obs) -> int` 317 | 318 | 319 | 检测当前是否和牌,如和则返回番数(包括小于8番),如没和则返回-1。 320 | 321 | - `staticmethod check_kong(obs) -> bool` 322 | 323 | 检测当前是否能杠上一张别人打出或摸进的牌。 324 | 325 | - `staticmethod check_meld_kong(obs) -> bool` 326 | 327 | 检测当前是否能补杠上一张别人打出的牌。 328 | 329 | - `staticmethod check_pung(obs) -> bool` 330 | 331 | 332 | 检测当前是否能碰上一张别人打出的牌。 333 | 334 | - `staticmethod check_chow(obs) -> List[str]` 335 | 336 | 检测当前是否能吃上一张别人打出的牌。如不能则返回空列表,否则返回**所有**合法吃牌顺子的中间牌。 337 | 338 | - `action(self, obs: dict) -> Action` 339 | 340 | 做出一个动作。需要在子类中实现。 341 | 342 | - `RandomMahjongBot(BaseMahjongBot)` 343 | 344 | 实现了一个`action`函数,能鸣牌就鸣牌,否则随机打一张单张,否则随机出牌。 345 | 346 | ### Requirements 347 | 348 | 需要使用Botzone平台提供的算番器,详见[这里](https://github.com/ailab-pku/Chinese-Standard-Mahjong/tree/master/fan-calculator-usage)。其中Python版本为包装程序,需要在本地用`setup.py`安装(在`fan-calculator-usage/Mahjong-GB-Python/`路径下): 349 | 350 | ```bash 351 | python setup.py install 352 | ``` 353 | 354 | Botzone平台已预安装了这一算番器。算番器C++源码见[这里](https://github.com/summerinsects/ChineseOfficialMahjongHelper)。如果安装成功,可以运行同一路径下的`test.py`测试,输出为: 355 | 356 | ``` 357 | ((64, '四暗刻'), (48, '一色四节高'), (24, '清一色'), (8, '妙手回春'), (1, '幺九刻'), (1, '花牌')) 358 | ((48, '一色四节高'), (24, '清一色'), (16, '三暗刻'), (1, '幺九刻'), (1, '明杠'), (1, '花牌')) 359 | ERROR_WRONG_TILES_COUNT 360 | ERROR_NOT_WIN 361 | ``` 362 | 363 | 364 | 365 | ## `mahjong_data`: 人类牌局数据 366 | 367 | `mahjong_data`文件夹下包含了数据预处理脚本`preprocess.py`与测试用的数据文件`processed_data_sample.json`。 368 | 369 | 数据来自Botzone官网提供的`mjdata.zip`,详见[这里](https://www.Botzone.org.cn/static/gamecontest2020a.html)。链接:[百度网盘](https://pan.baidu.com/s/1vXzYUsRBNpH245SQku0b3A),提取码:rm79。其中包含人类玩家对局的12140场流局,132994场自摸,及385324场点炮。 370 | 371 | ### ⚠️ 372 | 373 | 由于每场比赛数据记录在一个txt文件中,解压极有可能会把一般电脑的文件资源管理器搞炸,因此**请不要尝试解压**!!预处理脚本会自动处理压缩包,在不解压的情况下读取数据。 374 | 375 | ### `preprocess.py` 376 | 377 | 将比赛数据处理为方便`mahjong_env`读取的格式。默认不读取流局,只读取自摸和点炮的数据,如需要流局数据,可以在源码中反注释相应行。运行需要用到`tqdm`包(进度条)。使用以下命令运行脚本: 378 | 379 | ```bash 380 | python preprocess.py 381 | ``` 382 | 383 | 其中``是`mjdata.zip`所在路径,如没有提供则默认在当前路径下。服务器上运行约需要10分钟(Intel Xeon E5-2697 v4),本地运行时间约40分钟。脚本会在当前路径输出文件`processed_data.json`。 384 | 385 | ### `processed_data.json` 386 | 387 | 约1.5G(不包括流局),每一行为代表一局游戏的json串,包含以下内容: 388 | 389 | - `round_wind`: 圈风,0-4分别表示东西南北。 390 | 391 | - `first_player`: 第一位玩家,由于东风位玩家编号总为0,此参数相当于局风。 392 | 393 | - `tiles`: 四位玩家的牌墙,4 × 34的数组,每个元素为代表牌的字符串。注意,为了消除随机性,**玩家所有摸牌也会加入其中**。为了不影响`mahjong_env`对和牌的检查(海底捞月等),将每个人的手牌用空串补足到34张。 394 | 395 | - `actions`: 四位玩家每轮动作。其中每个动作为一个数组,第一个元素为动作类型: 396 | 397 | - 0:过; 398 | - 1:摸牌,不应在数据中出现; 399 | - 2:出牌; 400 | - 3:吃; 401 | - 4:碰; 402 | - 5:杠; 403 | - 6:补杠; 404 | - 7:和。 405 | 406 | 以上与`ActionType`一致。如有第二个元素,则表示动作参数,与Botzone接口一致,详见[Botzone规则说明](https://wiki.Botzone.org.cn/index.php?title=Chinese-Standard-Mahjong#Bot.E8.BE.93.E5.85.A5.E8.BE.93.E5.87.BA)。 407 | 408 | 例子(格式化后,格式化前为单行): 409 | 410 | ```json 411 | { 412 | "round_wind": 0, 413 | "first_player": 2, 414 | "tiles": [ 415 | ["B1", "B6", "W6", "T6", "T6", "W2", "B7", "B8", "W1", "T9", 416 | "F4", "J3", "B3", "T3", "W1", "T1", "B2", "W3", "F1", "B6", 417 | "", "", "", "", "", "", "", "", "", "", "", "", "", ""], 418 | ["F3", "W1", "B5", "T4", "W3", "W5", "W6", "W2", "T4", "B4", 419 | "T1", "B9", "W6", "B7", "J2", "J1", "T2", "B2", "T7", "J2", 420 | "T8", "", "", "", "", "", "", "", "", "", "", "", "", ""], 421 | ["T8", "T8", "W7", "T3", "W4", "B1", "J1", "T7", "F2", "B1", 422 | "B8", "W8", "W9", "F4", "T4", "F1", "T5", "W1", "B7", "B3", 423 | "T2", "", "", "", "", "", "", "", "", "", "", "", "", ""], 424 | ["T9", "T5", "J2", "T3", "T7", "B5", "J2", "W7", "J1", "B4", 425 | "B3", "F3", "T6", "W3", "F2", "T6", "W6", "W5", "T9", "W7", 426 | "T2", "", "", "", "", "", "", "", "", "", "", "", "", ""] 427 | ], 428 | "actions": [ 429 | [[0], [0], [0], [0], [2, "F4"], [0], [0], [0], [0], [0], [0], [0], 430 | [2, "J3"], [0], [0], [0], [0], [2, "T9"], [0], [0], [0], [0], [0], 431 | [0], [0], [2, "W6"], [0], [0], [0], [0], [0], [0], [3, "W2 W1"], 432 | [0], [0], [0], [0], [0], [0], [0], [2, "W3"], [0], [0], [0], [0], 433 | [0], [0], [0], [2, "F1"], [0], [0], [0], [0], [0], [0], [0], 434 | [2, "B6"], [0], [0], [0], [0], [0], [0], [7]], 435 | [[0], [0], [0], [0], [0], [0], [2, "F3"], [0], [0], [0], [0], [0], 436 | [0], [0], [2, "J2"], [0], [0], [0], [0], [2, "J1"], [0], [0], 437 | [0], [0], [0], [0], [0], [2, "W6"], [0], [0], [0], [0], [0], [0], 438 | [2, "B2"], [0], [0], [0], [0], [0], [0], [0], [2, "T7"], [0], [0], 439 | [0], [0], [0], [0], [0], [2, "J2"], [0], [0], [0], [0], [0], [0], 440 | [0], [2, "T8"], [0], [0], [0], [0], [0]], 441 | [[2, "F2"], [0], [0], [0], [0], [0], [0], [0], [2, "F4"], [0], [0], 442 | [0], [0], [0], [0], [0], [0], [0], [0], [0], [0], [2, "J1"], [0], 443 | [0], [0], [0], [0], [0], [0], [2, "F1"], [0], [0], [0], [0], [0], 444 | [0], [2, "W1"], [0], [0], [0], [0], [0], [0], [0], [2, "T8"], [0], 445 | [0], [0], [0], [0], [0], [0], [2, "B3"], [0], [0], [0], [0], [0], 446 | [0], [0], [2, "W4"], [0], [0], [0]], 447 | [[0], [0], [2, "J1"], [0], [0], [0], [0], [0], [0], [0], [2, "T3"], 448 | [0], [0], [0], [0], [4, "T9"], [0], [0], [0], [0], [0], [0], [0], 449 | [2, "T6"], [0], [0], [0], [0], [0], [0], [0], [2, "W3"], [0], [0], 450 | [0], [0], [0], [0], [2, "F2"], [0], [0], [0], [0], [0], [0], [0], 451 | [2, "T9"], [0], [0], [0], [0], [0], [0], [0], [2, "W7"], [0], [0], 452 | [0], [0], [0], [0], [0], [2, "T2"], [0]] 453 | ] 454 | } 455 | ``` 456 | 457 | 本项目中的`processed_data_sample.json`含有三局按以上格式处理后的数据,用于测试环境和bot。 458 | 459 | 460 | 461 | ## `mahjong_cpp`: C++调用接口 462 | 463 | `mahjong_cpp`文件夹中包含了打包C++程序以供Python程序调用的样例。由于传统算法通常是C++程序以加快搜索速度,在强化学习中如需要C++程序作为对手学习,需要将其打包成Python模块。 464 | 465 | 文件夹中包含以下文件: 466 | 467 | - `mahjong.h`: 定义了需要打包的接口函数: 468 | 469 | ```c++ 470 | std::string action(std::string &requests); 471 | ``` 472 | 473 | `action`接收一个输入字符串,并且输出一个代表动作的字符串。 474 | 475 | - `mahjong.cpp`: 实现了上述函数(简单交互),修改自官方样例程序。 476 | 477 | - `mahjong_wrapper.cpp`: Python包装接口。定义了模块名`MahjongBot`与方法名`MahjongBot.action`。 478 | 479 | - `setup.py`: 安装脚本。 480 | 481 | 运行 482 | 483 | ```bash 484 | python setup.py install 485 | ``` 486 | 487 | 以安装自定义包。总的来说,结果是生成了名为`MahjongBot`的本地模块,其中包含`action`方法接收一个字符串,并输出一个字符串。如要改变实现,请修改`mahjong.cpp`;如要修改模块名和方法名,请修改`mahjong_wrapper.cpp`与`setup.py`。更多细节请参见[Python官网](https://docs.python.org/3/extending/extending.html)。 488 | 489 | 为了测试是否成功安装,可以运行`test_cpp_bot.py`,将随机生成一场牌局。 490 | 491 | 492 | 493 | ## 测试程序 494 | 495 | 测试程序用以检测`mahjong_env`的部分功能,并提供接口的使用示例。项目中包含了以下测试程序: 496 | 497 | - `test_bot.py`: Bot接口测试。先模拟一个随机牌局,再模拟`processed_data_sample.json`中的牌局。如果你实现了自己的Bot,可以通过修改此文件测试。 498 | - `test_cpp_bot.py`: Python包装的C++ Bot接口测试,模拟一个随机牌局,用来测试C++接口是否成功调用。 499 | - `test_mahjong.py`: (非常不全的)麻将环境测试。 500 | 501 | 502 | 503 | ## TODO 504 | 505 | - [ ] 实现“长时运行”接口和Bot(详见[这里](https://wiki.botzone.org.cn/index.php?title=Bot#.E9.95.BF.E6.97.B6.E8.BF.90.E8.A1.8C)) 506 | - [ ] 更全面的测试程序(包括一些极端情况例如海底捞月等等) -------------------------------------------------------------------------------- /mahjong_env/core.py: -------------------------------------------------------------------------------- 1 | import random 2 | import json 3 | from typing import List 4 | 5 | from MahjongGB import MahjongFanCalculator 6 | 7 | from . import consts 8 | from .consts import ActionType, RoundWind 9 | from .player_data import PlayerData, Action 10 | from .utils import request2str, response2str 11 | 12 | 13 | class Mahjong: 14 | def __init__(self, random_seed=None): 15 | self.random_seed = random_seed 16 | random.seed(self.random_seed) 17 | 18 | self.training = None 19 | self.round_wind = None 20 | self.players_data = None 21 | 22 | self.done, self.rewards = False, None 23 | self.round_stage = None 24 | self.last_round_stage = None 25 | self.last_operation = None 26 | self.current_exposed_kung = self.current_concealed_kung = self.current_meld_kung = \ 27 | self.last_exposed_kung = self.last_concealed_kung = self.last_meld_kung = False 28 | self.init_tiles = [] 29 | self.played_tiles = None 30 | self.last_tile = None 31 | self.request_hist = [] # type: List[Action] 32 | self.fans = None 33 | 34 | def init(self, tile_wall=None, round_wind=None, round_stage=None) -> Action: 35 | self.training = tile_wall is not None 36 | self.round_wind = round_wind if self.training else self.__generate_round_wind() 37 | if self.training: 38 | for tile in tile_wall: 39 | tile.reverse() 40 | else: 41 | tile_wall = self.__generate_tile_wall() 42 | self.players_data = self.__deal(tile_wall) 43 | for player in self.players_data: 44 | self.init_tiles.append(player.tiles.copy()) 45 | 46 | self.done, self.rewards = False, {str(i): 0 for i in range(consts.NUM_PLAYERS)} 47 | self.round_stage = round_stage if self.training else random.randrange(4) 48 | self.last_round_stage = None 49 | self.last_operation = None 50 | self.current_exposed_kung = self.current_concealed_kung = self.current_meld_kung = \ 51 | self.last_exposed_kung = self.last_concealed_kung = self.last_meld_kung = False 52 | 53 | self.played_tiles = {tile: 0 for tile in consts.TILE_SET} 54 | self.last_tile = self.__draw_tile() 55 | first_act = Action(self.round_stage, ActionType.DRAW, self.last_tile) 56 | self.request_hist = [first_act] 57 | self.fans = None 58 | 59 | print(f"Initilized to {'training' if self.training else 'testing'} mode") 60 | print(f'Round wind: {self.round_wind}, Round stage: {self.round_stage}') 61 | return first_act 62 | 63 | def observation(self) -> str: 64 | obs = [] 65 | for player in self.players_data: 66 | obs.append(''.join([ 67 | 'Tiles: ', ' '.join(sorted(player.tiles)), 68 | '; Claimings: ', ', '.join([str(c) for c in player.claimings]), 69 | '; Remain tiles: ', str(len(player.tile_wall)) 70 | ])) 71 | return '\n'.join(obs) 72 | 73 | def player_obs(self, player_id: int) -> dict: 74 | def filter_draw(c): 75 | if c.act_type == ActionType.DRAW and c.player != player_id: 76 | return Action(c.player, c.act_type, None) 77 | return c 78 | 79 | player = self.players_data[player_id] 80 | return { 81 | 'player_id': player_id, 82 | 'tiles': sorted(player.tiles), 83 | 'tile_count': [len(self.players_data[i].tile_wall) for i in range(4)], 84 | 'claimings': player.claimings, 85 | 'all_claimings': [self.players_data[i].get_claimings(i != player_id) for i in range(4)], 86 | 'played_tiles': self.played_tiles, 87 | 'last_player': self.round_stage % 4, 88 | 'last_tile': None if self.last_operation == ActionType.DRAW 89 | and player_id != self.round_stage else self.last_tile, 90 | 'last_operation': self.last_operation, 91 | 'round_wind': self.round_wind, 92 | 'request_hist': [filter_draw(c) for c in self.request_hist], 93 | 'response_hist': player.response_hist, 94 | } 95 | 96 | def request_json(self, player_id: int) -> str: 97 | request = {'requests': [f'0 {player_id} {self.round_wind}', # first round 98 | f'1 0 0 0 0 {" ".join(self.init_tiles[player_id])}'], # second round 99 | 'responses': [response2str(act) for act in self.players_data[player_id].response_hist]} 100 | request['requests'].extend([request2str(act, player_id) for act in self.request_hist]) 101 | return json.dumps(request) 102 | 103 | def request_simple(self, player_id: int) -> str: 104 | request = [f'{len(self.request_hist) + 2}', # first line 105 | f'0 {player_id} {self.round_wind}', f'PASS', # first action 106 | f'1 0 0 0 0 {" ".join(self.init_tiles[player_id])}', f'PASS'] # second action 107 | for req_act, res_act in zip(self.request_hist[:-1], self.players_data[player_id].response_hist): 108 | request.append(request2str(req_act, player_id)) 109 | request.append(response2str(res_act)) 110 | request.append(request2str(self.request_hist[-1], player_id)) 111 | return '\n'.join(request) 112 | 113 | @staticmethod 114 | def __generate_round_wind() -> int: 115 | return random.choice([RoundWind.EAST, RoundWind.SOUTH, 116 | RoundWind.WEST, RoundWind.NORTH]).value 117 | 118 | @staticmethod 119 | def __generate_tile_wall() -> List[List[str]]: 120 | tile_wall = list() 121 | # 数牌(万, 条, 饼), 风牌, 箭牌 122 | for _ in range(4): 123 | for i in range(1, 9 + 1): 124 | tile_wall.append(f"W{i}") 125 | tile_wall.append(f"T{i}") 126 | tile_wall.append(f"B{i}") 127 | for i in range(1, 4 + 1): 128 | tile_wall.append(f"F{i}") 129 | for i in range(1, 3 + 1): 130 | tile_wall.append(f"J{i}") 131 | random.shuffle(tile_wall) 132 | thr = len(tile_wall) // consts.NUM_PLAYERS 133 | return [tile_wall[:thr], tile_wall[thr:2 * thr], tile_wall[2 * thr:3 * thr], tile_wall[3 * thr:]] 134 | 135 | @staticmethod 136 | def __deal(tile_wall: List[List[str]]) -> List[PlayerData]: 137 | players_data = [PlayerData(i, tile) for i, tile in enumerate(tile_wall)] 138 | for player in players_data: 139 | for _ in range(consts.NUM_HAND_TILES): 140 | player.tiles.append(player.tile_wall.pop()) 141 | return players_data 142 | 143 | def __win(self, player: int, fan: int): 144 | self.last_operation = ActionType.HU 145 | self.done = True 146 | self.rewards = {p: 0 for p in range(consts.NUM_PLAYERS)} 147 | for i in range(consts.NUM_PLAYERS): 148 | if self.round_stage < 4: 149 | if i == player: 150 | self.rewards[i] = 3 * (8 + fan) 151 | else: 152 | self.rewards[i] = -(8 + fan) 153 | else: 154 | if i == player: 155 | self.rewards[i] = 3 * 8 + fan 156 | elif self.round_stage == i + 4: 157 | self.rewards[i] = -(8 + fan) 158 | elif self.round_stage == i + 8 and (self.last_meld_kung or self.last_concealed_kung): 159 | self.rewards[i] = -(8 + fan) 160 | else: 161 | self.rewards[i] = -8 162 | 163 | def __lose(self, player: int): 164 | self.last_operation = ActionType.HU 165 | self.done = True 166 | self.rewards = {p: 10 if p != player else -30 for p in range(consts.NUM_PLAYERS)} 167 | 168 | def __huang(self): 169 | self.done = True 170 | self.rewards = {p: 0 for p in range(consts.NUM_PLAYERS)} 171 | 172 | def __check_pass(self, player: int, action: Action) -> bool: 173 | if action.act_type != ActionType.PASS: 174 | self.__lose(player) 175 | return False 176 | return True 177 | 178 | def __check_draw(self, player: int, action: Action) -> bool: 179 | action_type, action_content = action.act_type, action.tile 180 | if action_type == ActionType.HU: 181 | fan = self.__calculate_fan(player) 182 | self.__lose(player) if fan < 8 else self.__win(player, fan) 183 | return False 184 | elif action_type in {ActionType.PLAY, ActionType.KONG, 185 | ActionType.MELD_KONG}: 186 | self.players_data[player].tiles.append(self.last_tile) 187 | self.last_tile = action_content 188 | if action_type == ActionType.PLAY: 189 | if self.players_data[player].play(action_content) is True: 190 | self.last_operation = ActionType.PLAY 191 | self.round_stage += 4 192 | return True 193 | elif action_type == ActionType.KONG: 194 | if len(self.players_data[player].tile_wall) == 0 or \ 195 | len(self.players_data[(player + 1) % consts.NUM_PLAYERS].tiles) == 0: 196 | self.__lose(player) 197 | return False 198 | if self.players_data[player].kong(self.last_tile, player) is True: 199 | self.last_operation = ActionType.KONG 200 | self.current_concealed_kung = True 201 | self.current_exposed_kung = self.last_exposed_kung = self.current_meld_kung = self.last_meld_kung = False 202 | self.round_stage = player + 8 203 | return True 204 | elif action_type == ActionType.MELD_KONG: 205 | if len(self.players_data[player].tile_wall) == 0 or \ 206 | len(self.players_data[(player + 1) % consts.NUM_PLAYERS].tiles) == 0: 207 | self.__lose(player) 208 | return False 209 | if self.players_data[player].meld_kong(self.last_tile) is True: 210 | self.played_tiles[self.last_tile] = 4 211 | self.last_operation = ActionType.MELD_KONG 212 | self.current_meld_kung = True 213 | self.current_concealed_kung = self.last_concealed_kung = self.current_exposed_kung = self.last_exposed_kung = False 214 | self.round_stage = player + 8 215 | return True 216 | self.__lose(player) 217 | return False 218 | 219 | def __check_hu(self, player: int, action: Action) -> bool: 220 | if action.act_type == ActionType.HU: 221 | fan = self.__calculate_fan(player) 222 | self.__lose(player) if fan < 8 else self.__win(player, fan) 223 | return False 224 | return True 225 | 226 | def __check_pung_kong(self, player: int, action: Action) -> bool: 227 | action_type, action_content = action.act_type, action.tile 228 | if action_type == ActionType.PASS: 229 | return True 230 | elif action_type == ActionType.KONG: 231 | if self.players_data[player].kong(self.last_tile, self.round_stage % 4) is False: 232 | self.__lose(player) 233 | return False 234 | self.played_tiles[self.last_tile] = 4 235 | self.last_operation = ActionType.KONG 236 | self.current_exposed_kung = True 237 | self.last_meld_kung = self.current_meld_kung = self.last_concealed_kung = self.current_concealed_kung = False 238 | self.round_stage = player + 8 239 | return False 240 | elif action_type == ActionType.PUNG: 241 | if self.players_data[player].pung(self.last_tile, self.round_stage % 4) is False: 242 | self.__lose(player) 243 | return False 244 | self.played_tiles[self.last_tile] += 3 245 | self.last_operation = ActionType.PUNG 246 | self.last_tile = action_content 247 | if self.players_data[player].play(action_content) is False: 248 | self.__lose(player) 249 | return False 250 | self.round_stage = player + 4 251 | return False 252 | elif action_type != ActionType.CHOW: 253 | self.__lose(player) 254 | return False 255 | return True 256 | 257 | def __check_chow(self, player: int, action: Action) -> bool: 258 | action_type, action_content = action.act_type, action.tile 259 | if action_type != ActionType.CHOW: 260 | return True 261 | 262 | if (self.round_stage - player) % consts.NUM_PLAYERS != 3: 263 | self.__lose(player) 264 | return False 265 | chow_tiles_middle, play_tile = action_content.split() 266 | if chow_tiles_middle[0] not in {"W", "B", "T"} or chow_tiles_middle[0] != self.last_tile[0] or abs( 267 | int(self.last_tile[1]) - int(chow_tiles_middle[1])) > 1: 268 | self.__lose(player) 269 | return False 270 | self.players_data[player].tiles.append(self.last_tile) 271 | suit, number = chow_tiles_middle 272 | chow_tiles = [f"{suit}{int(number) - 1}", chow_tiles_middle, f"{suit}{int(number) + 1}"] 273 | chow_tile_index = 0 274 | for i in range(len(chow_tiles)): 275 | if self.last_tile == chow_tiles[i]: 276 | chow_tile_index = i + 1 277 | break 278 | if self.players_data[player].chow(chow_tiles, chow_tile_index) is False: 279 | self.__lose(player) 280 | return False 281 | self.last_operation = ActionType.CHOW 282 | self.last_tile = play_tile 283 | if self.players_data[player].play(play_tile) is False: 284 | self.__lose(player) 285 | return False 286 | self.round_stage = player + 4 287 | return False 288 | 289 | def __check_kong(self, player: int, action: Action) -> bool: 290 | action_type = action.act_type 291 | if action_type == ActionType.PASS: 292 | return True 293 | if self.last_meld_kung is True and self.round_stage % consts.NUM_PLAYERS != player and action_type == ActionType.HU: 294 | fan = self.__calculate_fan(player) 295 | self.__lose(player) if fan < 8 else self.__win(player, fan) 296 | return False 297 | self.__lose(player) 298 | return False 299 | 300 | def step(self, actions: List[Action]) -> Action: 301 | if self.done: 302 | raise TypeError('Step after done!') 303 | if self.training is None: 304 | raise TypeError('Not initialized! Call self.init() first!') 305 | 306 | self.last_round_stage = self.round_stage 307 | for player, act in zip(self.players_data, actions): 308 | player.response_hist.append(act) 309 | 310 | pass_flag = True 311 | if 0 <= self.round_stage < 4: 312 | for i in range(consts.NUM_PLAYERS): 313 | if self.round_stage != i: 314 | pass_flag = self.__check_pass(i, actions[i]) 315 | if not pass_flag: 316 | break 317 | else: 318 | pass_flag = self.__check_draw(i, actions[i]) 319 | if not pass_flag: 320 | break 321 | self.last_exposed_kung = self.current_exposed_kung 322 | self.last_concealed_kung = self.current_concealed_kung 323 | self.last_meld_kung = self.current_meld_kung 324 | self.current_exposed_kung = self.current_concealed_kung = self.current_meld_kung = False 325 | elif 4 <= self.round_stage < 8: 326 | for i in range(consts.NUM_PLAYERS): 327 | if i == 0: 328 | pass_flag = self.__check_pass(self.round_stage % consts.NUM_PLAYERS, 329 | actions[self.round_stage % consts.NUM_PLAYERS]) 330 | if not pass_flag: 331 | break 332 | else: 333 | pass_flag = self.__check_hu((self.round_stage + i) % consts.NUM_PLAYERS, 334 | actions[(self.round_stage + i) % consts.NUM_PLAYERS]) 335 | if not pass_flag: 336 | self.round_stage = self.round_stage + i 337 | break 338 | 339 | for i in range(consts.NUM_PLAYERS): 340 | if pass_flag is True and self.round_stage != i + 4: 341 | pass_flag = self.__check_pung_kong(i, actions[i]) 342 | 343 | for i in range(consts.NUM_PLAYERS): 344 | if pass_flag is True and self.round_stage != i + 4: 345 | pass_flag = self.__check_chow(i, actions[i]) 346 | 347 | if pass_flag is True: 348 | self.round_stage = (self.round_stage + 1) % consts.NUM_PLAYERS 349 | self.played_tiles[self.last_tile] += 1 350 | else: 351 | for i in range(consts.NUM_PLAYERS): 352 | if self.__check_hu((self.round_stage + i) % consts.NUM_PLAYERS, 353 | actions[(self.round_stage + i) % consts.NUM_PLAYERS]) is False: 354 | break 355 | if not self.done: 356 | self.round_stage -= 8 357 | 358 | if pass_flag: 359 | if 0 <= self.round_stage < 4: 360 | if len(self.players_data[self.round_stage % consts.NUM_PLAYERS].tile_wall) == 0: 361 | self.__huang() 362 | else: 363 | self.last_tile = self.__draw_tile() 364 | elif 4 <= self.round_stage < 8: 365 | if len(self.players_data[(self.last_round_stage + 1) % consts.NUM_PLAYERS].tile_wall) == 0 and \ 366 | self.last_operation in {ActionType.CHOW, ActionType.PUNG}: 367 | self.__lose(self.round_stage % consts.NUM_PLAYERS) 368 | else: 369 | if len(self.players_data[(self.last_round_stage + 1) % consts.NUM_PLAYERS].tile_wall) == 0 and \ 370 | self.last_operation in {ActionType.KONG, ActionType.MELD_KONG}: 371 | self.__lose(self.round_stage % consts.NUM_PLAYERS) 372 | 373 | player_id = self.round_stage % 4 374 | if self.last_operation == ActionType.DRAW: 375 | self.request_hist.append(Action(player_id, ActionType.DRAW, self.last_tile)) 376 | elif self.last_concealed_kung: 377 | self.request_hist.append(Action(player_id, ActionType.KONG, None)) 378 | else: 379 | self.request_hist.append(actions[player_id]) 380 | if self.done: 381 | self.training = None 382 | return self.request_hist[-1] 383 | 384 | def __calculate_fan(self, player: int) -> int: 385 | claimings, tiles = self.players_data[player].claimings_and_tiles # 明牌, 暗牌 386 | win_tile = self.last_tile # 和牌 387 | flower_count = 0 # 补花数 388 | is_self_draw = player == self.round_stage # 自摸 389 | is_fourth_tile = self.played_tiles[self.last_tile] == 3 # 绝张 390 | is_kong = self.last_meld_kung or self.last_concealed_kung or self.current_meld_kung # 杠 391 | is_last_tile = len(self.players_data[(self.round_stage + 1) % consts.NUM_PLAYERS].tile_wall) == 0 # 牌墙最后一张 392 | player_wind = player # 门风 393 | round_wind = self.round_wind # 圈风 394 | 395 | try: 396 | result = MahjongFanCalculator(claimings, tiles, win_tile, flower_count, is_self_draw, is_fourth_tile, 397 | is_kong, is_last_tile, player_wind, round_wind) 398 | self.fans = result 399 | return sum([res[0] for res in result]) 400 | except Exception as exception: 401 | if str(exception) == "ERROR_NOT_WIN": 402 | return -1 403 | raise 404 | 405 | def __draw_tile(self) -> str: 406 | self.last_operation = ActionType.DRAW 407 | return self.players_data[self.round_stage].tile_wall.pop() 408 | --------------------------------------------------------------------------------