Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
33 changes: 29 additions & 4 deletions bot/local/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,31 @@
from torch.distributions import Normal, Categorical
from bot.local.model import Brain, DQN

def _to_python_scalars(value):
if isinstance(value, torch.Tensor):
return value.detach().cpu().tolist()
if isinstance(value, np.ndarray):
value = value.tolist()
elif isinstance(value, np.generic):
return value.item()

if isinstance(value, (list, tuple)):
return [_to_python_scalars(v) for v in value]
return value

def _batch_to_tensor(batch, *, dtype, device):
if isinstance(batch, torch.Tensor):
return batch.to(device=device, dtype=dtype)

# Avoid the torch<->numpy bridge here. The shipped torch build is linked
# against NumPy 1.x, while the runtime may have NumPy 2.x installed.
if isinstance(batch, np.ndarray):
batch = batch.tolist()
elif not isinstance(batch, (list, tuple)):
batch = list(batch)

return torch.tensor(_to_python_scalars(batch), dtype=dtype, device=device)

class MortalEngine:
""" Mortal Engine for local Bot 4p"""
def __init__(
Expand Down Expand Up @@ -48,11 +73,11 @@ def react_batch(self, obs, masks, invisible_obs):
return self._react_batch(obs, masks, invisible_obs)

def _react_batch(self, obs, masks, invisible_obs):
obs = torch.as_tensor(np.stack(obs, axis=0), device=self.device)
masks = torch.as_tensor(np.stack(masks, axis=0), device=self.device)
obs = _batch_to_tensor(obs, dtype=torch.float32, device=self.device)
masks = _batch_to_tensor(masks, dtype=torch.bool, device=self.device)
invisible_obs = None
if self.is_oracle:
invisible_obs = torch.as_tensor(np.stack(invisible_obs, axis=0), device=self.device)
invisible_obs = _batch_to_tensor(invisible_obs, dtype=torch.float32, device=self.device)
batch_size = obs.shape[0]

match self.version:
Expand Down Expand Up @@ -123,4 +148,4 @@ def get_engine(model_file:str) -> MortalEngine:
version = state['config']['control']['version'],
)

return engine
return engine
33 changes: 29 additions & 4 deletions bot/local/engine3p.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,31 @@
from torch.distributions import Normal, Categorical
from bot.local.model3p import Brain, DQN

def _to_python_scalars(value):
if isinstance(value, torch.Tensor):
return value.detach().cpu().tolist()
if isinstance(value, np.ndarray):
value = value.tolist()
elif isinstance(value, np.generic):
return value.item()

if isinstance(value, (list, tuple)):
return [_to_python_scalars(v) for v in value]
return value

def _batch_to_tensor(batch, *, dtype, device):
if isinstance(batch, torch.Tensor):
return batch.to(device=device, dtype=dtype)

# Avoid the torch<->numpy bridge here. The shipped torch build is linked
# against NumPy 1.x, while the runtime may have NumPy 2.x installed.
if isinstance(batch, np.ndarray):
batch = batch.tolist()
elif not isinstance(batch, (list, tuple)):
batch = list(batch)

return torch.tensor(_to_python_scalars(batch), dtype=dtype, device=device)

class MortalEngine:
""" Mortal Engine for local bot 3p"""
def __init__(
Expand Down Expand Up @@ -48,11 +73,11 @@ def react_batch(self, obs, masks, invisible_obs):
return self._react_batch(obs, masks, invisible_obs)

def _react_batch(self, obs, masks, invisible_obs):
obs = torch.as_tensor(np.stack(obs, axis=0), device=self.device)
masks = torch.as_tensor(np.stack(masks, axis=0), device=self.device)
obs = _batch_to_tensor(obs, dtype=torch.float32, device=self.device)
masks = _batch_to_tensor(masks, dtype=torch.bool, device=self.device)
invisible_obs = None
if self.is_oracle:
invisible_obs = torch.as_tensor(np.stack(invisible_obs, axis=0), device=self.device)
invisible_obs = _batch_to_tensor(invisible_obs, dtype=torch.float32, device=self.device)
batch_size = obs.shape[0]

match self.version:
Expand Down Expand Up @@ -121,4 +146,4 @@ def get_engine(model_file:str) -> MortalEngine:
version= state['config']['control']['version']
)

return engine
return engine
48 changes: 36 additions & 12 deletions bot_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,11 +129,16 @@ def start_browser(self):
self.browser.start(ms_url, proxy, self.st.browser_width, self.st.browser_height, self.st.enable_chrome_ext)

def is_browser_zoom_off(self):
""" check browser zoom level, return true if zoomlevel is not 1"""
"""Check whether the browser client area mismatches the configured automation size."""
if self.browser and self.browser.is_page_normal():
zoom = self.browser.zoomlevel_check
if zoom is not None:
if abs(zoom - 1) > 0.001:
client_size = self.browser.client_size_check
if client_size is not None:
inner_width, inner_height = client_size
tolerance = self.browser.CLIENT_SIZE_TOLERANCE_PX
if (
abs(inner_width - self.browser.width) > tolerance or
abs(inner_height - self.browser.height) > tolerance
):
return True
return False

Expand Down Expand Up @@ -382,21 +387,31 @@ def _process_msg(self, msg:mitm.WSMessage):
LOGGER.info("Game Started. Game Flow ID=%s", msg.flow_id)
self.game_flow_id = msg.flow_id
self.game_state = GameState(self.bot) # create game state with bot
self.game_state.input(liqimsg) # authGame -> mjai:start_game, no reaction
self.game_exception = None
self.automation.on_enter_game()
self._handle_game_flow_msg(liqimsg, process_idle=False)
elif msg.flow_id == self.game_flow_id:
self._handle_game_flow_msg(liqimsg, process_idle=False)
else:
LOGGER.warning("Game flow %s already started. ignoring new game flow %s", self.game_flow_id, msg.flow_id)

elif (liqi_type, liqi_method) in (
(liqi.MsgType.RES, liqi.LiqiMethod.enterGame),
(liqi.MsgType.RES, liqi.LiqiMethod.syncGame),
):
if self.game_flow_id is None:
LOGGER.info("Game flow initialized from %s. Flow ID=%s", liqi_method, msg.flow_id)
self.game_flow_id = msg.flow_id
self.game_state = GameState(self.bot)
self.game_exception = None
self.automation.on_enter_game()
self._handle_game_flow_msg(liqimsg, process_idle=False)
elif msg.flow_id == self.game_flow_id:
self._handle_game_flow_msg(liqimsg)

elif msg.flow_id == self.game_flow_id:
# Game Flow Message (in-Game message)
# Feed msg to game_state for processing with AI bot
LOGGER.debug('Game msg: %s', str(liqimsg))
reaction = self.game_state.input(liqimsg)
if reaction:
self._do_automation(reaction)
else:
self._process_idle_automation(liqimsg)
self._handle_game_flow_msg(liqimsg)
# if self.game_state.is_game_ended:
# self._process_end_game()

Expand Down Expand Up @@ -488,6 +503,15 @@ def _update_overlay_botleft(self):
text = '\n'.join((text, model_text, autoplay_text, line))
self.browser.overlay_update_botleft(text)

def _handle_game_flow_msg(self, liqimsg:dict, process_idle:bool=True):
"""Feed an in-game message into GameState and trigger automation as needed."""
LOGGER.debug('Game msg: %s', str(liqimsg))
reaction = self.game_state.input(liqimsg)
if reaction:
self._do_automation(reaction)
elif process_idle:
self._process_idle_automation(liqimsg)


def _do_automation(self, reaction:dict):
# auto play given mjai reaction
Expand Down
30 changes: 25 additions & 5 deletions common/lan_str.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ class LanStr:
WEB_OVERLAY = "Overlay"
AUTOPLAY = "Autoplay"
AUTO_JOIN_GAME = "Auto Join"
AUTO_JOIN_ROOM = "Auto Join Room"
AUTO_JOIN_TIMER = "Auto Join Timer"
OPEN_LOG_FILE = "Open Log File"
SETTINGS = "Settings"
Expand Down Expand Up @@ -41,8 +42,13 @@ class LanStr:
MITM_PORT = "MITM Server Port"
UPSTREAM_PROXY = "Upstream Proxy"
CLIENT_SIZE = "Client Size"
MAJSOUL_SERVER = "Majsoul Server"
MAJSOUL_URL = "Majsoul URL"
ENABLE_CHROME_EXT = "Enable Chrome Extensioins"
MAJSOUL_CN = "China"
MAJSOUL_JP = "Japan"
MAJSOUL_CUSTOM = "Custom"
MAJSOUL_URL_ERROR_PROMPT = "Invalid Majsoul URL"
ENABLE_CHROME_EXT = "Enable Chrome Extensions"
LANGUAGE = "Display Language"
CLIENT_INJECT_PROXY = "Auto Proxy Majsoul Windows Client"
MODEL_TYPE = "AI Model Type"
Expand All @@ -66,6 +72,9 @@ class LanStr:
RANDOM_DELAY_RANGE = "Base Delay Range (sec)"
GAME_LEVELS = ["Bronze", "Silver", "Gold", "Jade", "Throne"]
GAME_MODES = ["4-P East","4-P South","3-P East","3-P South"]
MATCH_LEVELS = ["Event Hall", "Activity 1", "Activity 2", "Activity 3", "Casual Hall"]
MATCH_GAME_MODES = ["4-P East"]
AUTO_JOIN_ROOMS = ["Ranked", "Match"]
MOUSE_RANDOM_MOVE = "Randomize Move"

# Status
Expand Down Expand Up @@ -93,9 +102,10 @@ class LanStr:
MITM_CERT_NOT_INSTALLED = "Run as admin or manually install MITM cert."
MAIN_THREAD_ERROR = "Main Thread Error!"
MODEL_NOT_SUPPORT_MODE_ERROR = "Model not supporting game mode"
UNSUPPORTED_GAME_RULE_ERROR = "Unsupported game rules"
CONNECTION_ERROR = "Network Connection Error"
BROWSER_ZOOM_OFF = r"Set Browser Zoom level to 100% !"
CHECK_ZOOM = "Browser Zoom!"
BROWSER_ZOOM_OFF = "Browser page size mismatch. Restart the browser!"
CHECK_ZOOM = "Browser Page Size Error!"
# Reaction/Actions
PASS = "Skip"
DISCARD = "Discard"
Expand Down Expand Up @@ -174,7 +184,12 @@ class LanStrZHS(LanStr):
MITM_PORT = "MITM 服务端口"
UPSTREAM_PROXY = "上游代理"
CLIENT_SIZE = "客户端大小"
MAJSOUL_SERVER = "雀魂区服"
MAJSOUL_URL = "雀魂网址"
MAJSOUL_CN = "国服"
MAJSOUL_JP = "日服"
MAJSOUL_CUSTOM = "自定义"
MAJSOUL_URL_ERROR_PROMPT = "错误的雀魂网址"
ENABLE_CHROME_EXT = "启用浏览器插件"
LANGUAGE = "显示语言"
CLIENT_INJECT_PROXY = "自动代理雀魂 Windows 客户端"
Expand All @@ -197,10 +212,14 @@ class LanStrZHS(LanStr):
DRAG_DAHAI = "鼠标拖拽出牌"
RANDOM_CHOICE = "AI 选项随机化(去重)"
REPLY_EMOJI_CHANCE = "回复表情概率"
AUTO_JOIN_ROOM = "自动加入房间"

RANDOM_DELAY_RANGE = "基础延迟随机范围(秒)"
GAME_LEVELS = ["铜之间", "银之间", "金之间", "玉之间", "王座之间"]
GAME_MODES = ["四人东","四人南","三人东","三人南"]
MATCH_LEVELS = ["赛事大厅", "活动场1", "活动场2", "活动场3", "普通休闲场"]
MATCH_GAME_MODES = ["四人东"]
AUTO_JOIN_ROOMS = ["段位场", "比赛场"]
MOUSE_RANDOM_MOVE = "鼠标移动随机化"

# Status
Expand Down Expand Up @@ -228,9 +247,10 @@ class LanStrZHS(LanStr):
MITM_SERVER_ERROR = "MITM 服务错误!"
MAIN_THREAD_ERROR = "主进程发生错误!"
MODEL_NOT_SUPPORT_MODE_ERROR = "模型不支持游戏模式"
UNSUPPORTED_GAME_RULE_ERROR = "当前活动规则暂不支持"
CONNECTION_ERROR = "网络连接错误"
BROWSER_ZOOM_OFF = r"请将浏览器缩放设置成 100% 以正常运行!"
CHECK_ZOOM = "浏览器缩放错误!"
BROWSER_ZOOM_OFF = "浏览器页面尺寸异常,请重启浏览器!"
CHECK_ZOOM = "浏览器页面尺寸异常!"

# Reaction/Actions
PASS = "跳过"
Expand Down
6 changes: 6 additions & 0 deletions common/mj_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@

def cvt_ms2mjai(ms_tile:str) -> str:
""" convert majsoul tile to mjai tile"""
if not ms_tile:
return ms_tile
# Activity rooms may append a transient state suffix such as `t` to tile strings.
# The bot/state code only needs the underlying tile identity.
if len(ms_tile) > 2 and ms_tile[0].isdigit() and ms_tile[1] in ('m', 'p', 's', 'z'):
ms_tile = ms_tile[:2]
if ms_tile in TILES_MS_2_MJAI:
return TILES_MS_2_MJAI[ms_tile]
else:
Expand Down
53 changes: 52 additions & 1 deletion common/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
from . import utils

DEFAULT_SETTING_FILE = 'settings.json'
MAJSOUL_SERVER_URLS = {
"CN": "https://game.maj-soul.com/1/",
"JP": "https://game.mahjongsoul.com/",
}
MAJSOUL_SERVER_TYPES = tuple(MAJSOUL_SERVER_URLS.keys()) + ("CUSTOM",)

class Settings:
""" Settings class to load and save settings to json file"""
Expand All @@ -23,6 +28,11 @@ def __init__(self, json_file:str=DEFAULT_SETTING_FILE) -> None:
self.browser_width:int = self._get_value("browser_width", 1280, lambda x: 0 < x < 19999)
self.browser_height:int = self._get_value("browser_height", 720, lambda x: 0 < x < 19999)
self.ms_url:str = self._get_value("ms_url", "https://game.maj-soul.com/1/",self.valid_url)
self.ms_server_select:str = self._get_value(
"ms_server_select",
self.infer_majsoul_server(self.ms_url),
self.valid_majsoul_server,
)
self.enable_chrome_ext:bool = self._get_value("enable_chrome_ext", False, self.valid_bool)
self.mitm_port:int = self._get_value("mitm_port", 10999, self.valid_mitm_port)
self.upstream_proxy:str = self._get_value("upstream_proxy","") # mitm upstream proxy server e.g. http://ip:port
Expand Down Expand Up @@ -61,8 +71,12 @@ def __init__(self, json_file:str=DEFAULT_SETTING_FILE) -> None:
self.auto_retry_interval:float = self._get_value("auto_retry_interval", 1.5, lambda x: 0.5 < x < 30.0) # not shown

self.auto_join_game:bool = self._get_value("auto_join_game", False, self.valid_bool)
self.auto_join_room:str = self._get_value("auto_join_room", utils.AUTO_JOIN_ROOMS[0], self.valid_auto_join_room)
self.auto_join_level:int = self._get_value("auto_join_level", 1, self.valid_game_level)
self.auto_join_mode:int = self._get_value("auto_join_mode", utils.GAME_MODES[0], self.valid_game_mode)

if self.ms_server_select in MAJSOUL_SERVER_URLS:
self.ms_url = self.get_majsoul_server_url(self.ms_server_select)

self.save_json()
LOGGER.info("Settings initialized and saved to %s", self._json_file)
Expand Down Expand Up @@ -106,6 +120,35 @@ def _get_value(self, key:str, default_value:any, validator:Callable[[any],bool]=
def lan(self) -> LanStr:
""" return the LanString instance"""
return LAN_OPTIONS[self.language]

def infer_majsoul_server(self, url:str) -> str:
"""Infer the Majsoul server type from a configured URL."""
url = (url or "").lower()
if "mahjongsoul.com" in url:
return "JP"
if "maj-soul.com" in url:
return "CN"
return "CUSTOM"

def get_majsoul_server(self) -> str:
"""Return the selected Majsoul server type."""
return getattr(self, "ms_server_select", self.infer_majsoul_server(self.ms_url))

def get_majsoul_server_url(self, server:str|None=None) -> str:
"""Return the canonical URL for the selected Majsoul server."""
if server is None:
server = self.get_majsoul_server()
return MAJSOUL_SERVER_URLS.get(server, MAJSOUL_SERVER_URLS["CN"])

def set_majsoul_server(self, server:str, custom_url:str|None=None) -> None:
"""Update Majsoul URL by server selection."""
if server == "CUSTOM":
self.ms_server_select = "CUSTOM"
if custom_url is not None:
self.ms_url = custom_url
return
self.ms_server_select = server
self.ms_url = self.get_majsoul_server_url(server)

### Validate functions: return true if the value is valid

Expand Down Expand Up @@ -149,11 +192,19 @@ def valid_game_mode(self, mode:str) -> bool:
return True
else:
return False

def valid_auto_join_room(self, room:str) -> bool:
""" return true if auto join room type is valid"""
return room in utils.AUTO_JOIN_ROOMS

def valid_majsoul_server(self, server:str) -> bool:
"""Return True if the selected Majsoul server type is valid."""
return server in MAJSOUL_SERVER_TYPES

def valid_url(self, url:str) -> bool:
""" validate url"""
valid_prefix = ["https://", "http://"]
for p in valid_prefix:
if url.startswith(p):
return True
return False
return False
Loading