Skip to content

新增了对 AkagiOT2 三麻模型支持的实现 #51

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 27 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
a344924
Update readme.md
latorc May 2, 2024
c48dd7f
加载 AkagiOT2模型的建议实现
diverpet May 26, 2024
30c1c63
Merge branch 'main' into dev
diverpet May 26, 2024
743d9e4
Fix typo in setting_windows
diverpet May 26, 2024
e285794
Revert "Update readme.md"
diverpet May 27, 2024
51be7b8
配置 OT2 模型时检测可用性
diverpet May 27, 2024
91f535b
用分离进程解决 rust panick导致崩溃的问题
diverpet May 27, 2024
ae11582
降低超时时间到 3 秒
diverpet May 27, 2024
1a86918
删除多余注释
diverpet May 27, 2024
b37235d
添加BotAkagiOt2类
diverpet May 28, 2024
829e53c
添加 OT2 加载异常类
diverpet May 28, 2024
dbf6242
设置页去除 OT2 enable 选项
diverpet May 28, 2024
604cbc1
添加 OT2 加载错误提示
diverpet May 28, 2024
70ad864
Revert "删除多余注释"
diverpet May 28, 2024
51e000f
Revert "降低超时时间到 3 秒"
diverpet May 28, 2024
c00392f
Revert "用分离进程解决 rust panick导致崩溃的问题"
diverpet May 28, 2024
b2ca49b
解决冲突,回退 localbot的修改
diverpet May 28, 2024
b8c7181
Merge pull request #1 from diverpet/dev
diverpet May 28, 2024
b20c6a0
忽略 model.pth
diverpet May 28, 2024
e6d4afd
还原 bot.py 逻辑
diverpet May 28, 2024
6976c9a
Merge pull request #2 from diverpet/dev
diverpet May 28, 2024
3ad14de
实现 OT2 模型在线状态展示
diverpet May 29, 2024
274e4be
Merge pull request #4 from diverpet/dev
diverpet May 29, 2024
119f300
Merge pull request #5 from latorc/dev
diverpet May 29, 2024
40199c3
Update readme.md
diverpet May 29, 2024
7095fb1
Merge branch 'main' into dev
diverpet May 29, 2024
1154c68
bot 加载验证的超时时间宽限至 10 秒,打印超时错误。修复未尝试加载的 bug
diverpet Jun 1, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,4 @@ MahjongCopilot.spec
libriichi3p/*.pyd
libriichi3p/*.so
chrome_ext/*/
mjai/bot_3p/model.pth
3 changes: 2 additions & 1 deletion bot/akagiot/bot_akagiot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
105 changes: 105 additions & 0 deletions bot/akagiot2/bot_akagiot2.py
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions bot/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
6 changes: 5 additions & 1 deletion bot/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 _:
Expand Down
1 change: 1 addition & 0 deletions bot/local/bot_local.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
1 change: 1 addition & 0 deletions bot/mjapi/bot_mjapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
9 changes: 9 additions & 0 deletions bot_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions common/lan_str.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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!"
Expand Down Expand Up @@ -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 用量"
Expand Down Expand Up @@ -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 = "主进程发生错误!"
Expand Down
14 changes: 13 additions & 1 deletion common/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
Expand All @@ -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", "")
Expand Down Expand Up @@ -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"""
Expand Down
7 changes: 6 additions & 1 deletion common/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""

Expand All @@ -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):
Expand Down
27 changes: 24 additions & 3 deletions gui/settings_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions mjai/bot_3p/Put_model.pth_here
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Get model.pth from the official Akagi discord channel.
39 changes: 39 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,48 @@ python main.py
```
### 配置模型
本程序支持几种模型来源。其中,本地模型(Local)是基于 Akagi 兼容的 Mortal 模型。要获取 Akagi 的模型,请参见 <a href="https://github.com/shinkuan/Akagi" target="_blank"> Akagi Github </a> 的说明。

#### 使用 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 <a href="https://github.com/shinkuan/Akagi" target="_blank"> Akagi Github </a>.
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

Expand Down