diff --git a/.gitignore b/.gitignore index d51c984..5eaea24 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ MahjongCopilot.spec libriichi3p/*.pyd libriichi3p/*.so chrome_ext/*/ +mjai/bot_3p/model.pth diff --git a/bot/akagiot/bot_akagiot.py b/bot/akagiot/bot_akagiot.py index 1601c49..2a1a9f2 100644 --- a/bot/akagiot/bot_akagiot.py +++ b/bot/akagiot/bot_akagiot.py @@ -15,7 +15,8 @@ class BotAkagiOt(BotMjai): def __init__(self, url:str, apikey:str) -> None: super().__init__("Akagi Online Bot") self.url = url - self.apikey = apikey + self.apikey = apikey + self.model_type = "AkagiOT" self._check() diff --git a/bot/akagiot2/bot_akagiot2.py b/bot/akagiot2/bot_akagiot2.py new file mode 100644 index 0000000..97fc3b8 --- /dev/null +++ b/bot/akagiot2/bot_akagiot2.py @@ -0,0 +1,105 @@ +import multiprocessing +from pathlib import Path + +from bot.bot import BotMjai, GameMode +from common.log_helper import LOGGER +from common.utils import BotNotSupportingMode, Ot2BotCreationError +import time + +model_file_path = "mjai/bot_3p/model.pth" + + +class BotAkagiOt2(BotMjai): + """ Bot implementation for Akagi OT2 model """ + + def __init__(self) -> None: + super().__init__("Akagi OT2 Bot") + self._supported_modes: list[GameMode] = [] + self._is_online = "Waiting" + self._check() + self.model_type = "AkagiOT2" + + def _check(self): + # check model file + if not Path(model_file_path).exists() or not Path(model_file_path).is_file(): + LOGGER.warning("Cannot find model file for Akagi OT2 model:%s", model_file_path) + if try_create_ot2_bot(): + self._supported_modes.append(GameMode.MJ3P) + else: + LOGGER.warning("Cannot create bot for OT2 model.", exc_info=True) + LOGGER.warning("Could be missing file: %s", model_file_path) + raise Ot2BotCreationError("Failed to create bot instance for Akagi OT2 model.") + pass + + @property + def supported_modes(self) -> list[GameMode]: + """ return supported game modes""" + return self._supported_modes + + # 覆写父类 impl 方法 + def _init_bot_impl(self, mode: GameMode = GameMode.MJ3P): + if mode == GameMode.MJ3P: + try: + import riichi3p + self.mjai_bot = riichi3p.online.Bot(self.seat) + except Exception as e: + LOGGER.warning("Cannot create bot for Akagi OT2 model: %s", e, exc_info=True) + LOGGER.warning("Could be missing model.pth file in path mjai/bot_3p") + raise Ot2BotCreationError("Failed to create bot instance for Akagi OT2 model.") + else: + raise BotNotSupportingMode(mode) + + # 覆写父类 react 方法 + def react(self, input_msg: dict) -> dict | None: + reaction = super().react(input_msg) + if reaction is not None: + if self.mjai_bot.is_online(): + self._is_online = "Online" + else: + self._is_online = "Offline" + return reaction + + @property + def is_online(self): + return self._is_online + + +# 尝试获取mjai.bot实例,该方法可能会导致 panick,需要在分离进程中使用 +def create_bot_instance(queue): + import riichi3p + try: + # 尝试创建一个mjai.bot实例 + riichi3p.online.Bot(1) + queue.put(True) # 将成功的标志放入队列 + except Exception as e: + LOGGER.warning("Cannot create bot: %s", e, exc_info=True) + LOGGER.warning("Could be missing model.pth file in path ./mjai/bot_3p") + queue.put(False) # 将失败的标志放入队列 + + +# 使用分离进程尝试创建bot实例 +def try_create_ot2_bot(): + queue = multiprocessing.Queue() + process = multiprocessing.Process(target=create_bot_instance, args=(queue,)) + process.start() + + # 尝试从队列中获取结果,设置超时时间防止无限等待 + start_time = time.time() + timeout = 10 + try: + timeout = 10 + success = queue.get(timeout=timeout) + except Exception as e: + end_time = time.time() + LOGGER.error("Failed to retrieve the result from the subprocess: %s", e) + if end_time - start_time >= timeout: + LOGGER.error("Timeout when waiting for the result from the subprocess") + process.terminate() + success = False + + process.join() + + if not success or process.exitcode != 0: + LOGGER.error("Failed to create bot or detected a crash in the subprocess with exit code %s", process.exitcode) + return False + return True diff --git a/bot/bot.py b/bot/bot.py index f4e8d17..f4b73d2 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -27,6 +27,11 @@ def __init__(self, name:str="Bot") -> None: self.name = name self._initialized:bool = False self.seat:int = None + self.model_type:str = None + + @property + def get_model_type(self) -> str: + return self.model_type @property def supported_modes(self) -> list[GameMode]: diff --git a/bot/factory.py b/bot/factory.py index 47d8b2f..024d356 100644 --- a/bot/factory.py +++ b/bot/factory.py @@ -5,9 +5,11 @@ from .local.bot_local import BotMortalLocal from .mjapi.bot_mjapi import BotMjapi from .akagiot.bot_akagiot import BotAkagiOt +from .akagiot2.bot_akagiot2 import BotAkagiOt2 -MODEL_TYPE_STRINGS = ["Local", "AkagiOT", "MJAPI"] +MODEL_TYPE_STRINGS = ["Local", "AkagiOT", "MJAPI", "AkagiOT2"] +OT2_MODEL_PATH = "./mjai/bot_3p/model.pth" def get_bot(settings:Settings) -> Bot: @@ -22,6 +24,8 @@ def get_bot(settings:Settings) -> Bot: bot = BotMortalLocal(model_files) case "AkagiOT": bot = BotAkagiOt(settings.akagi_ot_url, settings.akagi_ot_apikey) + case "AkagiOT2": + bot = BotAkagiOt2() case "MJAPI": bot = BotMjapi(settings) case _: diff --git a/bot/local/bot_local.py b/bot/local/bot_local.py index d522b54..a0ccaed 100644 --- a/bot/local/bot_local.py +++ b/bot/local/bot_local.py @@ -18,6 +18,7 @@ def __init__(self, model_files:dict[GameMode, str]) -> None: super().__init__("Local Mortal Bot") self._supported_modes: list[GameMode] = [] self.model_files = model_files + self.model_type = "Local" self._engines:dict[GameMode, any] = {} for k,v in model_files.items(): if not Path(v).exists() or not Path(v).is_file(): diff --git a/bot/mjapi/bot_mjapi.py b/bot/mjapi/bot_mjapi.py index feaafb4..fe95cf6 100644 --- a/bot/mjapi/bot_mjapi.py +++ b/bot/mjapi/bot_mjapi.py @@ -26,6 +26,7 @@ def __init__(self, setting:Settings) -> None: self._login_or_reg() self.id = -1 self.ignore_next_turn_self_reach:bool = False + self.model_type = "mjapi" @property def info_str(self): diff --git a/bot_manager.py b/bot_manager.py index 19821ee..8cf6175 100644 --- a/bot_manager.py +++ b/bot_manager.py @@ -463,6 +463,15 @@ def _update_overlay_botleft(self): model_text = '🤖' if self.is_bot_created(): model_text += self.st.lan().MODEL + ": " + self.st.model_type + if self.bot.get_model_type == "AkagiOT2": + if self.bot.is_online == "Online": + model_text += "(🌐)" + elif self.bot.is_online == "Offline": + model_text += "(🔌)" + elif self.bot.is_online == "Waiting": + model_text += "(⏳)" + else: + model_text += "(❓)" else: model_text += self.st.lan().MODEL_NOT_LOADED diff --git a/common/lan_str.py b/common/lan_str.py index 565f627..c12a4fd 100644 --- a/common/lan_str.py +++ b/common/lan_str.py @@ -50,6 +50,8 @@ class LanStr: AI_MODEL_FILE_3P = "Local Model File (3P)" AKAGI_OT_URL = "AkagiOT Server URL" AKAGI_OT_APIKEY = "AkagiOT API Key" + AKAGI_OT2_URL = "AkagiOT2 Server URL" + AKAGI_OT2_APIKEY = "AkagiOT2 API Key" MJAPI_URL = "MJAPI Server URL" MJAPI_USER = "MJAPI User" MJAPI_USAGE = "API Usage" @@ -89,6 +91,7 @@ class LanStr: GAME_NOT_RUNNING = "Not Launched" # errors LOCAL_MODEL_ERROR = "Local Model Loading Error!" + OT2_MODEL_ERROR = "OT2 Model Loading Error!" MITM_SERVER_ERROR = "MITM Service Error!" MITM_CERT_NOT_INSTALLED = "Run as admin or manually install MITM cert." MAIN_THREAD_ERROR = "Main Thread Error!" @@ -183,6 +186,8 @@ class LanStrZHS(LanStr): AI_MODEL_FILE_3P = "本地模型文件(三麻)" AKAGI_OT_URL = "AkagiOT 服务器地址" AKAGI_OT_APIKEY = "AkagiOT API Key" + AKAGI_OT2_URL = "AkagiOT2 服务器地址" + AKAGI_OT2_APIKEY = "AkagiOT2 API Key" MJAPI_URL = "MJAPI 服务器地址" MJAPI_USER = "MJAPI 用户名" MJAPI_USAGE = "API 用量" @@ -224,6 +229,7 @@ class LanStrZHS(LanStr): GAME_NOT_RUNNING = "未启动" #error LOCAL_MODEL_ERROR = "本地模型加载错误!" + OT2_MODEL_ERROR = "OT2 模型加载错误!" MITM_CERT_NOT_INSTALLED = "以管理员运行或手动安装 MITM 证书" MITM_SERVER_ERROR = "MITM 服务错误!" MAIN_THREAD_ERROR = "主进程发生错误!" diff --git a/common/settings.py b/common/settings.py index 397a277..25644d8 100644 --- a/common/settings.py +++ b/common/settings.py @@ -7,6 +7,7 @@ from . import utils DEFAULT_SETTING_FILE = 'settings.json' +DEFAULT_OT2_JSON = 'settings_ot2.json' class Settings: """ Settings class to load and save settings to json file""" @@ -33,13 +34,16 @@ def __init__(self, json_file:str=DEFAULT_SETTING_FILE) -> None: # AI Model settings self.model_type:str = self._get_value("model_type", "Local") - """ model type: local, mjapi""" + """ model type: local, mjapi, AkagiOT, AkagiOT2""" # for local model self.model_file:str = self._get_value("model_file", "mortal.pth") self.model_file_3p:str = self._get_value("model_file_3p", "mortal_3p.pth") # akagi ot model self.akagi_ot_url:str = self._get_value("akagi_ot_url", "") self.akagi_ot_apikey:str = self._get_value("akagi_ot_apikey", "") + # akagi ot2 3p model + self.akagi_ot2_url:str = self._get_value("akagi_ot2_url", "") + self.akagi_ot2_apikey: str = self._get_value("akagi_ot2_apikey", "") # for mjapi self.mjapi_url:str = self._get_value("mjapi_url", "https://mjai.7xcnnw11phu.eu.org", self.valid_url) self.mjapi_user:str = self._get_value("mjapi_user", "") @@ -86,6 +90,14 @@ def save_json(self): if not key.startswith('_') and not callable(value)} with open(self._json_file, 'w', encoding='utf-8') as file: json.dump(settings_to_save, file, indent=4, separators=(', ', ': ')) + + # Save ot2 related settings to settings_ot2.json + ot2_settings = { + "ip": self.akagi_ot2_url, + "key": self.akagi_ot2_apikey + } + with open(DEFAULT_OT2_JSON, 'w', encoding='utf-8') as ot2_file: + json.dump(ot2_settings, ot2_file, indent=4, separators=(', ', ': ')) def _get_value(self, key:str, default_value:any, validator:Callable[[any],bool]=None) -> any: """ Get value from settings dictionary, or return default_value if error""" diff --git a/common/utils.py b/common/utils.py index 39b2190..6e57a93 100644 --- a/common/utils.py +++ b/common/utils.py @@ -64,10 +64,13 @@ class UiState(Enum): GAME_ENDING = 20 -# === Exceptions === +# === Exceptions === class LocalModelException(Exception): """ Exception for model file error""" +class Ot2BotCreationError(Exception): + """ Exception for OT2 bot creation error""" + class MITMException(Exception): """ Exception for MITM error""" @@ -84,6 +87,8 @@ def error_to_str(error:Exception, lan:LanStr) -> str: """ Convert error to language specific string""" if isinstance(error, LocalModelException): return lan.LOCAL_MODEL_ERROR + elif isinstance(error, Ot2BotCreationError): + return lan.OT2_MODEL_ERROR elif isinstance(error, MitmCertNotInstalled): return lan.MITM_CERT_NOT_INSTALLED + f"{error.args}" elif isinstance(error, MITMException): diff --git a/gui/settings_window.py b/gui/settings_window.py index d63a81a..190cdc4 100644 --- a/gui/settings_window.py +++ b/gui/settings_window.py @@ -16,8 +16,8 @@ def __init__(self, parent:tk.Frame, setting:Settings): super().__init__(parent) self.st = setting - self.geometry('700x675') - self.minsize(700,675) + self.geometry('800x800') + self.minsize(800,800) # self.resizable(False, False) # set position: within main window parent_x = parent.winfo_x() @@ -151,7 +151,22 @@ def create_widgets(self): _label.grid(row=cur_row, column=0, **args_label) self.akagiot_apikey_var = tk.StringVar(value=self.st.akagi_ot_apikey) string_entry = ttk.Entry(main_frame, textvariable=self.akagiot_apikey_var, width=std_wid*4) - string_entry.grid(row=cur_row, column=1,columnspan=3, **args_entry) + string_entry.grid(row=cur_row, column=1,columnspan=3, **args_entry) + + # Akagi OT2 url + cur_row += 1 + _label = ttk.Label(main_frame, text=self.st.lan().AKAGI_OT2_URL) + _label.grid(row=cur_row, column=0, **args_label) + self.akagiot2_url_var = tk.StringVar(value=self.st.akagi_ot2_url) + string_entry = ttk.Entry(main_frame, textvariable=self.akagiot2_url_var, width=std_wid*4) + string_entry.grid(row=cur_row, column=1,columnspan=3, **args_entry) + # Akagi OT2 API Key + cur_row += 1 + _label = ttk.Label(main_frame, text=self.st.lan().AKAGI_OT2_APIKEY) + _label.grid(row=cur_row, column=0, **args_label) + self.akagiot2_apikey_var = tk.StringVar(value=self.st.akagi_ot2_apikey) + string_entry = ttk.Entry(main_frame, textvariable=self.akagiot2_apikey_var, width=std_wid*4) + string_entry.grid(row=cur_row, column=1,columnspan=3, **args_entry) # MJAPI url cur_row += 1 @@ -292,6 +307,8 @@ def _on_save(self): mode_file_3p_new = self.model_file_3p_var.get() akagi_url_new = self.akagiot_url_var.get() akagi_apikey_new = self.akagiot_apikey_var.get() + akagi_url2_new = self.akagiot2_url_var.get() + akagi_apikey2_new = self.akagiot2_apikey_var.get() mjapi_url_new = self.mjapi_url_var.get() mjapi_user_new = self.mjapi_user_var.get() mjapi_secret_new = self.mjapi_secret_var.get() @@ -302,6 +319,8 @@ def _on_save(self): self.st.model_file_3p != mode_file_3p_new or self.st.akagi_ot_url != akagi_url_new or self.st.akagi_ot_apikey != akagi_apikey_new or + self.st.akagi_ot2_url != akagi_url2_new or + self.st.akagi_ot2_apikey != akagi_apikey2_new or self.st.mjapi_url != mjapi_url_new or self.st.mjapi_user != mjapi_user_new or self.st.mjapi_secret != mjapi_secret_new or @@ -337,6 +356,8 @@ def _on_save(self): self.st.model_file_3p = mode_file_3p_new self.st.akagi_ot_url = akagi_url_new self.st.akagi_ot_apikey = akagi_apikey_new + self.st.akagi_ot2_url = akagi_url2_new + self.st.akagi_ot2_apikey = akagi_apikey2_new self.st.mjapi_url = mjapi_url_new self.st.mjapi_user = mjapi_user_new self.st.mjapi_secret = mjapi_secret_new diff --git a/mjai/bot_3p/Put_model.pth_here b/mjai/bot_3p/Put_model.pth_here new file mode 100644 index 0000000..c65a16a --- /dev/null +++ b/mjai/bot_3p/Put_model.pth_here @@ -0,0 +1 @@ +Get model.pth from the official Akagi discord channel. \ No newline at end of file diff --git a/readme.md b/readme.md index 590164c..62a5ac0 100644 --- a/readme.md +++ b/readme.md @@ -59,9 +59,48 @@ python main.py ``` ### 配置模型 本程序支持几种模型来源。其中,本地模型(Local)是基于 Akagi 兼容的 Mortal 模型。要获取 Akagi 的模型,请参见 Akagi Github 的说明。 + +#### 使用 Akagi OT2模型 +如需使用 AkagiOT2 模型,请前往 Akagi 的官方 Discord 频道获取对应的 model.pth 和 riichi3p 包并安装。 + +第一步:下载模型文件 model.pth 和 riichi3p 包 + +第二步:将 model.pth 放置到目录 `mjai/bot_3p/` 下 + +第三步:安装 riichi3p 包 +```bash +pip install riichi3p-${version}.whl +``` +此处安装对应 python 版本和操作系统的 riichi3p 包。 + +第四步:运行 main.py,打开设置,在模型类型中选择 AkagiOT2 模型。 + +第五步:设置 OT2 的 url地址 和 api_key,并保存。 + +第六步:等待 OT2 模型加载完成,即可开始游戏。 + ### Model Configuration This program supports different types of AI models. The 'Local' Model type uses Mortal models compatible with Akagi. To acquire Akagi's models, please refer to Akagi Github . +To use the AkagiOT2 model, please visit Akagi's official Discord channel to obtain the corresponding model.pth and riichi3p package, and then install them. + +#### Using the Akagi OT2 Model +To use the AkagiOT2 model, please visit the official Akagi Discord channel to obtain the corresponding `model.pth` file and `riichi3p` package, and install them. + +Step 1: Download the model file `model.pth` and the `riichi3p` package. + +Step 2: Place the `model.pth` file in the directory `mjai/bot_3p/`. + +Step 3: Install the `riichi3p` package: +```bash +pip install riichi3p-${version}.whl +``` +Install the `riichi3p` package appropriate for your Python version and operating system here. + +Step 4: Run `main.py`, open the settings, and select the AkagiOT2 model under model type. + +Step 5: Set the URL and API key for OT2 and save. +Step 6: Wait for the OT2 model to load completely, then you can start the game. ## 截图 / Screenshots