diff --git a/.gitignore b/.gitignore index 7e2a35c..c454716 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ /mitm_playwright.bat /playwright_test.py /unlocker_test.py +/unlocker_passthrough.py /objects /players/bot /players/docker @@ -28,4 +29,5 @@ /config.json /mjai/bot/mortal.pth /mjai/bot/libriichi.* -/mjai/bot_3p \ No newline at end of file +/mjai/bot_3p +.DS_Store \ No newline at end of file diff --git a/README.md b/README.md index f48b143..8b9eafe 100644 --- a/README.md +++ b/README.md @@ -40,11 +40,13 @@ https://github.com/shinkuan/RandomStuff/assets/35415788/ce1b598d-b1d7-49fe-a175- [YouTube Video for you to follow.](https://youtu.be/V7NMNsZ3Ut8) ### You will need: + 1. A `mortal.pth`. [(Get one from Discord server if you don't have one.)](https://discord.gg/Z2wjXUK8bN) 2. (Optional, Recommend) Use Windows Terminal to open client.py for a nice looking TUI. 3. (Optional) If you want to use Steam, Majsoul Plus, or anything other client, proxy the client using tools like proxifier. -__Get mortal.pth at [Discord](https://discord.gg/Z2wjXUK8bN)__ +**Get mortal.pth at [Discord](https://discord.gg/Z2wjXUK8bN)** + 1. Go to #verify and click the ✅ reaction. 2. Go to #bot-zip 3. Download a bot you like. @@ -53,6 +55,8 @@ __Get mortal.pth at [Discord](https://discord.gg/Z2wjXUK8bN)__ ### Akagi: +#### Windows + Download `install_akagi.ps1` at [Release](https://github.com/shinkuan/Akagi/releases/latest) 1. Put `install_akagi.ps1` at the location you want to install Akagi. @@ -66,22 +70,36 @@ Download `install_akagi.ps1` at [Release](https://github.com/shinkuan/Akagi/rele 9. Install the certificate. 10. Put `mortal.pth` into `./Akagi/mjai/bot` +#### Mac + +Download `install_akagi.command` from [Release](https://github.com/shinkuan/Akagi/releases/latest) + +1. Place `install_akagi.command` in the location where you want to install Akagi. +2. Download the latest Python installation package from the [Python official website](https://www.python.org/downloads/) and install it (skip this step if you already have a compatible version of Python installed). +3. Double-click `install_akagi.command` to automatically install the required dependencies. +4. Double-click `run_agaki.command` to start Akagi. +5. If you are using mitmproxy for the first time, click on "start mitm". +6. Close it. +7. Go to your user home directory `~/.mitmproxy`. +8. Install the certificate. +9. Put `mortal.pth` into `./Akagi/mjai/bot`. + ### settings.json - - `Unlocker`: Decide to use [MajsoulUnlocker](https://github.com/shinkuan/MajsoulUnlocker) or not. - - `v10`: If your Majsoul client in still in v0.10.x and you want to use MajsoulUnlocker, set it to true. - - `Autoplay`: Autoplay. - - `Helper`: To use [mahjong-helper](https://github.com/EndlessCheng/mahjong-helper) or not - - `Autohu`: Auto Ron. - - `Port`: - - `MITM`: The MITM Port, you should redirect Majsoul connection to this port. - - `XMLRPC`: The XMLRPC Port. - - `MJAI`: The port bind to MJAI bot container. - - `Playwright`: - - `enable`: Enable the playwright - - `width`: width of the viewport of playwright - - `height`: height of the viewport of playwright - - The rest are the setting for MajsoulUnlocker. +- `Unlocker`: Decide to use [MajsoulUnlocker](https://github.com/shinkuan/MajsoulUnlocker) or not. +- `v10`: If your Majsoul client in still in v0.10.x and you want to use MajsoulUnlocker, set it to true. +- `Autoplay`: Autoplay. +- `Helper`: To use [mahjong-helper](https://github.com/EndlessCheng/mahjong-helper) or not +- `Autohu`: Auto Ron. +- `Port`: + - `MITM`: The MITM Port, you should redirect Majsoul connection to this port. + - `XMLRPC`: The XMLRPC Port. + - `MJAI`: The port bind to MJAI bot container. +- `Playwright`: + - `enable`: Enable the playwright + - `width`: width of the viewport of playwright + - `height`: height of the viewport of playwright +- The rest are the setting for MajsoulUnlocker. ## Instructions @@ -105,7 +123,7 @@ On top right is the MJAI Messages, this is the message our bot sent back to us, Then below is our tehai, it is composed using unicode characters. -Bottom left is the settings. +Bottom left is the settings. Bottom right is the bot's action. @@ -124,23 +142,24 @@ Following guide can minimum the probility of account suspension. ### There is no way to guarantee 100% no account suspension currently. # TODO - - [x] 3 Player Mahjong - - Already done, but not planned to release yet. - - [x] Change Setting inside application. - - [x] Autoplay - - [ ] Auto use stickers to make opponent think we are not a bot. - - [ ] Add random time in settings.json to let user choose time they want. - - [ ] Mix multiple AI's decision to make we more like a human but not a perfect bot. - - [x] Reduce Startup time of the bot. (Maybe start it before match begin?) - - [x] Intergrade with [MajsoulUnlocker](https://github.com/shinkuan/MajsoulUnlocker) - - [ ] Don't use MITM at all for the gameplay, use image recognition. - - [ ] Decide use what model - - [ ] Training data generation - - [ ] Train it - - [ ] Delta Score Recognition. - - [ ] Ryukyoku Recognition. - - [ ] Implement - - [x] Easier installation. + +- [x] 3 Player Mahjong + - Already done, but not planned to release yet. +- [x] Change Setting inside application. +- [x] Autoplay + - [ ] Auto use stickers to make opponent think we are not a bot. + - [ ] Add random time in settings.json to let user choose time they want. +- [ ] Mix multiple AI's decision to make we more like a human but not a perfect bot. +- [x] Reduce Startup time of the bot. (Maybe start it before match begin?) +- [x] Intergrade with [MajsoulUnlocker](https://github.com/shinkuan/MajsoulUnlocker) +- [ ] Don't use MITM at all for the gameplay, use image recognition. + - [ ] Decide use what model + - [ ] Training data generation + - [ ] Train it + - [ ] Delta Score Recognition. + - [ ] Ryukyoku Recognition. + - [ ] Implement +- [x] Easier installation. ## Need Help! @@ -152,7 +171,7 @@ Following guide can minimum the probility of account suspension. # Authors -* **Shinkuan** - [Shinkuan](https://github.com/shinkuan/) +- **Shinkuan** - [Shinkuan](https://github.com/shinkuan/) ## Support me @@ -166,7 +185,7 @@ You can find me at [Discord](https://discord.gg/Z2wjXUK8bN). ### What can I get after donating? -Firstly, thank you very much for your willingness to support the author. +Firstly, thank you very much for your willingness to support the author. I will prioritize the opinions of donors, such as feature requests and bug fixes. @@ -211,4 +230,4 @@ Software: Akagi License: GNU Affero General Public License version 3 with Commons Clause Licensor: shinkuan -``` \ No newline at end of file +``` diff --git a/README_CH.md b/README_CH.md index 555ea45..112d7ac 100644 --- a/README_CH.md +++ b/README_CH.md @@ -35,50 +35,68 @@ https://github.com/shinkuan/RandomStuff/assets/35415788/ce1b598d-b1d7-49fe-a175- ### 安裝 -[點我到Youtube觀看安裝影片](https://youtu.be/V7NMNsZ3Ut8) +[點我到 Youtube 觀看安裝影片](https://youtu.be/V7NMNsZ3Ut8) 在開始前,你需要以下東西: -1. `mortal.pth` [(如果你沒有的話,到Discord去下載)](https://discord.gg/Z2wjXUK8bN) -2. (Optional) 推薦使用Windows Terminal,以獲得預期中的UI效果。 -3. (Optional) 如果你要使用Steam或Majsoul Plus之類的,請使用類似Proxifier的軟體將連線導向至MITM -__到[Discord](https://discord.gg/Z2wjXUK8bN)下載我提供的mortal.pth__ +1. `mortal.pth` [(如果你沒有的話,到 Discord 去下載)](https://discord.gg/Z2wjXUK8bN) +2. (Optional) 推薦使用 Windows Terminal,以獲得預期中的 UI 效果。 +3. (Optional) 如果你要使用 Steam 或 Majsoul Plus 之類的,請使用類似 Proxifier 的軟體將連線導向至 MITM + +**到[Discord](https://discord.gg/Z2wjXUK8bN)下載我提供的 mortal.pth** + 1. 到 #verify 頻道點擊 ✅ 驗證身分. 2. 到 #bot-zip -3. 選一個你喜歡的bot下載 +3. 選一個你喜歡的 bot 下載 4. 解壓縮 ### Akagi: -到[Release](https://github.com/shinkuan/Akagi/releases/latest)下載`install_akagi.ps1` +#### Windows -1. 將 `install_akagi.ps1` 放在您想安裝Akagi的位置。 +到[Release](https://github.com/shinkuan/Akagi/releases/latest)下載`install_akagi.ps1` + +1. 將 `install_akagi.ps1` 放在您想安裝 Akagi 的位置。 2. 以**管理員**身份打開 **Powershell** 3. cd 到該目錄 4. 執行: `Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass` 5. 執行: `install_akagi.ps1` -6. 如果這是您第一次使用mitmproxy,請打開它。 +6. 如果這是您第一次使用 mitmproxy,請打開它。 7. 關閉它。 8. 到使用者主目錄 `~/.mitmproxy` 9. 安裝證書。 10. 將 `mortal.pth` 放入 `./Akagi/mjai/bot` +#### Mac + +到[Release](https://github.com/shinkuan/Akagi/releases/latest)下載`install_akagi.command` + +1. 将 `install_akagi.command` 放在您想安裝 Akagi 的位置。 +2. 从[Python 官方网站](https://www.python.org/downloads/)下载最新的 Python 安装包并安装(如已安装其他兼容版本的 Python 可以跳过这一步)。 +3. 双击`install_akagi.command`自动安装所需的依赖项。 +4. 双击run_agaki.command启动agaki。 +5. 如果您是第一次使用mitmproxy,请点击start mitm。 +5. 关闭它。 +6. 到使用者主目录 `~/.mitmproxy` +7. 安装证书。 +8. 将 `mortal.pth` 放入 `./Akagi/mjai/bot` + ### settings.json - - `Unlocker`: 使用 [MajsoulUnlocker](https://github.com/shinkuan/MajsoulUnlocker) - - `v10`: 如果你的客戶端還在v0.10.x版本而且你想要使用MajsoulUnlocker,設為true - - `Autoplay`: 自動打牌. - - `Helper`: [mahjong-helper](https://github.com/EndlessCheng/mahjong-helper) - - `Autohu`: Auto Ron. - - `Port`: - - `MITM`: MITM Port, 你應該將雀魂連線導向到這個Port. - - `XMLRPC`: The XMLRPC Port. - - `MJAI`: The port bind to MJAI bot container. - - `Playwright`: - - `enable`: Enable the playwright - - `width`: width of the viewport of playwright - - `height`: height of the viewport of playwright - - The rest are the setting for MajsoulUnlocker. +- `Unlocker`: 使用 [MajsoulUnlocker](https://github.com/shinkuan/MajsoulUnlocker) +- `v10`: 如果你的客戶端還在 v0.10.x 版本而且你想要使用 MajsoulUnlocker,設為 true +- `Autoplay`: 自動打牌. +- `Helper`: [mahjong-helper](https://github.com/EndlessCheng/mahjong-helper) +- `Autohu`: Auto Ron. +- `Port`: + - `MITM`: MITM Port, 你應該將雀魂連線導向到這個 Port. + - `XMLRPC`: The XMLRPC Port. + - `MJAI`: The port bind to MJAI bot container. +- `Playwright`: + - `enable`: Enable the playwright + - `width`: width of the viewport of playwright + - `height`: height of the viewport of playwright +- The rest are the setting for MajsoulUnlocker. ## 如何使用 @@ -86,7 +104,7 @@ __到[Discord](https://discord.gg/Z2wjXUK8bN)下載我提供的mortal.pth__ ![image](https://github.com/shinkuan/RandomStuff/assets/35415788/6b66a48b-48fe-4e12-b3cc-18b582410f9a) -可以看到這裡有兩個流程,通常上面的是「大廳」的Websocket流程,而下面的是「遊戲」的Websocket流程,這個會在加入對局後出現。 +可以看到這裡有兩個流程,通常上面的是「大廳」的 Websocket 流程,而下面的是「遊戲」的 Websocket 流程,這個會在加入對局後出現。 點擊下方的流程以開始。(這可能需要一些時間,點擊一次並等待,不要多次點擊) @@ -95,12 +113,12 @@ __到[Discord](https://discord.gg/Z2wjXUK8bN)下載我提供的mortal.pth__ ![image](https://github.com/shinkuan/RandomStuff/assets/35415788/17ae8275-4499-4788-a91b-ecafbac33512) 在進入遊戲流程畫面後,應該會看到這些內容。 -左上角是我們使用MITM捕獲的LiqiProto訊息。 -LiqiProto訊息隨後被轉錄為mjai格式並發送給機器人。 +左上角是我們使用 MITM 捕獲的 LiqiProto 訊息。 +LiqiProto 訊息隨後被轉錄為 mjai 格式並發送給機器人。 -右上角是MJAI訊息,這是我們的機器人發回給我們的訊息,指示我們應該採取的動作。 +右上角是 MJAI 訊息,這是我們的機器人發回給我們的訊息,指示我們應該採取的動作。 -然後下方是我們的手牌,它是使用Unicode字符組成的。 +然後下方是我們的手牌,它是使用 Unicode 字符組成的。 左下角是設置。 @@ -112,46 +130,47 @@ LiqiProto訊息隨後被轉錄為mjai格式並發送給機器人。 以下是一些你可以採取的措施。 -1. 不要使用Steam版,因為它可能會監測你電腦上正在執行的程式。改用web版。 +1. 不要使用 Steam 版,因為它可能會監測你電腦上正在執行的程式。改用 web 版。 2. 使用[Majsoul Mod Plus](https://github.com/Avenshy/majsoul_mod_plus)的`safe_code.js`。 -3. 不要開MajsoulUnlocker,因為它會竄改websocket數據。 -4. 乖乖手打,不要使用Autoplay +3. 不要開 MajsoulUnlocker,因為它會竄改 websocket 數據。 +4. 乖乖手打,不要使用 Autoplay 5. 使用貼圖與你的對手交流。 6. 不要完全照著機器人的指示打牌 -7. 不要使用Autoplay功能掛機24h打牌。 +7. 不要使用 Autoplay 功能掛機 24h 打牌。 ### 目前沒有任何辦法保證完全不封號。 # TODO - - [x] 三麻模式 - - 已完成,但尚未決定公布。 - - [x] 在應用程式內更改Setting。 - - [x] 自動打牌 - - [ ] 自動使用貼圖,讓對手認為我們不是機器人。 - - [ ] 在settings.json中添加隨機時間,讓用戶選擇他們想要的時間。 - - [ ] 混合多個AI的決策,讓我們看起來更像人類,而不是完美的機器人。 - - [x] 縮短機器人的啟動時間。(也許在遊戲開始前就啟動?) - - [x] 與[MajsoulUnlocker](https://github.com/shinkuan/MajsoulUnlocker)整合 - - [ ] 完全不使用MITM進行遊戲,使用圖像識別。 - - [ ] 決定使用哪種模型 - - [ ] 訓練數據生成 - - [ ] 進行訓練 - - [ ] 得分差異識別。 - - [ ] 流局識別。 - - [ ] 實施 - - [x] 更簡單的安裝流程 + +- [x] 三麻模式 + - 已完成,但尚未決定公布。 +- [x] 在應用程式內更改 Setting。 +- [x] 自動打牌 + - [ ] 自動使用貼圖,讓對手認為我們不是機器人。 + - [ ] 在 settings.json 中添加隨機時間,讓用戶選擇他們想要的時間。 +- [ ] 混合多個 AI 的決策,讓我們看起來更像人類,而不是完美的機器人。 +- [x] 縮短機器人的啟動時間。(也許在遊戲開始前就啟動?) +- [x] 與[MajsoulUnlocker](https://github.com/shinkuan/MajsoulUnlocker)整合 +- [ ] 完全不使用 MITM 進行遊戲,使用圖像識別。 + - [ ] 決定使用哪種模型 + - [ ] 訓練數據生成 + - [ ] 進行訓練 + - [ ] 得分差異識別。 + - [ ] 流局識別。 + - [ ] 實施 +- [x] 更簡單的安裝流程 ## Need Help! -1. 歡迎任何人提交PR(Pull Request)。 -2. 如果你有使用MajsoulUnlocker,請告訴我它運行得是否順暢,有沒有關於我們修改訊息的痕跡洩露給Majsoul伺服器? +1. 歡迎任何人提交 PR(Pull Request)。 +2. 如果你有使用 MajsoulUnlocker,請告訴我它運行得是否順暢,有沒有關於我們修改訊息的痕跡洩露給 Majsoul 伺服器? 3. 尋找一種穩定且安全的自動打牌方式。 4. 如果遇到任何錯誤,請回報。 -5. 如果你的機器人很好用,可以考慮分享你的bot.zip檔案。 +5. 如果你的機器人很好用,可以考慮分享你的 bot.zip 檔案。 # Authors -* **Shinkuan** - [Shinkuan](https://github.com/shinkuan/) +- **Shinkuan** - [Shinkuan](https://github.com/shinkuan/) ## 支持作者 @@ -159,7 +178,7 @@ LiqiProto訊息隨後被轉錄為mjai格式並發送給機器人。 ETH Mainnet: 0x83095C4355E43bDFe9cEf2e439F371900664D41F -Paypal或其他: 到Discord找我 +Paypal 或其他: 到 Discord 找我 You can find me at [Discord](https://discord.gg/Z2wjXUK8bN). @@ -205,4 +224,4 @@ Software: Akagi License: GNU Affero General Public License version 3 with Commons Clause Licensor: shinkuan -``` \ No newline at end of file +``` diff --git a/client.py b/client.py index 91d1064..2e37328 100644 --- a/client.py +++ b/client.py @@ -1,35 +1,34 @@ import atexit +import json import os -from pathlib import Path -import time -os.environ["LOGURU_AUTOINIT"] = "False" - import pathlib +import subprocess +import sys +import time import webbrowser +from pathlib import Path from sys import executable -from subprocess import Popen, CREATE_NEW_CONSOLE - +from threading import Thread from typing import Any, Coroutine from xmlrpc.client import ServerProxy -import json -from loguru import logger -from textual import on +from my_logger import logger, game_result_log +from textual import on from textual.app import App, ComposeResult -from textual.containers import ScrollableContainer, Horizontal, Vertical -from textual.events import Event, ScreenResume -from textual.widgets import Button, Footer, Header, Static, Log, Pretty, Label, Rule, LoadingIndicator, Checkbox, Input, Markdown +from textual.containers import Horizontal, ScrollableContainer, Vertical from textual.css.query import NoMatches +from textual.events import Event, ScreenResume from textual.screen import Screen +from textual.widgets import (Button, Checkbox, Footer, Header, Input, Label, + LoadingIndicator, Log, Markdown, Pretty, Rule, + Static) +from action import Action from liqi import LiqiProto, MsgType -from mjai.player import MjaiPlayerClient from majsoul2mjai import MajsoulBridge -from tileUnicode import TILE_2_UNICODE_ART_RICH, TILE_2_UNICODE, VERTICLE_RULE -from action import Action -from concurrent.futures import ThreadPoolExecutor -from threading import Thread -from playwright.sync_api import Playwright, sync_playwright +from libriichi_helper import meta_to_recommend, state_to_tehai +from tileUnicode import TILE_2_UNICODE_ART_RICH, TILE_2_UNICODE, VERTICLE_RULE, HAI_VALUE + submission = 'players/bot.zip' PORT_NUM = 28680 @@ -66,13 +65,17 @@ def compose(self) -> ComposeResult: liqi_log_container.border_title = "LiqiProto" mjai_log_container.border_title = "Mjai" tehai_labels = [Label(TILE_2_UNICODE_ART_RICH["?"], id="tehai_"+str(i)) for i in range(13)] + tehai_value_labels = [Label(HAI_VALUE[40], id="tehai_value_"+str(i)) for i in range(13)] tehai_rule = Label(VERTICLE_RULE, id="tehai_rule") tsumohai_label = Label(TILE_2_UNICODE_ART_RICH["?"], id="tsumohai") + tsumohai_value_label = Label(HAI_VALUE[40], id="tsumohai_value") tehai_container = Horizontal(id="tehai_container") - for tehai_label in tehai_labels: - tehai_container.mount(tehai_label) + for i in range(13): + tehai_container.mount(tehai_labels[i]) + tehai_container.mount(tehai_value_labels[i]) tehai_container.mount(tehai_rule) tehai_container.mount(tsumohai_label) + tehai_container.mount(tsumohai_value_label) tehai_container.border_title = "Tehai" akagi_action = Button("Akagi", id="akagi_action", variant="default") akagi_pai = Button("Pai", id="akagi_pai", variant="default") @@ -106,11 +109,13 @@ def on_mount(self) -> None: self.liqi_log_container = self.query_one("#liqi_log_container") self.mjai_log_container = self.query_one("#mjai_log_container") self.tehai_labels = [self.query_one("#tehai_"+str(i)) for i in range(13)] + self.tehai_value_labels = [self.query_one("#tehai_value_"+str(i)) for i in range(13)] self.tehai_rule = self.query_one("#tehai_rule") self.tsumohai_label = self.query_one("#tsumohai") + self.tsumohai_value_label = self.query_one("#tsumohai_value") self.tehai_container = self.query_one("#tehai_container") - self.liqi_log_container.scroll_end() - self.mjai_log_container.scroll_end() + self.liqi_log_container.scroll_end(animate=False) + self.mjai_log_container.scroll_end(animate=False) self.liqi_msg_idx = len(self.app.liqi_msg_dict[self.flow_id]) self.mjai_msg_idx = len(self.app.mjai_msg_dict[self.flow_id]) self.update_log = self.set_interval(0.10, self.refresh_log) @@ -130,11 +135,8 @@ def refresh_log(self) -> None: try: if self.liqi_msg_idx < len(self.app.liqi_msg_dict[self.flow_id]): self.liqi_log.update(self.app.liqi_msg_dict[self.flow_id][-1]) - self.liqi_log_container.scroll_end() + self.liqi_log_container.scroll_end(animate=False) self.liqi_msg_idx += 1 - for idx, tehai_label in enumerate(self.tehai_labels): - tehai_label.update(TILE_2_UNICODE_ART_RICH[self.app.bridge[self.flow_id].my_tehais[idx]]) - self.tsumohai_label.update(TILE_2_UNICODE_ART_RICH[self.app.bridge[self.flow_id].my_tsumohai]) liqi_msg = self.app.liqi_msg_dict[self.flow_id][-1] if liqi_msg['type'] == MsgType.Notify: if liqi_msg['method'] == '.lq.ActionPrototype': @@ -149,50 +151,80 @@ def refresh_log(self) -> None: self.action.reached = False if liqi_msg['method'] == '.lq.NotifyGameEndResult' or liqi_msg['method'] == '.lq.NotifyGameTerminate': self.action_quit() - + elif self.syncing: self.query_one("#loading_indicator").remove() self.syncing = False - if AUTOPLAY: + if AUTOPLAY and len(self.app.mjai_msg_dict[self.flow_id]) > 0: logger.log("CLICK", self.app.mjai_msg_dict[self.flow_id][-1]) self.app.set_timer(2, self.autoplay) if self.mjai_msg_idx < len(self.app.mjai_msg_dict[self.flow_id]): - self.mjai_log.update(self.app.mjai_msg_dict[self.flow_id]) - self.mjai_log_container.scroll_end() + self.app.mjai_msg_dict[self.flow_id][-1]['meta'] = meta_to_recommend(self.app.mjai_msg_dict[self.flow_id][-1]['meta']) + latest_mjai_msg = self.app.mjai_msg_dict[self.flow_id][-1] + # Update tehai + player_state = self.app.bridge[self.flow_id].mjai_client.bot.state() + tehai, tsumohai = state_to_tehai(player_state) + for idx, tehai_label in enumerate(self.tehai_labels): + tehai_label.update(TILE_2_UNICODE_ART_RICH[tehai[idx]]) + action_list = [x[0] for x in latest_mjai_msg['meta']] + for idx, tehai_value_label in enumerate(self.tehai_value_labels): + # latest_mjai_msg['meta'] is list of (pai, value) + try: + pai_value = int(latest_mjai_msg['meta'][action_list.index(tehai[idx])][1] * 40) + if pai_value == 40: + pai_value = 39 + except ValueError: + pai_value = 40 + tehai_value_label.update(HAI_VALUE[pai_value]) + self.tsumohai_label.update(TILE_2_UNICODE_ART_RICH[tsumohai]) + if tsumohai in action_list: + try: + pai_value = int(latest_mjai_msg['meta'][action_list.index(tsumohai)][1] * 40) + if pai_value == 40: + pai_value = 39 + except ValueError: + pai_value = 40 + self.tsumohai_value_label.update(HAI_VALUE[pai_value]) + # mjai log + self.mjai_log.update(self.app.mjai_msg_dict[self.flow_id][-3:]) + self.mjai_log_container.scroll_end(animate=False) self.mjai_msg_idx += 1 - self.akagi_action.label = self.app.mjai_msg_dict[self.flow_id][-1]["type"] + self.akagi_action.label = latest_mjai_msg["type"] for akagi_action_class in self.akagi_action.classes: self.akagi_action.remove_class(akagi_action_class) - self.akagi_action.add_class("action_"+self.app.mjai_msg_dict[self.flow_id][-1]["type"]) + self.akagi_action.add_class("action_"+latest_mjai_msg["type"]) for akagi_pai_class in self.akagi_pai.classes: self.akagi_pai.remove_class(akagi_pai_class) - self.akagi_pai.add_class("pai_"+self.app.mjai_msg_dict[self.flow_id][-1]["type"]) - if "consumed" in self.app.mjai_msg_dict[self.flow_id][-1]: - self.akagi_pai.label = str(self.app.mjai_msg_dict[self.flow_id][-1]["consumed"]) - if "pai" in self.app.mjai_msg_dict[self.flow_id][-1]: - self.pai_unicode_art.update(TILE_2_UNICODE_ART_RICH[self.app.mjai_msg_dict[self.flow_id][-1]["pai"]]) + self.akagi_pai.add_class("pai_"+latest_mjai_msg["type"]) + if "consumed" in latest_mjai_msg: + self.akagi_pai.label = str(latest_mjai_msg["consumed"]) + if "pai" in latest_mjai_msg: + self.pai_unicode_art.update(TILE_2_UNICODE_ART_RICH[latest_mjai_msg["pai"]]) self.akagi_container.mount(Label(VERTICLE_RULE, id="consumed_rule")) self.consume_ids.append("#"+"consumed_rule") i=0 - for c in self.app.mjai_msg_dict[self.flow_id][-1]["consumed"]: + for c in latest_mjai_msg["consumed"]: self.akagi_container.mount(Label(TILE_2_UNICODE_ART_RICH[c], id="consumed_"+c+str(i))) self.consume_ids.append("#"+"consumed_"+c+str(i)) i+=1 - elif "pai" in self.app.mjai_msg_dict[self.flow_id][-1]: + elif "pai" in latest_mjai_msg: for consume_id in self.consume_ids: self.query_one(consume_id).remove() self.consume_ids = [] - self.akagi_pai.label = str(self.app.mjai_msg_dict[self.flow_id][-1]["pai"]) - self.pai_unicode_art.update(TILE_2_UNICODE_ART_RICH[self.app.mjai_msg_dict[self.flow_id][-1]["pai"]]) + self.akagi_pai.label = str(latest_mjai_msg["pai"]) + self.pai_unicode_art.update(TILE_2_UNICODE_ART_RICH[latest_mjai_msg["pai"]]) else: self.akagi_pai.label = "None" self.pai_unicode_art.update(TILE_2_UNICODE_ART_RICH["?"]) # Action - logger.info(f"Current tehai: {self.app.bridge[self.flow_id].my_tehais}") - logger.info(f"Current tsumohai: {self.app.bridge[self.flow_id].my_tsumohai}") + logger.info(f"Current tehai: {tehai}") + logger.info(f"Current tsumohai: {tsumohai}") + self.tehai = tehai + self.tsumohai = tsumohai if not self.syncing and ENABLE_PLAYWRIGHT and AUTOPLAY: - logger.log("CLICK", self.app.mjai_msg_dict[self.flow_id][-1]) - self.app.set_timer(0.05, self.autoplay) + logger.log("CLICK", latest_mjai_msg) + self.app.set_timer(0.15, self.autoplay) + # self.autoplay(tehai, tsumohai) except Exception as e: logger.error(e) @@ -205,7 +237,7 @@ def checkbox_autoplay_changed(self, event: Checkbox.Changed) -> None: pass def autoplay(self) -> None: - self.action.mjai2action(self.app.mjai_msg_dict[self.flow_id][-1], self.app.bridge[self.flow_id].my_tehais, self.app.bridge[self.flow_id].my_tsumohai) + self.action.mjai2action(self.app.mjai_msg_dict[self.flow_id][-1], self.tehai, self.tsumohai) pass def action_quit(self) -> None: @@ -227,6 +259,7 @@ def on_button_pressed(self, event: Button.Pressed) -> None: self.app.push_screen(FlowScreen(self.flow_id)) self.app.update_flow.pause() + class HoverLink(Static): def __init__(self, text, url, *args, **kwargs) -> None: super().__init__(*args, **kwargs) @@ -240,6 +273,7 @@ def on_click(self, event): webbrowser.open_new_tab(self.url) pass + class SettingsScreen(Static): def __init__(self, *args, **kwargs) -> None: @@ -393,14 +427,13 @@ class Akagi(App): def __init__(self, rpc_server, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.rpc_server = rpc_server - self.liqi: dict[str, LiqiProto]={} - self.bridge: dict[str, MajsoulBridge]={} + self.liqi: dict[str, LiqiProto] = {} + self.bridge: dict[str, MajsoulBridge] = {} self.active_flows = [] - self.messages_dict = dict() # flow.id -> List[flow_msg] - self.liqi_msg_dict = dict() # flow.id -> List[liqi_msg] - self.mjai_msg_dict = dict() # flow.id -> List[mjai_msg] - self.akagi_log_dict= dict() # flow.id -> List[akagi_log] - self.loguru_log = [] # List[loguru_log] + self.messages_dict = dict() # flow.id -> List[flow_msg] + self.liqi_msg_dict = dict() # flow.id -> List[liqi_msg] + self.mjai_msg_dict = dict() # flow.id -> List[mjai_msg] + self.akagi_log_dict = dict() # flow.id -> List[akagi_log] self.mitm_started = False def on_mount(self) -> None: @@ -422,6 +455,8 @@ def refresh_flow(self) -> None: self.liqi_msg_dict.pop(flow_id) self.mjai_msg_dict.pop(flow_id) self.akagi_log_dict.pop(flow_id) + self.liqi.pop(flow_id) + self.bridge.pop(flow_id) for flow_id in flows: try: self.query_one("#FlowContainer") @@ -484,11 +519,11 @@ def on_button_pressed(self, event: Button.Pressed) -> None: pass def mitm_connected(self): - self.mitm_started = True - - def my_sink(self, message) -> None: - record = message.record - self.loguru_log.append(f"{record['time'].strftime('%H:%M:%S')} | {record['level'].name}\t | {record['message']}") + try: + self.rpc_server.ping() + self.mitm_started = True + except: + self.set_timer(2, self.mitm_connected) def action_quit(self) -> None: self.update_flow.stop() @@ -500,25 +535,32 @@ def exit_handler(): global mitm_exec try: mitm_exec.kill() + logger.info("Stop Akagi") except: pass pass + def start_mitm(): global mitm_exec - mitm_exec = Popen([executable, pathlib.Path(__file__).parent / "mitm.py"], creationflags=CREATE_NEW_CONSOLE) - pass + + command = [sys.executable, pathlib.Path(__file__).parent / "mitm.py"] + + if sys.platform == "win32": + # Windows特定代码 + mitm_exec = subprocess.Popen(command, creationflags=subprocess.CREATE_NEW_CONSOLE) + else: + # macOS和其他Unix-like系统 + mitm_exec = subprocess.Popen(command, preexec_fn=os.setsid) + if __name__ == '__main__': with open("settings.json", "r") as f: settings = json.load(f) - rpc_port = settings["Port"]["XMLRPC"] + rpc_port = settings["Port"]["XMLRPC"] rpc_host = "127.0.0.1" s = ServerProxy(f"http://{rpc_host}:{rpc_port}", allow_none=True) - logger.level("CLICK", no=10, icon="CLICK") - logger.add("akagi.log") app = Akagi(rpc_server=s) - logger.add(app.my_sink) atexit.register(exit_handler) try: logger.info("Start Akagi") @@ -526,4 +568,3 @@ def start_mitm(): except Exception as e: exit_handler() raise e - diff --git a/libriichi_helper.py b/libriichi_helper.py new file mode 100644 index 0000000..8e91d63 --- /dev/null +++ b/libriichi_helper.py @@ -0,0 +1,128 @@ +import numpy as np + +def meta_to_recommend(meta: dict) -> dict: + # """ + # { + # "q_values":[ + # -9.09196, + # -9.46696, + # -8.365397, + # -8.849772, + # -9.43571, + # -10.06071, + # -9.295085, + # -0.73649096, + # -9.27946, + # -9.357585, + # 0.3221028, + # -2.7794597 + # ], + # "mask_bits":2697207348, + # "is_greedy":true, + # "eval_time_ns":357088300 + # } + # """ + + recommend = [] + + mask_unicode = [ + "1m", "2m", "3m", "4m", "5m", "6m", "7m", "8m", "9m", + "1p", "2p", "3p", "4p", "5p", "6p", "7p", "8p", "9p", + "1s", "2s", "3s", "4s", "5s", "6s", "7s", "8s", "9s", + "E", "S", "W", "N", "P", "F", "C", + '5mr', '5pr', '5sr', + 'reach', 'chi_low', 'chi_mid', 'chi_high', 'pon', 'kan_select', 'hora', 'ryukyoku', 'none' + ] + + def mask_bits_to_binary_string(mask_bits): + binary_string = bin(mask_bits)[2:] + binary_string = binary_string.zfill(46) + return binary_string + + def mask_bits_to_bool_list(mask_bits): + binary_string = mask_bits_to_binary_string(mask_bits) + bool_list = [] + for bit in binary_string[::-1]: + bool_list.append(bit == '1') + return bool_list + + def eq(l, r): + # Check for approximate equality using numpy's floating-point epsilon + return np.abs(l - r) <= np.finfo(float).eps + + def softmax(arr, temperature=1.0): + arr = np.array(arr, dtype=float) # Ensure the input is a numpy array of floats + + if arr.size == 0: + return arr # Return the empty array if input is empty + + if not eq(temperature, 1.0): + arr /= temperature # Scale by temperature if temperature is not approximately 1 + + # Shift values by max for numerical stability + max_val = np.max(arr) + arr = arr - max_val + + # Apply the softmax transformation + exp_arr = np.exp(arr) + sum_exp = np.sum(exp_arr) + + softmax_arr = exp_arr / sum_exp + + return softmax_arr + + def scale_list(list): + scaled_list = softmax(list) + return scaled_list + q_values = meta['q_values'] + mask_bits = meta['mask_bits'] + mask = mask_bits_to_bool_list(mask_bits) + scaled_q_values = scale_list(q_values) + q_value_idx = 0 + + true_count = 0 + for i in range(46): + if mask[i]: + true_count += 1 + + for i in range(46): + if mask[i]: + recommend.append((mask_unicode[i], scaled_q_values[q_value_idx])) + q_value_idx += 1 + + recommend = sorted(recommend, key=lambda x: x[1], reverse=True) + return recommend + +def state_to_tehai(state) -> tuple[list[str], str]: + tehai34 = state.tehai # with tsumohai, no aka marked + akas = state.akas_in_hand + tsumohai = state.last_self_tsumo() + return _state_to_tehai(tehai34, akas, tsumohai) + +def _state_to_tehai(tile34: int, aka: list[bool], tsumohai: str|None) -> tuple[list[str], str]: + pai_str = [ + "1m", "2m", "3m", "4m", "5m", "6m", "7m", "8m", "9m", + "1p", "2p", "3p", "4p", "5p", "6p", "7p", "8p", "9p", + "1s", "2s", "3s", "4s", "5s", "6s", "7s", "8s", "9s", + "E", "S", "W", "N", "P", "F", "C", "?" + ] + aka_str = [ + "5mr", "5pr", "5sr" + ] + tile_list = [] + for tile_id, tile_count in enumerate(tile34): + for _ in range(tile_count): + tile_list.append(pai_str[tile_id]) + for idx, aka in enumerate(aka): + if aka: + tile_list[tile_list.index("5" + ["m", "p", "s"][idx])] = aka_str[idx] + if len(tile_list)%3 == 2 and tsumohai is not None: + tile_list.remove(tsumohai) + else: + tsumohai = "?" + len_tile_list = len(tile_list) + if len_tile_list < 13: + tile_list += ["?"]*(13-len_tile_list) + + return (tile_list, tsumohai) + \ No newline at end of file diff --git a/liqi.py b/liqi.py index 5694e44..8447c78 100644 --- a/liqi.py +++ b/liqi.py @@ -7,10 +7,7 @@ from google.protobuf.json_format import MessageToDict, ParseDict -try: - from .proto import liqi_pb2 as pb -except: - from proto import liqi_pb2 as pb +from liqi_proto import liqi_pb2 as pb from rich.console import Console console = Console() @@ -48,7 +45,7 @@ def __init__(self): self.tot = 0 self.res_type = dict() self.jsonProto = json.load( - open(os.path.join(os.path.dirname(__file__), 'proto/liqi.json'), 'r')) + open(os.path.join(os.path.dirname(__file__), 'liqi_proto/liqi.json'), 'r')) def init(self): self.msg_id = 1 diff --git a/proto/liqi.json b/liqi_proto/liqi.json similarity index 100% rename from proto/liqi.json rename to liqi_proto/liqi.json diff --git a/proto/liqi.proto b/liqi_proto/liqi.proto similarity index 100% rename from proto/liqi.proto rename to liqi_proto/liqi.proto diff --git a/proto/liqi_pb2.py b/liqi_proto/liqi_pb2.py similarity index 100% rename from proto/liqi_pb2.py rename to liqi_proto/liqi_pb2.py diff --git a/majsoul2mjai.py b/majsoul2mjai.py index b1e7c8f..9166063 100644 --- a/majsoul2mjai.py +++ b/majsoul2mjai.py @@ -6,7 +6,7 @@ from convert import MS_TILE_2_MJAI_TILE, MJAI_TILE_2_MS_TILE from liqi import LiqiProto from functools import cmp_to_key -from loguru import logger +from my_logger import logger, game_result_log class Operation: NoEffect = 0 @@ -47,6 +47,10 @@ def __init__(self) -> None: self.my_tsumohai = "?" self.syncing = False + self.mode_id = -1 + self.rank = -1 + self.score = -1 + self.mjai_client = MjaiPlayerClient() self.is_3p = False pass @@ -76,6 +80,10 @@ def input(self, parse_msg: dict) -> dict | None: self.accountId = parse_msg['data']['accountId'] if parse_msg['method'] == '.lq.FastTest.authGame' and parse_msg['type'] == MsgType.Res: self.is_3p = len(parse_msg['data']['seatList']) == 3 + try: + self.mode_id = parse_msg['data']['gameConfig']['meta']['modeId'] + except: + self.mode_id = -1 seatList = parse_msg['data']['seatList'] self.seat = seatList.index(self.accountId) @@ -106,9 +114,6 @@ def input(self, parse_msg: dict) -> dict | None: my_tehais = ['?']*13 for hai in range(13): my_tehais[hai] = MS_TILE_2_MJAI_TILE[parse_msg['data']['data']['tiles'][hai]] - self.my_tehais = my_tehais - self.my_tsumohai = "?" - self.my_tehais = sorted(self.my_tehais, key=cmp_to_key(compare_pai)) if len(parse_msg['data']['data']['tiles']) == 13: tehais[self.seat] = my_tehais self.mjai_message.append( @@ -210,14 +215,6 @@ def input(self, parse_msg: dict) -> dict | None: 'type': 'reach_accepted', 'actor': actor } - if actor == self.seat: - if self.my_tsumohai != "?": - self.my_tehais.append(self.my_tsumohai) - self.my_tsumohai = "?" - else: - self.my_tehais.append("?") - self.my_tehais.remove(pai) - self.my_tehais = sorted(self.my_tehais, key=cmp_to_key(compare_pai)) # Reach if parse_msg['data']['name'] == 'ActionReach': # TODO @@ -275,11 +272,6 @@ def input(self, parse_msg: dict) -> dict | None: pass case _: raise - if actor == self.seat: - for pai in consumed: - self.my_tehais.remove(pai) - self.my_tehais.append("?") - self.my_tehais = sorted(self.my_tehais, key=cmp_to_key(compare_pai)) # AnkanKakan if parse_msg['data']['name'] == 'ActionAnGangAddGang': actor = parse_msg['data']['data']['seat'] @@ -296,17 +288,6 @@ def input(self, parse_msg: dict) -> dict | None: 'consumed': consumed } ) - if actor == self.seat: - if self.my_tsumohai != "?": - self.my_tehais.append(self.my_tsumohai) - self.my_tsumohai = "?" - else: - self.my_tehais.append("?") - for pai in consumed: - self.my_tehais.remove(pai) - self.my_tehais.append("?") - self.my_tehais.remove("?") - self.my_tehais = sorted(self.my_tehais, key=cmp_to_key(compare_pai)) case OperationAnGangAddGang.AddGang: pai = MS_TILE_2_MJAI_TILE[parse_msg['data']['data']['tiles']] consumed = [pai.replace("r", "")] * 3 @@ -320,14 +301,6 @@ def input(self, parse_msg: dict) -> dict | None: 'consumed': consumed } ) - if actor == self.seat: - if self.my_tsumohai != "?": - self.my_tehais.append(self.my_tsumohai) - self.my_tsumohai = "?" - else: - self.my_tehais.append("?") - self.my_tehais.remove(pai) - self.my_tehais = sorted(self.my_tehais, key=cmp_to_key(compare_pai)) if parse_msg['data']['name'] == 'ActionBaBei': actor = parse_msg['data']['data']['seat'] @@ -338,14 +311,6 @@ def input(self, parse_msg: dict) -> dict | None: 'pai': 'N' } ) - if actor == self.seat: - if self.my_tsumohai != "?": - self.my_tehais.append(self.my_tsumohai) - self.my_tsumohai = "?" - else: - self.my_tehais.append("?") - self.my_tehais.remove("N") - self.my_tehais = sorted(self.my_tehais, key=cmp_to_key(compare_pai)) # hora if parse_msg['data']['name'] == 'ActionHule': @@ -368,8 +333,6 @@ def input(self, parse_msg: dict) -> dict | None: 'type': 'end_kyoku' } ) - self.my_tehais = ["?"]*13 - self.my_tsumohai = "?" self.react(self.mjai_client) return None # notile @@ -380,8 +343,6 @@ def input(self, parse_msg: dict) -> dict | None: 'type': 'end_kyoku' } ) - self.my_tehais = ["?"]*13 - self.my_tsumohai = "?" self.react(self.mjai_client) return None # ryukyoku @@ -397,8 +358,6 @@ def input(self, parse_msg: dict) -> dict | None: 'type': 'end_kyoku' } ) - self.my_tehais = ["?"]*13 - self.my_tsumohai = "?" self.react(self.mjai_client) return None @@ -408,13 +367,19 @@ def input(self, parse_msg: dict) -> dict | None: return self.react(self.mjai_client) # end_game if parse_msg['method'] == '.lq.NotifyGameEndResult' or parse_msg['method'] == '.lq.NotifyGameTerminate': + try: + for idx, player in enumerate(parse_msg['data']['result']['players']): + if player['seat'] == self.seat: + self.rank = idx + 1 + self.score = player['partPoint1'] + game_result_log(self.mode_id, self.rank, self.score, self.mjai_client.bot.model_hash) + except: + pass self.mjai_message.append( { 'type': 'end_game' } ) - self.my_tehais = ["?"]*13 - self.my_tsumohai = "?" self.react(self.mjai_client) self.mjai_client.restart_bot(self.seat) return None diff --git a/mhm/addons.py b/mhm/addons.py index 4fcfee8..258878c 100644 --- a/mhm/addons.py +++ b/mhm/addons.py @@ -1,4 +1,6 @@ +import json from mitmproxy import http +from urllib.parse import urlparse, parse_qs from . import logger @@ -47,5 +49,19 @@ def websocket_message(self, flow: http.HTTPFlow): log(self.manager) + def request(self, flow: http.HTTPFlow): + parsed_url = urlparse(flow.request.url) + if parsed_url.hostname == "majsoul-hk-client.cn-hongkong.log.aliyuncs.com": + qs = parse_qs(parsed_url.query) + try: + content = json.loads(qs["content"][0]) + if content["type"] == "re_err": + logger.warning(" ".join(["[i][red]Error", str(qs)])) + flow.kill() + else: + logger.debug(" ".join(["[i][green]Log", str(qs)])) + except: + return + addons = [WebSocketAddon()] diff --git a/mhmp.json b/mhmp.json index 3c3fe78..ae4a2ed 100644 --- a/mhmp.json +++ b/mhmp.json @@ -4,8 +4,8 @@ "pure_python_protobuf": false }, "hook": { - "enable_skins": true, - "enable_aider": true, + "enable_skins": false, + "enable_aider": false, "enable_chest": false, "random_star_char": false, "no_cheering_emotes": false diff --git a/mitm.py b/mitm.py index b2e57a4..25dae5e 100644 --- a/mitm.py +++ b/mitm.py @@ -9,7 +9,6 @@ import mitmproxy.log import mitmproxy.tcp import mitmproxy.websocket -import mhm from pathlib import Path from optparse import OptionParser from mitmproxy import proxy, options, ctx @@ -70,16 +69,16 @@ async def start_proxy(host, port, enable_unlocker): with_dumper=False, ) master.addons.add(ClientWebSocket()) - master.addons.add(ClientHTTP()) if enable_unlocker: - from mhm.addons import WebSocketAddon as Unlocker - master.addons.add(Unlocker()) + master.addons.add(ClientHTTP()) + from mhm.addons import WebSocketAddon as Unlocker + master.addons.add(Unlocker()) await master.run() return master # Create a XMLRPC server class LiqiServer: - _rpc_methods_ = ['get_activated_flows', 'get_messages', 'reset_message_idx', 'page_clicker', 'do_autohu'] + _rpc_methods_ = ['get_activated_flows', 'get_messages', 'reset_message_idx', 'page_clicker', 'do_autohu', 'ping'] def __init__(self, host, port): self.host = host self.port = port @@ -117,6 +116,9 @@ def do_autohu(self): do_autohu = True return True + def ping(self): + return True + def serve_forever(self): print(f"XMLRPC Server is running on {self.host}:{self.port}") self.server.serve_forever() @@ -155,15 +157,16 @@ def serve_forever(self): if opts.unlocker is not None: enable_unlocker = bool(opts.unlocker) - print("fetching resver...") - mhm.fetch_resver() - with open("mhmp.json", "r") as f: mhmp = json.load(f) mhmp["mitmdump"]["mode"] = [f"regular@{mitm_port}"] + mhmp["hook"]["enable_skins"] = enable_unlocker mhmp["hook"]["enable_aider"] = enable_helper with open("mhmp.json", "w") as f: json.dump(mhmp, f, indent=4) + import mhm + print("fetching resver...") + mhm.fetch_resver() # Create and start the proxy server thread proxy_thread = threading.Thread(target=lambda: asyncio.run(start_proxy(mitm_host, mitm_port, enable_unlocker))) proxy_thread.start() diff --git a/mjai/bot/bot.py b/mjai/bot/bot.py index faaa9a9..fac8953 100644 --- a/mjai/bot/bot.py +++ b/mjai/bot/bot.py @@ -1,5 +1,7 @@ import json import sys +import hashlib +import pathlib from loguru import logger @@ -9,12 +11,14 @@ class Bot: def __init__(self, player_id: int): self.player_id = player_id + model_path = pathlib.Path(__file__).parent / f"mortal.pth" self.model = model.load_model(player_id) + with open(model_path, "rb") as f: + self.model_hash = hashlib.sha256(f.read()).hexdigest() def react(self, events: str) -> str: events = json.loads(events) - # logger.info("hi") return_action = None for e in events: return_action = self.model.react(json.dumps(e, separators=(",", ":"))) @@ -23,9 +27,11 @@ def react(self, events: str) -> str: return json.dumps({"type":"none"}, separators=(",", ":")) else: raw_data = json.loads(return_action) - del raw_data["meta"] return json.dumps(raw_data, separators=(",", ":")) + def state(self): + return self.model.state + def main(): player_id = int(sys.argv[1]) @@ -40,5 +46,4 @@ def main(): if __name__ == "__main__": - # debug() main() \ No newline at end of file diff --git a/mjai/bot/model.py b/mjai/bot/model.py index f99e46a..1d65f24 100644 --- a/mjai/bot/model.py +++ b/mjai/bot/model.py @@ -304,7 +304,6 @@ def _react_batch(self, obs, masks, invisible_obs): else: is_greedy = torch.ones(batch_size, dtype=torch.bool, device=self.device) actions = q_out.argmax(-1) - return actions.tolist(), q_out.tolist(), masks.tolist(), is_greedy.tolist() def sample_top_p(logits, p): @@ -335,9 +334,8 @@ def load_model(seat: int) -> riichi.mjai.Bot: # Get the path of control_state_file = current directory / control_state_file control_state_file = pathlib.Path(__file__).parent / control_state_file - - state = torch.load(control_state_file, map_location=device) + mortal = Brain(version=state['config']['control']['version'], conv_channels=state['config']['resnet']['conv_channels'], num_blocks=state['config']['resnet']['num_blocks']).eval() dqn = DQN(version=state['config']['control']['version']).eval() mortal.load_state_dict(state['mortal']) @@ -350,9 +348,9 @@ def load_model(seat: int) -> riichi.mjai.Bot: device = device, enable_amp = False, enable_quick_eval = False, - enable_rule_based_agari_guard = True, + enable_rule_based_agari_guard = False, name = 'mortal', - version= state['config']['control']['version'] + version = state['config']['control']['version'], ) bot = riichi.mjai.Bot(engine, seat) diff --git a/my_logger.py b/my_logger.py new file mode 100644 index 0000000..140a5ce --- /dev/null +++ b/my_logger.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import os +os.environ["LOGURU_AUTOINIT"] = "False" + +import json +import requests +import logging +import loguru +from loguru import logger +from aliyun.log.logger_hanlder import QueuedLogHandler, LogFields + +def my_sink(message: loguru.Message) -> None: + record = message.record + +logger.level("CLICK", no=10, icon="CLICK") +logger.add("akagi.log") +# logger.add(my_sink) + +RECORD_LOG_FIELDS = set((LogFields.record_name, LogFields.level)) +res = requests.get("https://cdn.jsdelivr.net/gh/shinkuan/RandomStuff/aliyun_log_handler_arg.json", allow_redirects=True) +json_data = json.loads(res.content) + +handler = QueuedLogHandler( + **json_data, + fields=RECORD_LOG_FIELDS, +) +game_result_logger = logging.getLogger("game_result_log") +game_result_logger.setLevel(logging.INFO) +game_result_logger.addHandler(handler) + +def game_result_log(mode_id: int, rank: int, score: int, model_hash: str) -> None: + if any((mode_id is None, rank is None, score is None, model_hash is None)): + logger.error("Invalid game result") + return + game_result = { + "mode_id": mode_id, + "rank": rank, + "score": score, + "model_hash": model_hash + } + game_result_logger.info(game_result) diff --git a/requirement.txt b/requirement.txt index 282a6b2..8f884d5 100644 --- a/requirement.txt +++ b/requirement.txt @@ -7,6 +7,8 @@ protobuf==4.25.1 rich==13.7.0 textual==0.46.0 playwright==1.41.0 +pycryptodome==3.20.0 torch>=2.2.0 ---find-links https://github.com/shinkuan/Akagi/releases/expanded_assets/v0.1.0-libriichi -riichi \ No newline at end of file +https://github.com/shinkuan/aliyun-log-python-sdk/archive/refs/heads/master.zip +--find-links https://github.com/shinkuan/Akagi/releases/expanded_assets/v0.1.1-libriichi +riichi>=0.1.1 \ No newline at end of file diff --git a/resver.json b/resver.json index d1b0d92..4ba492b 100644 --- a/resver.json +++ b/resver.json @@ -1 +1 @@ -{"version": "0.11.24.w", "emotes": {"200001": [10, 11, 12, 14, 15, 16, 17, 18, 888, 966, 983, 984, 996, 997], "200002": [10, 11, 12, 14, 15, 16, 17, 18, 888, 995, 998], "200003": [10, 11, 12, 14, 15, 16, 17, 18, 888, 959, 979, 999], "200004": [10, 11, 12, 14, 15, 16, 17, 18, 888, 957, 986, 994, 999], "200005": [10, 11, 12, 14, 15, 16, 17, 18, 888, 968, 989, 997], "200006": [10, 11, 12, 14, 15, 16, 17, 18, 888, 961, 977, 997], "200007": [10, 11, 12, 14, 15, 16, 17, 18, 888, 961, 970, 976, 993, 999], "200008": [10, 11, 12, 14, 15, 16, 17, 18, 888, 972, 991, 997], "200009": [10, 11, 12, 14, 15, 16, 17, 18, 888, 966, 994, 998], "200010": [10, 11, 12, 14, 15, 16, 17, 18, 888, 965, 982], "200011": [10, 11, 12, 14, 15, 16, 17, 18, 888, 970, 977, 998], "200012": [10, 11, 12, 14, 15, 16, 17, 18, 888, 959, 998], "200013": [10, 11, 12, 14, 15, 16, 17, 18, 888, 966, 988, 999], "200014": [10, 11, 12, 14, 15, 16, 17, 18, 888, 969, 982, 993], "200015": [10, 11, 12, 14, 15, 16, 17, 18, 888, 994], "200016": [10, 11, 12, 14, 15, 16, 17, 18, 888, 961, 970, 988, 996], "200017": [10, 11, 12, 14, 15, 16, 17, 18, 888, 962, 982, 995], "200018": [9, 10, 11, 12, 14, 15, 16, 17, 18, 888, 962, 994], "200019": [9, 10, 11, 12, 14, 15, 16, 17, 18, 888, 972, 981, 986], "200020": [10, 11, 12, 14, 888, 967, 988, 994], "200021": [10, 11, 12, 14, 888, 954, 981, 985], "200022": [10, 11, 12, 14, 888, 991], "200023": [10, 11, 12, 14, 888, 955, 981], "200024": [10, 11, 12, 14, 888, 960, 976, 992], "200025": [10, 11, 12, 14, 888, 957, 982], "200026": [10, 11, 12, 14, 888, 973, 989], "200027": [10, 11, 12, 14, 888, 968, 988], "200028": [10, 11, 12, 14, 888, 971, 991], "200029": [10, 11, 12, 14, 888, 964, 976, 992], "200030": [10, 11, 12, 14, 888, 955, 979], "200031": [10, 11, 12, 14, 888, 959, 978], "200032": [10, 11, 12, 14, 888, 957, 976, 985], "200033": [10, 11, 12, 14, 888, 969], "200034": [10, 11, 12, 14, 969, 990], "200035": [10, 11, 12, 14, 969, 990], "200036": [10, 11, 12, 14, 969, 990], "200037": [10, 11, 12, 14, 969, 990], "200038": [10, 11, 12, 14, 888, 959, 967, 974], "200039": [10, 11, 12, 14, 888, 961, 970], "200040": [10, 11, 12, 14, 987], "200041": [10, 11, 12, 14, 987], "200042": [10, 11, 12, 14, 987], "200043": [10, 11, 12, 14, 987], "200044": [10, 11, 12, 14, 888, 960, 978], "200045": [10, 11, 12, 14, 888, 973, 981], "200046": [10, 11, 12, 14, 888, 967], "200047": [10, 11, 12, 14, 888, 957, 967], "200048": [10, 11, 12, 14, 888, 965, 971], "200049": [10, 11, 12, 14, 888, 954, 964], "200050": [10, 11, 12, 14, 980], "200051": [10, 11, 12, 14, 980], "200052": [10, 11, 12, 14, 888], "200053": [10, 11, 12, 14, 888, 958], "200054": [10, 11, 12, 14, 888, 974], "200055": [10, 11, 12, 14, 975], "200056": [10, 11, 12, 14, 975], "200057": [10, 11, 12, 14, 975], "200058": [10, 11, 12, 14, 975], "200059": [10, 11, 12, 14, 888, 955, 960], "200060": [10, 11, 12, 14, 888, 958], "200061": [10, 11, 12, 14, 888], "200062": [10, 11, 12, 14, 969], "200063": [10, 11, 12, 14, 969], "200064": [10, 11, 12, 14, 969], "200065": [10, 11, 12, 14, 969], "200066": [10, 11, 12, 14, 888, 955, 962], "200067": [10, 11, 12, 14, 888, 954], "200068": [10, 11, 12, 14, 888, 955], "200069": [10, 11, 12, 14, 888], "200070": [10, 11, 12, 14, 963], "200071": [10, 11, 12, 14, 963], "200072": [10, 11, 12, 14, 963], "200073": [10, 11, 12, 14, 963], "200074": [10, 11, 12, 14, 888], "200075": [10, 11, 12, 14, 888], "200076": [10, 11, 12, 14, 888], "200077": [10, 11, 12, 14, 888], "200078": [10, 11, 12, 14, 888], "200079": [10, 11, 12, 956], "200080": [10, 11, 12, 956], "200081": [10, 11, 12, 956], "200082": [10, 11, 12, 956], "200083": [10, 11, 12, 888], "200084": [10, 11, 12, 888], "200085": [10, 11, 12, 888], "200090": [10, 11, 12, 888]}} \ No newline at end of file +{"version": "0.11.28.w", "emotes": {"200001": [10, 11, 12, 14, 15, 16, 17, 18, 888, 966, 983, 984, 996, 997], "200002": [10, 11, 12, 14, 15, 16, 17, 18, 888, 995, 998], "200003": [10, 11, 12, 14, 15, 16, 17, 18, 888, 959, 979, 999], "200004": [10, 11, 12, 14, 15, 16, 17, 18, 888, 957, 986, 994, 999], "200005": [10, 11, 12, 14, 15, 16, 17, 18, 888, 968, 989, 997], "200006": [10, 11, 12, 14, 15, 16, 17, 18, 888, 961, 977, 997], "200007": [10, 11, 12, 14, 15, 16, 17, 18, 888, 961, 970, 976, 993, 999], "200008": [10, 11, 12, 14, 15, 16, 17, 18, 888, 972, 991, 997], "200009": [10, 11, 12, 14, 15, 16, 17, 18, 888, 966, 994, 998], "200010": [10, 11, 12, 14, 15, 16, 17, 18, 888, 965, 982], "200011": [10, 11, 12, 14, 15, 16, 17, 18, 888, 970, 977, 998], "200012": [10, 11, 12, 14, 15, 16, 17, 18, 888, 959, 998], "200013": [10, 11, 12, 14, 15, 16, 17, 18, 888, 966, 988, 999], "200014": [10, 11, 12, 14, 15, 16, 17, 18, 888, 969, 982, 993], "200015": [10, 11, 12, 14, 15, 16, 17, 18, 888, 994], "200016": [10, 11, 12, 14, 15, 16, 17, 18, 888, 961, 970, 988, 996], "200017": [10, 11, 12, 14, 15, 16, 17, 18, 888, 962, 982, 995], "200018": [9, 10, 11, 12, 14, 15, 16, 17, 18, 888, 962, 994], "200019": [9, 10, 11, 12, 14, 15, 16, 17, 18, 888, 972, 981, 986], "200020": [10, 11, 12, 14, 888, 967, 988, 994], "200021": [10, 11, 12, 14, 888, 954, 981, 985], "200022": [10, 11, 12, 14, 888, 991], "200023": [10, 11, 12, 14, 888, 955, 981], "200024": [10, 11, 12, 14, 888, 960, 976, 992], "200025": [10, 11, 12, 14, 888, 957, 982], "200026": [10, 11, 12, 14, 888, 973, 989], "200027": [10, 11, 12, 14, 888, 968, 988], "200028": [10, 11, 12, 14, 888, 971, 991], "200029": [10, 11, 12, 14, 888, 964, 976, 992], "200030": [10, 11, 12, 14, 888, 955, 979], "200031": [10, 11, 12, 14, 888, 959, 978], "200032": [10, 11, 12, 14, 888, 957, 976, 985], "200033": [10, 11, 12, 14, 888, 969], "200034": [10, 11, 12, 14, 969, 990], "200035": [10, 11, 12, 14, 969, 990], "200036": [10, 11, 12, 14, 969, 990], "200037": [10, 11, 12, 14, 969, 990], "200038": [10, 11, 12, 14, 888, 959, 967, 974], "200039": [10, 11, 12, 14, 888, 961, 970], "200040": [10, 11, 12, 14, 987], "200041": [10, 11, 12, 14, 987], "200042": [10, 11, 12, 14, 987], "200043": [10, 11, 12, 14, 987], "200044": [10, 11, 12, 14, 888, 960, 978], "200045": [10, 11, 12, 14, 888, 973, 981], "200046": [10, 11, 12, 14, 888, 967], "200047": [10, 11, 12, 14, 888, 957, 967], "200048": [10, 11, 12, 14, 888, 965, 971], "200049": [10, 11, 12, 14, 888, 954, 964], "200050": [10, 11, 12, 14, 980], "200051": [10, 11, 12, 14, 980], "200052": [10, 11, 12, 14, 888], "200053": [10, 11, 12, 14, 888, 958], "200054": [10, 11, 12, 14, 888, 953, 974], "200055": [10, 11, 12, 14, 975], "200056": [10, 11, 12, 14, 975], "200057": [10, 11, 12, 14, 975], "200058": [10, 11, 12, 14, 975], "200059": [10, 11, 12, 14, 888, 955, 960], "200060": [10, 11, 12, 14, 888, 958], "200061": [10, 11, 12, 14, 888], "200062": [10, 11, 12, 14, 969], "200063": [10, 11, 12, 14, 969], "200064": [10, 11, 12, 14, 969], "200065": [10, 11, 12, 14, 969], "200066": [10, 11, 12, 14, 888, 955, 962], "200067": [10, 11, 12, 14, 888, 954], "200068": [10, 11, 12, 14, 888, 955], "200069": [10, 11, 12, 14, 888, 953], "200070": [10, 11, 12, 14, 963], "200071": [10, 11, 12, 14, 963], "200072": [10, 11, 12, 14, 963], "200073": [10, 11, 12, 14, 963], "200074": [10, 11, 12, 14, 888], "200075": [10, 11, 12, 14, 888], "200076": [10, 11, 12, 14, 888], "200077": [10, 11, 12, 14, 888, 953], "200078": [10, 11, 12, 14, 888], "200079": [10, 11, 12, 956], "200080": [10, 11, 12, 956], "200081": [10, 11, 12, 956], "200082": [10, 11, 12, 956], "200083": [10, 11, 12, 888], "200084": [10, 11, 12, 888], "200085": [10, 11, 12, 888], "200090": [10, 11, 12, 888]}} \ No newline at end of file diff --git a/run_akagi.command b/run_akagi.command new file mode 100755 index 0000000..ceb7194 --- /dev/null +++ b/run_akagi.command @@ -0,0 +1,4 @@ +#!/bin/bash +cd "$(dirname "$0")" +source venv/bin/activate +python client.py diff --git a/settings.json b/settings.json index 5f7df7b..ea0e9ff 100644 --- a/settings.json +++ b/settings.json @@ -1,8 +1,8 @@ { - "Unlocker": true, + "Unlocker": false, "v10": false, - "Autoplay": true, - "Helper": true, + "Autoplay": false, + "Helper": false, "Autohu": true, "Port": { "MITM": 7878, diff --git a/simple_client.py b/simple_client.py new file mode 100644 index 0000000..9945205 --- /dev/null +++ b/simple_client.py @@ -0,0 +1,210 @@ +import json +import threading +import asyncio +import signal +import time +import re +import mitmproxy.addonmanager +import mitmproxy.http +import mitmproxy.log +import mitmproxy.tcp +import mitmproxy.websocket +from pathlib import Path +from optparse import OptionParser +from mitmproxy import proxy, options, ctx +from mitmproxy.tools.dump import DumpMaster +from xmlrpc.server import SimpleXMLRPCServer +from playwright.sync_api import sync_playwright, WebSocket +from playwright.sync_api._generated import Page + +from liqi import LiqiProto +from majsoul2mjai import MajsoulBridge + +activated_flows = [] # store all flow.id ([-1] is the recently opened) +activated_flows_instance = [] +messages_dict = dict() # flow.id -> Queue[flow_msg] +stop = False +SHOW_LIQI = False + +class ClientWebSocket: + def __init__(self): + self.liqi: dict[str, LiqiProto]={} + self.bridge: dict[str, MajsoulBridge]={} + pass + + # Websocket lifecycle + def websocket_start(self, flow: mitmproxy.http.HTTPFlow): + """ + + A websocket connection has commenced. + + """ + # print('[new websocket]:',flow,flow.__dict__,dir(flow)) + assert isinstance(flow.websocket, mitmproxy.websocket.WebSocketData) + global activated_flows,messages_dict,activated_flows_instance + + activated_flows.append(flow.id) + activated_flows_instance.append(flow) + + messages_dict[flow.id]=flow.websocket.messages + + self.liqi[flow.id] = LiqiProto() + self.bridge[flow.id] = MajsoulBridge() + + def websocket_message(self, flow: mitmproxy.http.HTTPFlow): + """ + + Called when a WebSocket message is received from the client or + + server. The most recent message will be flow.messages[-1]. The + + message is user-modifiable. Currently there are two types of + + messages, corresponding to the BINARY and TEXT frame types. + + """ + assert isinstance(flow.websocket, mitmproxy.websocket.WebSocketData) + flow_msg = flow.websocket.messages[-1] + + parse_msg = self.liqi[flow.id].parse(flow_msg) + mjai_msg = self.bridge[flow.id].input(parse_msg) + if mjai_msg is not None: + print('-'*65) + print(mjai_msg) + # composed_msg = self.bridge[flow.id].action(mjai_msg, self.liqi[flow.id]) + # if composed_msg is not None and AUTOPLAY: + # ws_composed_msg = mitmproxy.websocket.WebSocketMessage(2, True, composed_msg) + # flow.messages.append(ws_composed_msg) + # flow.inject_message(flow.server_conn, composed_msg) + # print('='*65) + if SHOW_LIQI: + print(flow_msg.content) + print(parse_msg) + print('='*65) + # if parse_msg['data']['name'] == 'ActionDiscardTile': + # print("Action is DiscardTile") + # if len(parse_msg['data']['data']['operation']['operationList'])>0: + # print(parse_msg['data']['data']['operation']['operationList']) + # print("OperationList is not empty") + # parse_msg['data']['data']['operation']['operationList'] = [ + # { + # 'type': 3, + # 'combination': [ + # ['3m|4m', '4m|6m', '6m|7m'] + # ] + # }, + # { + # 'type': 3, + # 'combination': [ + # ['0m|5m', '5m|5m'] + # ] + # }, + # { + # 'type': 5, + # 'combination': [ + # ['0m|5m|5m'] + # ] + # }, + # { + # 'type': 9 + # } + # ] + # print("Composing message...") + # composed_msg = self.liqi[flow.id].compose(parse_msg) + # flow.messages[-1].kill() + # flow.messages.append(composed_msg) + # flow.inject_message(flow.client_conn, composed_msg) + # flow.messages[-1] = composed_msg + # print('='*65) + # print(parse_msg) + # print('='*65) + # print('='*65) + # if not AUTOPLAY: + # print(mjai_msg) + # print('='*65) + + # packet = flow_msg.content + # from_client = flow_msg.from_client + # print("[" + ("Sended" if from_client else "Reveived") + + # "] from '"+flow.id+"': decode the packet here: %r…" % packet) + + def websocket_end(self, flow: mitmproxy.http.HTTPFlow): + """ + + A websocket connection has ended. + + """ + # print('[end websocket]:',flow,flow.__dict__,dir(flow)) + global activated_flows,messages_dict,activated_flows_instance + activated_flows.remove(flow.id) + activated_flows_instance.remove(flow) + messages_dict.pop(flow.id) + self.liqi.pop(flow.id) + self.bridge.pop(flow.id) + +class ClientHTTP: + def __init__(self): + pass + + def request(self, flow: mitmproxy.http.HTTPFlow): + if flow.request.method == "GET": + if re.search(r'^https://game\.maj\-soul\.(com|net)/[0-9]+/v[0-9\.]+\.w/code\.js$', flow.request.url): + print("====== GET code.js ======"*3) + print("====== GET code.js ======"*3) + print("====== GET code.js ======"*3) + flow.request.url = "http://cdn.jsdelivr.net/gh/Avenshy/majsoul_mod_plus/safe_code.js" + elif re.search(r'^https://game\.mahjongsoul\.com/v[0-9\.]+\.w/code\.js$', flow.request.url): + flow.request.url = "http://cdn.jsdelivr.net/gh/Avenshy/majsoul_mod_plus/safe_code.js" + elif re.search(r'^https://mahjongsoul\.game\.yo-star\.com/v[0-9\.]+\.w/code\.js$', flow.request.url): + flow.request.url = "http://cdn.jsdelivr.net/gh/Avenshy/majsoul_mod_plus/safe_code.js" + +async def start_proxy(host, port, enable_unlocker): + opts = options.Options(listen_host=host, listen_port=port) + + master = DumpMaster( + opts, + with_termlog=False, + with_dumper=False, + ) + master.addons.add(ClientWebSocket()) + master.addons.add(ClientHTTP()) + # if enable_unlocker: + from mhm.addons import WebSocketAddon as Unlocker + master.addons.add(Unlocker()) + await master.run() + return master + +if __name__ == '__main__': + with open("settings.json", "r") as f: + settings = json.load(f) + mitm_port = settings["Port"]["MITM"] + enable_unlocker = settings["Unlocker"] + enable_helper = settings["Helper"] + + mitm_host="127.0.0.1" + + print("fetching resver...") + + with open("mhmp.json", "r") as f: + mhmp = json.load(f) + mhmp["mitmdump"]["mode"] = [f"regular@{mitm_port}"] + mhmp["hook"]["enable_skins"] = enable_unlocker + mhmp["hook"]["enable_aider"] = enable_helper + with open("mhmp.json", "w") as f: + json.dump(mhmp, f, indent=4) + import mhm + mhm.fetch_resver() + mhm.logger.setLevel("WARNING") + + # Create and start the proxy server thread + proxy_thread = threading.Thread(target=lambda: asyncio.run(start_proxy(mitm_host, mitm_port, enable_unlocker))) + proxy_thread.start() + + + + try: + while True: + time.sleep(1) + except KeyboardInterrupt: + ctx.master.shutdown() + exit(0) diff --git a/tileUnicode.py b/tileUnicode.py index 212d048..696395f 100644 --- a/tileUnicode.py +++ b/tileUnicode.py @@ -487,7 +487,7 @@ │ │ ╰──────╯""", '?': - """[bold gray]╭─────╮ + """[bold]╭─────╮ │ ┏━┓ │ │ ┏┛ │ │ ● │ @@ -498,4 +498,49 @@ ║ ║ ║ -║""" \ No newline at end of file +║""" + +#▁▂▃▄▅▆▇█ +HAI_VALUE = [ +'[red]\n \n \n \n▁', +'[red]\n \n \n \n▂', +'[red]\n \n \n \n▃', +'[red]\n \n \n \n▄', # ~ 10% +'[yellow]\n \n \n \n▅', +'[yellow]\n \n \n \n▆', +'[yellow]\n \n \n \n▇', +'[yellow]\n \n \n \n█', # ~ 20% +'[yellow]\n \n \n▁\n█', +'[yellow]\n \n \n▂\n█', +'[yellow]\n \n \n▃\n█', +'[yellow]\n \n \n▄\n█', # ~ 30% +'[yellow]\n \n \n▅\n█', +'[yellow]\n \n \n▆\n█', +'[yellow]\n \n \n▇\n█', +'[yellow]\n \n \n█\n█', # ~ 40% +'[green]\n \n▁\n█\n█', +'[green]\n \n▂\n█\n█', +'[green]\n \n▃\n█\n█', +'[green]\n \n▄\n█\n█', # ~ 50% +'[green]\n \n▅\n█\n█', +'[green]\n \n▆\n█\n█', +'[green]\n \n▇\n█\n█', +'[green]\n \n█\n█\n█', # ~ 60% +'[green]\n▁\n█\n█\n█', +'[green]\n▂\n█\n█\n█', +'[green]\n▃\n█\n█\n█', +'[green]\n▄\n█\n█\n█', # ~ 70% +'[green]\n▅\n█\n█\n█', +'[green]\n▆\n█\n█\n█', +'[green]\n▇\n█\n█\n█', +'[green]\n█\n█\n█\n█', # ~ 80% +'[blue]▁\n█\n█\n█\n█', +'[blue]▂\n█\n█\n█\n█', +'[blue]▃\n█\n█\n█\n█', +'[blue]▄\n█\n█\n█\n█', # ~ 90% +'[blue]▅\n█\n█\n█\n█', +'[blue]▆\n█\n█\n█\n█', +'[blue]▇\n█\n█\n█\n█', +'[blue]█\n█\n█\n█\n█', # ~ 100% +'' # none +] \ No newline at end of file