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