diff --git a/bot/local/engine.py b/bot/local/engine.py index a05ac88..94ba0ee 100644 --- a/bot/local/engine.py +++ b/bot/local/engine.py @@ -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__( @@ -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: @@ -123,4 +148,4 @@ def get_engine(model_file:str) -> MortalEngine: version = state['config']['control']['version'], ) - return engine \ No newline at end of file + return engine diff --git a/bot/local/engine3p.py b/bot/local/engine3p.py index f76f661..d6ee9d4 100644 --- a/bot/local/engine3p.py +++ b/bot/local/engine3p.py @@ -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__( @@ -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: @@ -121,4 +146,4 @@ def get_engine(model_file:str) -> MortalEngine: version= state['config']['control']['version'] ) - return engine \ No newline at end of file + return engine diff --git a/bot_manager.py b/bot_manager.py index b1c6ee5..5aaa95b 100644 --- a/bot_manager.py +++ b/bot_manager.py @@ -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 @@ -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() @@ -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 diff --git a/common/lan_str.py b/common/lan_str.py index 565f627..86da233 100644 --- a/common/lan_str.py +++ b/common/lan_str.py @@ -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" @@ -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" @@ -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 @@ -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" @@ -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 客户端" @@ -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 @@ -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 = "跳过" diff --git a/common/mj_helper.py b/common/mj_helper.py index cf9a09a..d43685f 100644 --- a/common/mj_helper.py +++ b/common/mj_helper.py @@ -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: diff --git a/common/settings.py b/common/settings.py index 397a277..e7fbd30 100644 --- a/common/settings.py +++ b/common/settings.py @@ -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""" @@ -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 @@ -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) @@ -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 @@ -149,6 +192,14 @@ 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""" @@ -156,4 +207,4 @@ def valid_url(self, url:str) -> bool: for p in valid_prefix: if url.startswith(p): return True - return False \ No newline at end of file + return False diff --git a/common/utils.py b/common/utils.py index 6a6f71b..9c2717d 100644 --- a/common/utils.py +++ b/common/utils.py @@ -54,6 +54,8 @@ class GameMode(Enum): # for automation GAME_MODES = ['4E', '4S', '3E', '3S'] +MATCH_GAME_MODES = ['4E'] +AUTO_JOIN_ROOMS = ['ranked', 'match'] class UiState(Enum): @@ -79,6 +81,15 @@ class BotNotSupportingMode(Exception): def __init__(self, mode:GameMode): super().__init__(mode) +class UnsupportedMajsoulRule(Exception): + """ Majsoul rule variant unsupported by the current parser/bot stack""" + def __init__(self, rules:list[str] | tuple[str, ...] | str): + if isinstance(rules, str): + self.rules = (rules,) + else: + self.rules = tuple(rules) + super().__init__(', '.join(self.rules)) + def error_to_str(error:Exception, lan:LanStr) -> str: """ Convert error to language specific string""" @@ -90,6 +101,8 @@ def error_to_str(error:Exception, lan:LanStr) -> str: return lan.MITM_SERVER_ERROR elif isinstance(error, BotNotSupportingMode): return lan.MODEL_NOT_SUPPORT_MODE_ERROR + f' {error.args[0].value}' + elif isinstance(error, UnsupportedMajsoulRule): + return lan.UNSUPPORTED_GAME_RULE_ERROR + f": {', '.join(error.rules)}" elif isinstance(error, requests.exceptions.ConnectionError): return lan.CONNECTION_ERROR + f': {error}' elif isinstance(error, requests.exceptions.ReadTimeout): diff --git a/game/automation.py b/game/automation.py index 1646498..8c748d0 100644 --- a/game/automation.py +++ b/game/automation.py @@ -16,7 +16,7 @@ from common.mj_helper import sort_mjai_tiles, cvt_ms2mjai from common.log_helper import LOGGER from common.settings import Settings -from common.utils import UiState, GAME_MODES +from common.utils import UiState, GAME_MODES, MATCH_GAME_MODES, AUTO_JOIN_ROOMS from .img_proc import ImgTemp, GameVisual from .browser import GameBrowser @@ -92,9 +92,10 @@ class Positions: (14.35, 8.12), # OK button 确定按钮 (6.825, 6.8), # 点击好感度礼物? ] - MENUS = [ - (11.5, 2.75), # Ranked 段位场 - ] + MENUS = { + AUTO_JOIN_ROOMS[0]: (11.5, 2.75), # Ranked 段位场 + AUTO_JOIN_ROOMS[1]: (11.5, 4.65), # Match 比赛场 + } LEVELS = [ (11.5, 3.375), # Bronze 铜之间 @@ -111,6 +112,11 @@ class Positions: (11.6, 7.35), # 3S 三人南 ] + MATCH_LEVELS = LEVELS + MATCH_MODE_BUTTONS = { + '4E': (11.6, 3.325), # 4E 四人东 + } + MJAI_2_MS_TYPE = { @@ -281,6 +287,9 @@ class Automation: """ Convert mjai reaction messages to browser actions, automating the AI actions on Majsoul. Screen positions are calculated using pre-defined constants in 16x9 resolution, and are translated to client resolution before execution""" + END_GAME_CLICK_DELAY_LOWER = 1.2 + END_GAME_CLICK_DELAY_UPPER = 1.8 + def __init__(self, browser: GameBrowser, setting:Settings): if browser is None: raise ValueError("Browser is None") @@ -773,7 +782,6 @@ def on_end_game(self): self.stop_previous() if self.ui_state != UiState.NOT_RUNNING: self.ui_state = UiState.GAME_ENDING - # if auto next. go to lobby, then next def on_exit_lobby(self): """ exit lobby handler""" @@ -794,16 +802,21 @@ def automate_end_game(self): return True def _end_game_iter(self) -> Iterator[ActionStep]: - # generate action steps for exiting a match until main menu tested + # exit the settlement/result flow until the main menu template is visible again while True: res, diff = self.g_v.comp_temp(ImgTemp.MAIN_MENU) if res: # stop on main menu LOGGER.debug("Visual sees main menu with diff %.1f", diff) self.ui_state = UiState.MAIN_MENU break - - yield ActionStepDelay(random.uniform(2,3)) - + + yield ActionStepDelay( + random.uniform( + self.END_GAME_CLICK_DELAY_LOWER, + self.END_GAME_CLICK_DELAY_UPPER, + ) + ) + x,y = Positions.GAMEOVER[0] for step in self.steps_randomized_move_click(x,y): yield step @@ -815,24 +828,40 @@ def automate_join_game(self): if self.st.auto_join_game is False: return False self.stop_previous() - desc = f"Joining game (level={self.st.auto_join_level}, mode={self.st.auto_join_mode})" + desc = ( + f"Joining game (room={self.st.auto_join_room}, " + f"level={self.st.auto_join_level}, mode={self.st.auto_join_mode})" + ) self._task = AutomationTask(self.executor, JOIN_GAME, desc) self._task.start_action_steps(self._join_game_iter(), None) return True def _join_game_iter(self) -> Iterator[ActionStep]: # generate action steps for joining next game - + for step in self._wait_for_main_menu_iter(): + yield step + + if self.st.auto_join_room == AUTO_JOIN_ROOMS[1]: + for step in self._join_match_game_iter(): + yield step + else: + for step in self._join_ranked_game_iter(): + yield step + + def _wait_for_main_menu_iter(self) -> Iterator[ActionStep]: + """Wait until the main menu is visible.""" while True: # Wait for main menu res, diff = self.g_v.comp_temp(ImgTemp.MAIN_MENU) if res: LOGGER.debug("Visual sees main menu with diff %.1f", diff) self.ui_state = UiState.MAIN_MENU break + yield ActionStepDelay(random.uniform(0.5, 1)) - - # click on Ranked Mode - x,y = Positions.MENUS[0] + + def _join_ranked_game_iter(self) -> Iterator[ActionStep]: + """Join the configured ranked lobby and queue mode.""" + x,y = Positions.MENUS[AUTO_JOIN_ROOMS[0]] for step in self.steps_randomized_move_click(x,y): yield step yield ActionStepDelay(random.uniform(0.5, 1.5)) @@ -855,7 +884,32 @@ def _join_game_iter(self) -> Iterator[ActionStep]: mode_idx = GAME_MODES.index(self.st.auto_join_mode) x,y = Positions.MODES[mode_idx] for step in self.steps_randomized_move_click(x,y): - yield step + yield step + + def _join_match_game_iter(self) -> Iterator[ActionStep]: + """Join the configured match lobby and queue its fixed 4E mode.""" + x,y = Positions.MENUS[AUTO_JOIN_ROOMS[1]] + for step in self.steps_randomized_move_click(x,y): + yield step + yield ActionStepDelay(random.uniform(0.5, 1.5)) + + if self.st.auto_join_level >= 3: # activity 3 / casual hall require wheel + wx,wy = Positions.MATCH_LEVELS[1] + for step in self.steps_randomized_move(wx,wy): + yield step + yield ActionStepDelay(random.uniform(0.5, 0.9)) + for step in self.steps_random_wheels(0, 1000): + yield step + yield ActionStepDelay(random.uniform(0.5, 1.0)) + + x,y = Positions.MATCH_LEVELS[self.st.auto_join_level] + for step in self.steps_randomized_move_click(x,y): + yield step + yield ActionStepDelay(random.uniform(0.8, 1.5)) + + x,y = Positions.MATCH_MODE_BUTTONS['4E'] + for step in self.steps_randomized_move_click(x,y): + yield step def decide_lobby_action(self): """ decide what "lobby action" to execute based on current state.""" diff --git a/game/browser.py b/game/browser.py index ac8a0ee..a69f452 100644 --- a/game/browser.py +++ b/game/browser.py @@ -1,4 +1,5 @@ """ Game Broswer class for controlling maj-soul web client operations""" +import base64 import logging import time import threading @@ -15,6 +16,7 @@ class GameBrowser: """ Wrapper for Playwright browser controlling maj-soul operations Browser runs in a thread, and actions are queued to be processed by the thread""" + CLIENT_SIZE_TOLERANCE_PX = 4 def __init__(self, width:int, height:int): """ Set browser with viewport size (width, height)""" @@ -30,12 +32,14 @@ def init_vars(self): """ initialize internal variables""" self.context:BrowserContext = None self.page:Page = None # playwright page, only used by thread + self._page_cdp_session = None self.fps_counter = FPSCounter() # for tracking page info self._page_title:str = None self._last_update_time:float = 0 self.zoomlevel_check:float = None + self.client_size_check:tuple[int, int] | None = None # overlay info self._canvas_id = None # for overlay @@ -45,6 +49,119 @@ def init_vars(self): def __del__(self): self.stop() + def _build_launch_args(self, url:str, enable_chrome_ext:bool, extensions_list:list[str] | None = None) -> list[str]: + """ Build Chromium launch args. + Use app mode to hide browser tabs/address bar while keeping a native title bar.""" + args = [ + "--noerrdialogs", + f"--app={url}", + f"--window-size={self.width},{self.height}", + # Chromium upstream test harnesses use this flag to suppress the + # "Google API keys are missing" infobar. + "--test-type=gpu", + ] + if os.name != "nt": + args.append("--no-sandbox") + if enable_chrome_ext and extensions_list: + args.extend([ + "--disable-extensions-except=" + ",".join(extensions_list), + "--load-extension=" + ",".join(extensions_list), + ]) + return args + + def _build_launch_options(self, url:str, proxy_object:dict | None, enable_chrome_ext:bool, extensions_list:list[str]) -> dict: + """ Build launch kwargs for Playwright Chromium persistent context.""" + launch_options = { + "user_data_dir": utils.sub_folder(Folder.BROWSER_DATA), + "headless": False, + "no_viewport": True, + "proxy": proxy_object, + "ignore_default_args": ["--enable-automation"], + "args": self._build_launch_args(url, enable_chrome_ext, extensions_list), + } + + if os.name == "nt": + launch_options["chromium_sandbox"] = True + + return launch_options + + def _get_primary_page(self, timeout_ms:int=10000) -> Page: + """ Return the main non-extension page created by Chromium app mode.""" + timeout_at = time.time() + timeout_ms / 1000 + while time.time() < timeout_at: + for page in self.context.pages: + if not page.url.startswith("chrome-extension://"): + return page + time.sleep(0.05) + raise RuntimeError("Timed out waiting for Chromium app page") + + def _get_window_metrics(self) -> dict: + """ Return current browser/page size metrics.""" + return self.page.evaluate("""() => ({ + innerWidth: window.innerWidth, + innerHeight: window.innerHeight, + outerWidth: window.outerWidth, + outerHeight: window.outerHeight, + devicePixelRatio: window.devicePixelRatio, + })""") + + def _calibrate_window_bounds(self, max_attempts:int=6): + """ Resize the outer Chromium app window until the page client area matches target size. + + App mode with Playwright Chromium can leave extra client-area space after browser UI + infobars are suppressed, which shows up as a bottom black bar. We keep the content area + exactly at the configured automation resolution so coordinates remain valid. + """ + cdp = self.context.new_cdp_session(self.page) + try: + for _ in range(max_attempts): + metrics = self._get_window_metrics() + delta_w = int(round(self.width - metrics['innerWidth'])) + delta_h = int(round(self.height - metrics['innerHeight'])) + if ( + abs(delta_w) <= self.CLIENT_SIZE_TOLERANCE_PX and + abs(delta_h) <= self.CLIENT_SIZE_TOLERANCE_PX + ): + LOGGER.info( + "Browser client area calibrated: inner=%sx%s outer=%sx%s dpr=%s tolerance=%spx", + metrics['innerWidth'], + metrics['innerHeight'], + metrics['outerWidth'], + metrics['outerHeight'], + metrics['devicePixelRatio'], + self.CLIENT_SIZE_TOLERANCE_PX, + ) + return + + window_info = cdp.send("Browser.getWindowForTarget") + bounds = window_info.get('bounds', {}) + current_outer_w = bounds.get('width', metrics['outerWidth']) + current_outer_h = bounds.get('height', metrics['outerHeight']) + new_bounds = { + 'width': max(100, int(round(current_outer_w + delta_w))), + 'height': max(100, int(round(current_outer_h + delta_h))), + } + cdp.send( + "Browser.setWindowBounds", + { + 'windowId': window_info['windowId'], + 'bounds': new_bounds, + } + ) + time.sleep(0.12) + + metrics = self._get_window_metrics() + LOGGER.warning( + "Browser client area not fully calibrated: inner=%sx%s target=%sx%s tolerance=%spx", + metrics['innerWidth'], + metrics['innerHeight'], + self.width, + self.height, + self.CLIENT_SIZE_TOLERANCE_PX, + ) + finally: + cdp.detach() + def start(self, url:str, proxy:str=None, width:int=None, height:int=None, enable_chrome_ext:bool=False): """ Launch the browser in a thread, and start processing action queue params: @@ -80,57 +197,27 @@ def _run_browser_and_action_queue(self, url:str, proxy:str, enable_chrome_ext:bo proxy_object = None # read all subfolder names from Folder.CRX and form extension list + extensions_list = [] if enable_chrome_ext: extensions_list = list_children(Folder.CHROME_EXT, True, False, True) - # extensions_list = [] - # for root, dirs, files in os.walk(utils.sub_folder(Folder.CHROME_EXT)): - # for extension_dir in dirs: - # extensions_list.append(os.path.join(root, extension_dir)) LOGGER.info('Extensions loaded: %s', extensions_list) - disable_extensions_except_args = "--disable-extensions-except=" + ",".join(extensions_list) - load_extension_args = "--load-extension=" + ",".join(extensions_list) + launch_options = self._build_launch_options(url, proxy_object, enable_chrome_ext, extensions_list) LOGGER.info('Starting Chromium, viewport=%dx%d, proxy=%s', self.width, self.height, proxy) with sync_playwright() as playwright: - if enable_chrome_ext: - try: - # Initilize browser - chromium = playwright.chromium - self.context = chromium.launch_persistent_context( - user_data_dir=utils.sub_folder(Folder.BROWSER_DATA), - headless=False, - viewport={'width': self.width, 'height': self.height}, - proxy=proxy_object, - ignore_default_args=["--enable-automation"], - args=[ - "--noerrdialogs", - "--no-sandbox", - disable_extensions_except_args, - load_extension_args - ] - ) - except Exception as e: - LOGGER.error('Error launching the browser: %s', e, exc_info=True) - return - else: - try: - # Initilize browser - chromium = playwright.chromium - self.context = chromium.launch_persistent_context( - user_data_dir=utils.sub_folder(Folder.BROWSER_DATA), - headless=False, - viewport={'width': self.width, 'height': self.height}, - proxy=proxy_object, - ignore_default_args=["--enable-automation"], - args=["--noerrdialogs", "--no-sandbox"] - ) - except Exception as e: - LOGGER.error('Error launching the browser: %s', e, exc_info=True) - return + try: + chromium = playwright.chromium + self.context = chromium.launch_persistent_context(**launch_options) + except Exception as e: + LOGGER.error('Error launching the browser: %s', e, exc_info=True) + return try: - self.page = self.context.new_page() - self.page.goto(url) + self.page = self._get_primary_page() + if self.page.url != url: + self.page.goto(url) + self._page_cdp_session = self.context.new_cdp_session(self.page) + self._calibrate_window_bounds() except Exception as e: LOGGER.error('Error opening page. Check if certificate is installed. \n%s',e) @@ -149,8 +236,9 @@ def _run_browser_and_action_queue(self, url:str, proxy:str, enable_chrome_ext:bo try: # test if page is stil alive if time.time() - self._last_update_time > 1: self._page_title = self.page.title() - # check zoom level - self.zoomlevel_check = self.page.evaluate("() => window.devicePixelRatio") + metrics = self._get_window_metrics() + self.zoomlevel_check = metrics['devicePixelRatio'] + self.client_size_check = (metrics['innerWidth'], metrics['innerHeight']) self._last_update_time = time.time() except Exception as e: LOGGER.warning("Page error %s. exiting.", e) @@ -538,11 +626,33 @@ def _action_screen_shot(self, res_queue:queue.Queue, time_ms:int=5000): res_queue: queue for saving the image buff data""" if self.is_page_normal(): try: - ss_bytes:BytesIO = self.page.screenshot(timeout=time_ms) + if self._page_cdp_session is None: + self._page_cdp_session = self.context.new_cdp_session(self.page) + payload = self._page_cdp_session.send( + "Page.captureScreenshot", + { + "format": "png", + "fromSurface": True, + "captureBeyondViewport": False, + "optimizeForSpeed": True, + }, + ) + ss_bytes = base64.b64decode(payload["data"]) res_queue.put(ss_bytes) - except Exception as e: - LOGGER.error("Error taking screenshot: %s", e, exc_info=True) - res_queue.put(None) + except Exception as cdp_error: + LOGGER.warning("CDP screenshot failed, falling back to Playwright screenshot: %s", cdp_error) + self._page_cdp_session = None + try: + ss_bytes:BytesIO = self.page.screenshot( + timeout=time_ms, + animations="disabled", + caret="hide", + scale="css", + ) + res_queue.put(ss_bytes) + except Exception as e: + LOGGER.error("Error taking screenshot: %s", e, exc_info=True) + res_queue.put(None) else: res_queue.put(None) LOGGER.debug("Page not loaded, no screenshot") diff --git a/game/game_state.py b/game/game_state.py index 4e9beef..2ce9b9f 100644 --- a/game/game_state.py +++ b/game/game_state.py @@ -8,7 +8,7 @@ from liqi import LiqiProto, LiqiMethod, LiqiAction import common.mj_helper as mj_helper -from common.mj_helper import MjaiType, GameInfo, MJAI_WINDS, ChiPengGang, MSGangType +from common.mj_helper import MjaiType, GameInfo, MJAI_WINDS, ChiPengGang, MSGangType, MSType from common.log_helper import LOGGER from common.utils import GameMode from bot import Bot, reaction_convert_meta @@ -30,6 +30,20 @@ '.lq.NotifyPlayerConnectionState', # ] +REACTABLE_MS_TYPES = { + MSType.dahai, + MSType.chi, + MSType.pon, + MSType.ankan, + MSType.daiminkan, + MSType.kakan, + MSType.reach, + MSType.zimo, + MSType.hora, + MSType.ryukyoku, + MSType.nukidora, +} + class KyokuState: """ data class for kyoku info, will be reset every newround""" def __init__(self) -> None: @@ -64,6 +78,8 @@ def __init__(self, bot:Bot) -> None: ### Game info self.account_id = 0 # Majsoul account id self.mode_id:int = -1 # game mode + self.room_id:int = 0 # friend/custom room id if any + self.contest_uid:int = 0 # match/custom contest uid if any self.seat = 0 # seat index #seat 0 is chiicha (起家; first dealer; first East) #1-2-3 then goes counter-clockwise @@ -83,7 +99,9 @@ def __init__(self, bot:Bot) -> None: self.is_round_started:bool = False """ if any new round has started (so game info is available)""" self.is_game_ended:bool = False # if game has ended - + self.pending_start_kyoku_msg:dict = None + self.pending_start_tsumo_msg:dict = None + def get_game_info(self) -> GameInfo: """ Return game info. Return None if N/A""" if self.is_round_started: @@ -128,15 +146,17 @@ def input(self, liqi_msg: dict) -> dict | None: """ self.is_bot_calculating = True start_time = time.time() - reaction = self._input_inner(liqi_msg) - time_used = time.time() - start_time - if reaction is not None: - # Update last_reaction (not none) and set it to pending - self.last_reaction = reaction - self.last_reaction_pending = True - self.last_reaction_time = time_used - self.is_bot_calculating = False - return reaction + try: + reaction = self._input_inner(liqi_msg) + time_used = time.time() - start_time + if reaction is not None: + # Update last_reaction (not none) and set it to pending + self.last_reaction = reaction + self.last_reaction_pending = True + self.last_reaction_time = time_used + return reaction + finally: + self.is_bot_calculating = False def _input_inner(self, liqi_msg: dict) -> dict | None: liqi_type = liqi_msg['type'] @@ -223,6 +243,10 @@ def ms_sync_game(self, liqi_data:dict) -> dict: Every game start there is sync message (may contain no data)""" self.is_ms_syncing = True LOGGER.debug('Start syncing game') + game_restore = liqi_data.get('gameRestore', {}) + snapshot = game_restore.get('snapshot') + if snapshot: + self._restore_from_snapshot(snapshot) sync_msgs = LiqiProto().parse_syncGame(liqi_data) reacts = [] for msg in sync_msgs: @@ -235,49 +259,211 @@ def ms_sync_game(self, liqi_data:dict) -> dict: return reacts[-1] else: return None - - def ms_auth_game(self, liqi_data:dict) -> dict: - """ Game start, initial info""" - try: - self.mode_id = liqi_data['gameConfig']['meta']['modeId'] - except Exception: - LOGGER.warning("No modeId in liqi_data['gameConfig']['meta']['modeId']") - self.mode_id = -1 - seatList:list = liqi_data['seatList'] - if not seatList: - LOGGER.debug("No seatList in liqi_data, game has likely ended") - self.is_game_ended = True - return None - if len(seatList) == 4: - self.game_mode = GameMode.MJ4P - elif len(seatList) == 3: + def _apply_game_config(self, game_config:dict | None): + """Store game meta fields that differ across ranked / match / contest games.""" + if not game_config: + return + meta = game_config.get('meta', {}) + if 'modeId' in meta: + self.mode_id = meta['modeId'] + if 'roomId' in meta: + self.room_id = meta['roomId'] + if 'contestUid' in meta: + self.contest_uid = meta['contestUid'] + + def _ensure_self_hand_ready(self, action_name:str) -> bool: + """Best-effort guard against cascading hand-state errors after an earlier failure.""" + if self.kyoku_state.my_tehai is not None: + return True + LOGGER.warning("Skip self hand update for %s: hand state not initialized", action_name) + return False + + def _operation_is_reactable(self, data:dict | None) -> bool: + """Return True only when the current liqi operation can be mapped to standard riichi actions.""" + if not data: + return True + operation = data.get('operation') + if not operation or 'operationList' not in operation: + return False + op_list = operation.get('operationList', []) + if len(op_list) == 0: + return False + return any(op.get('type') in REACTABLE_MS_TYPES for op in op_list) + + def _bootstrap_pending_start_kyoku(self, liqi_data_data:dict): + """Delay start_kyoku until activity-room opening animations reveal the actual dora marker.""" + if self.pending_start_kyoku_msg is None: + return + + doras = liqi_data_data.get('doras', []) + dora = liqi_data_data.get('dora') + if not doras and dora: + doras = [dora] + if not doras: + return + + dora_marker = mj_helper.cvt_ms2mjai(doras[0]) + self.pending_start_kyoku_msg['dora_marker'] = dora_marker + self.kyoku_state.doras_ms = [dora_marker] + self.mjai_pending_input_msgs.append(self.pending_start_kyoku_msg) + if self.pending_start_tsumo_msg: + self.mjai_pending_input_msgs.append(self.pending_start_tsumo_msg) + self.pending_start_kyoku_msg = None + self.pending_start_tsumo_msg = None + LOGGER.debug("Deferred start_kyoku bootstrapped with dora %s", dora_marker) + + def _sync_pending_start_hand(self): + """Keep deferred bot bootstrap messages aligned with the latest local hand state.""" + if self.pending_start_kyoku_msg is None: + return + + tehais = self.pending_start_kyoku_msg.get('tehais') + if isinstance(tehais, list) and 0 <= self.seat < len(tehais): + tehais[self.seat] = list(self.kyoku_state.my_tehai) + + if self.pending_start_tsumo_msg is not None and self.pending_start_tsumo_msg.get('actor') == self.seat: + self.pending_start_tsumo_msg['pai'] = self.kyoku_state.my_tsumohai + + LOGGER.debug( + "Synced deferred start_kyoku hand after change: tehai=%s tsumo=%s", + self.kyoku_state.my_tehai, + self.kyoku_state.my_tsumohai, + ) + + def _update_self_hand_after_change(self, in_tiles_ms:list[str], out_tiles_ms:list[str]): + """Apply ActionChangeTile to the local hand state so GUI and autoplay keep working.""" + if not self._ensure_self_hand_ready('ActionChangeTile'): + return + + total_hand = list(self.kyoku_state.my_tehai) + if self.kyoku_state.my_tsumohai is not None: + total_hand.append(self.kyoku_state.my_tsumohai) + old_count = len(total_hand) + + for tile in out_tiles_ms: + tile_mjai = mj_helper.cvt_ms2mjai(tile) + if tile_mjai in total_hand: + total_hand.remove(tile_mjai) + else: + LOGGER.warning("ActionChangeTile removing missing tile %s from %s", tile_mjai, total_hand) + for tile in in_tiles_ms: + total_hand.append(mj_helper.cvt_ms2mjai(tile)) + + total_hand = mj_helper.sort_mjai_tiles(total_hand) + if old_count >= 14: + self.kyoku_state.my_tsumohai = total_hand[-1] + self.kyoku_state.my_tehai = total_hand[:-1] + else: + self.kyoku_state.my_tsumohai = None + self.kyoku_state.my_tehai = total_hand + + self._sync_pending_start_hand() + + def _merge_self_tsumohai(self, action_name:str) -> bool: + """Move the pending draw into hand before self discard/kan/nukidora updates.""" + if not self._ensure_self_hand_ready(action_name): + return False + if self.kyoku_state.my_tsumohai is not None: + self.kyoku_state.my_tehai.append(self.kyoku_state.my_tsumohai) + self.kyoku_state.my_tsumohai = None + return True + + def _init_game_context(self, seat:int, player_count:int): + """Initialize bot/game mode once enough metadata is available.""" + if self.game_mode is not None: + return + if player_count == 4: + self.game_mode = GameMode.MJ4P + elif player_count == 3: self.game_mode = GameMode.MJ3P else: - raise RuntimeError(f"Unexpected seat len:{len(seatList)}") + raise RuntimeError(f"Unexpected seat len:{player_count}") + + self.seat = seat LOGGER.info("Game Mode: %s", self.game_mode.name) - - self.seat = seatList.index(self.account_id) self.mjai_bot.init_bot(self.seat, self.game_mode) - # Start_game has no effect for mjai bot, omit here self.mjai_pending_input_msgs.append( { 'type': MjaiType.START_GAME, 'id': self.seat } - ) + ) self._react_all() + + def _restore_round_from_snapshot(self, snapshot:dict): + """Best-effort round info restoration from GameRestore.snapshot.""" + players = snapshot.get('players', []) + self.kyoku_state = KyokuState() + self.kyoku_state.bakaze = MJAI_WINDS[snapshot['chang']] + oya = snapshot['ju'] + self.kyoku_state.kyoku = oya + 1 + self.kyoku_state.honba = snapshot['ben'] + self.kyoku_state.jikaze = MJAI_WINDS[(self.seat - oya) % 4] + self.kyoku_state.first_round = False + self.kyoku_state.player_reach = [p.get('liqiposition', 0) > 0 for p in players] + if self.seat < len(self.kyoku_state.player_reach): + self.kyoku_state.self_in_reach = self.kyoku_state.player_reach[self.seat] + + doras = snapshot.get('doras', []) + self.kyoku_state.doras_ms = [mj_helper.cvt_ms2mjai(tile) for tile in doras] + + hand_ms = snapshot.get('hands', []) + hand_mjai = [mj_helper.cvt_ms2mjai(tile) for tile in hand_ms] + hand_mjai = mj_helper.sort_mjai_tiles(hand_mjai) + if len(hand_mjai) >= 14: + self.kyoku_state.my_tsumohai = hand_mjai[-1] + self.kyoku_state.my_tehai = hand_mjai[:-1] + else: + self.kyoku_state.my_tsumohai = None + self.kyoku_state.my_tehai = hand_mjai + self.is_round_started = True + + def _restore_from_snapshot(self, snapshot:dict): + """Restore seat/mode/scores/round info when a game starts from enterGame/syncGame.""" + players = snapshot.get('players', []) + if not players: + return + + seat = snapshot.get('indexPlayer') + if seat is not None: + self._init_game_context(seat, len(players)) + + self.player_scores = [p.get('score', 0) for p in players] + if self.game_mode == GameMode.MJ3P and len(self.player_scores) == 3: + self.player_scores = self.player_scores + [0] + self._restore_round_from_snapshot(snapshot) + + def ms_auth_game(self, liqi_data:dict) -> dict: + """ Game start, initial info""" + game_config = liqi_data.get('gameConfig') + self._apply_game_config(game_config) + if self.mode_id == -1: + LOGGER.warning("No modeId in liqi_data['gameConfig']['meta']['modeId']") + + seatList:list = liqi_data['seatList'] + if not seatList: + LOGGER.debug("No seatList in liqi_data, game has likely ended") + self.is_game_ended = True + return None + self._init_game_context(seatList.index(self.account_id), len(seatList)) return None # no reaction for start_game def ms_new_round(self, liqi_data:dict) -> dict: """ Start kyoku """ self.kyoku_state = KyokuState() self.mjai_pending_input_msgs = [] + self.pending_start_kyoku_msg = None + self.pending_start_tsumo_msg = None liqi_data_data = liqi_data['data'] self.kyoku_state.bakaze = MJAI_WINDS[liqi_data_data['chang']] - dora_marker = mj_helper.cvt_ms2mjai(liqi_data_data['doras'][0]) - self.kyoku_state.doras_ms = [dora_marker] + doras = liqi_data_data.get('doras', []) + dora = liqi_data_data.get('dora') + if not doras and dora: + doras = [dora] + dora_marker = mj_helper.cvt_ms2mjai(doras[0]) if doras else None + self.kyoku_state.doras_ms = [dora_marker] if dora_marker else [] self.kyoku_state.honba = liqi_data_data['ben'] oya = liqi_data_data['ju'] # oya is also the seat id of East self.kyoku_state.kyoku = oya + 1 @@ -320,7 +506,6 @@ def ms_new_round(self, liqi_data:dict) -> dict: start_kyoku_msg = { 'type': MjaiType.START_KYOKU, 'bakaze': self.kyoku_state.bakaze, - 'dora_marker': dora_marker, 'honba': self.kyoku_state.honba, 'kyoku': self.kyoku_state.kyoku, 'kyotaku': kyotaku, @@ -328,11 +513,18 @@ def ms_new_round(self, liqi_data:dict) -> dict: 'scores': self.player_scores, 'tehais': tehais_mjai } + self.is_round_started = True + if dora_marker is None: + self.pending_start_kyoku_msg = start_kyoku_msg + self.pending_start_tsumo_msg = tsumo_msg + LOGGER.debug("Deferring start_kyoku until a later action provides doras") + return None + + start_kyoku_msg['dora_marker'] = dora_marker self.mjai_pending_input_msgs.append(start_kyoku_msg) if tsumo_msg: self.mjai_pending_input_msgs.append(tsumo_msg) - - self.is_round_started = True + return self._react_all(liqi_data_data) def ms_action_prototype(self, liqi_data:dict) -> dict: @@ -345,6 +537,12 @@ def ms_action_prototype(self, liqi_data:dict) -> dict: self.kyoku_state.pending_reach_acc = None liqi_data_data = liqi_data['data'] + if liqi_data_name == 'ActionChangeTile': + self._update_self_hand_after_change( + liqi_data_data.get('inTiles', []), + liqi_data_data.get('outTiles', []), + ) + self._bootstrap_pending_start_kyoku(liqi_data_data) if 'data' in liqi_data: # Process dora events # According to mjai.app, in the case of an ankan, the dora event comes first, followed by the tsumo event. @@ -374,6 +572,9 @@ def ms_action_prototype(self, liqi_data:dict) -> dict: } ) return self._react_all(liqi_data_data) + + elif liqi_data_name == 'ActionChangeTile': + return self._react_all(liqi_data_data) # LiqiAction.DiscardTile -> MJAI_TYPE.DAHAI elif liqi_data_name == LiqiAction.DiscardTile: @@ -381,9 +582,8 @@ def ms_action_prototype(self, liqi_data:dict) -> dict: tile_mjai = mj_helper.cvt_ms2mjai(liqi_data_data['tile']) tsumogiri = liqi_data_data['moqie'] if actor == self.seat: # update self hand info - if self.kyoku_state.my_tsumohai: - self.kyoku_state.my_tehai.append(self.kyoku_state.my_tsumohai) - self.kyoku_state.my_tsumohai = None + if not self._merge_self_tsumohai(liqi_data_name): + return None self.kyoku_state.my_tehai.remove(tile_mjai) self.kyoku_state.my_tehai = mj_helper.sort_mjai_tiles(self.kyoku_state.my_tehai) @@ -428,6 +628,8 @@ def ms_action_prototype(self, liqi_data:dict) -> dict: else: consumed_mjai.append(mj_helper.cvt_ms2mjai(liqi_data_data['tiles'][idx])) if actor == self.seat: # update my hand info + if not self._ensure_self_hand_ready(liqi_data_name): + return None for c in consumed_mjai: self.kyoku_state.my_tehai.remove(c) self.kyoku_state.my_tehai = mj_helper.sort_mjai_tiles(self.kyoku_state.my_tehai) @@ -484,8 +686,8 @@ def ms_action_prototype(self, liqi_data:dict) -> dict: consumed_mjai[0] += 'r' if actor == self.seat: # update hand info. ankan is after tsumo, so there is tsumohai - self.kyoku_state.my_tehai.append(self.kyoku_state.my_tsumohai) - self.kyoku_state.my_tsumohai = None + if not self._merge_self_tsumohai(liqi_data_name): + return None for c in consumed_mjai: self.kyoku_state.my_tehai.remove(c) self.kyoku_state.my_tehai = mj_helper.sort_mjai_tiles(self.kyoku_state.my_tehai) @@ -504,8 +706,8 @@ def ms_action_prototype(self, liqi_data:dict) -> dict: consumed_mjai[0] = consumed_mjai[0] + "r" if actor == self.seat: # update hand info. kakan is after tsumo, so there is tsumohai - self.kyoku_state.my_tehai.append(self.kyoku_state.my_tsumohai) - self.kyoku_state.my_tsumohai = None + if not self._merge_self_tsumohai(liqi_data_name): + return None self.kyoku_state.my_tehai.remove(tile_mjai) self.kyoku_state.my_tehai = mj_helper.sort_mjai_tiles(self.kyoku_state.my_tehai) @@ -523,8 +725,8 @@ def ms_action_prototype(self, liqi_data:dict) -> dict: elif liqi_data_name == LiqiAction.BaBei: actor = liqi_data_data['seat'] if actor == self.seat: # update hand info. babei is after tsumo, so there is tsumohai - self.kyoku_state.my_tehai.append(self.kyoku_state.my_tsumohai) - self.kyoku_state.my_tsumohai = None + if not self._merge_self_tsumohai(liqi_data_name): + return None self.kyoku_state.my_tehai.remove('N') self.kyoku_state.my_tehai = mj_helper.sort_mjai_tiles(self.kyoku_state.my_tehai) @@ -541,6 +743,16 @@ def ms_action_prototype(self, liqi_data:dict) -> dict: elif liqi_data_name in LiqiAction.Hule: return self.ms_end_kyoku() + elif liqi_data_name == 'ActionHuleXueZhanMid': + if 'scores' in liqi_data_data: + self.player_scores = liqi_data_data['scores'] + return None + + elif liqi_data_name == 'ActionHuleXueZhanEnd': + if 'scores' in liqi_data_data: + self.player_scores = liqi_data_data['scores'] + return self.ms_end_kyoku() + # LiqiAction.NoTile -> MJAI END_KYOKU elif liqi_data_name == LiqiAction.NoTile: return self.ms_end_kyoku() @@ -597,7 +809,7 @@ def _react_all(self, data=None) -> dict | None: dict: the last reaction(output) from bot, or None """ if data: - if 'operation' not in data or 'operationList' not in data['operation'] or len(data['operation']['operationList']) == 0: + if not self._operation_is_reactable(data): return None try: if len(self.mjai_pending_input_msgs) == 1: diff --git a/game/img_proc.py b/game/img_proc.py index 7cb1800..72acf0e 100644 --- a/game/img_proc.py +++ b/game/img_proc.py @@ -1,7 +1,7 @@ """ image processing and visual analysis for Majsoul game screen""" from enum import Enum, auto import io -from PIL import Image, ImageChops, ImageStat +from PIL import Image, ImageChops, ImageStat, ImageDraw import common.utils as utils from common.utils import Folder from common.log_helper import LOGGER @@ -19,8 +19,12 @@ def img_avg_diff(base_img:Image.Image, input_img:Image.Image, mask_img:Image.Ima Return: float: average pixel difference (only unmasked area) """ - # input_img = Image.open(input_file).convert('RGB') + # operate on copies because masking mutates alpha + base_img = base_img.copy() + input_img = input_img.copy() + # resize input to mask or base img + modified_mask = None if mask_img: img_size = mask_img.size base_img = base_img.resize(img_size, Image.Resampling.LANCZOS) @@ -66,8 +70,107 @@ def __init__(self, browser:GameBrowser) -> None: raise ValueError("Browser is None") self.temp_dict = {} - """ image template dict {ImgTemp: (image_file, mask_file), ...}""" + """ image template dict {ImgTemp: [(image_file, mask_file, threshold), ...], ...}""" self._load_imgs() + + @staticmethod + def _scale_box(box:tuple[int, int, int, int], size:tuple[int, int]) -> tuple[int, int, int, int]: + """Scale a 1280x720 reference box to the current template size.""" + width, height = size + sx = width / 1280 + sy = height / 720 + x1, y1, x2, y2 = box + return ( + int(round(x1 * sx)), + int(round(y1 * sy)), + int(round(x2 * sx)), + int(round(y2 * sy)), + ) + + def _draw_rect(self, draw:ImageDraw.ImageDraw, size:tuple[int, int], box:tuple[int, int, int, int]) -> None: + draw.rectangle(self._scale_box(box, size), fill=255) + + def _draw_border( + self, + draw:ImageDraw.ImageDraw, + size:tuple[int, int], + box:tuple[int, int, int, int], + border_px:int, + ) -> None: + x1, y1, x2, y2 = self._scale_box(box, size) + scale = min(size[0] / 1280, size[1] / 720) + border = max(1, int(round(border_px * scale))) + draw.rectangle((x1, y1, x2, min(y2, y1 + border)), fill=255) + draw.rectangle((x1, max(y1, y2 - border), x2, y2), fill=255) + draw.rectangle((x1, y1, min(x2, x1 + border), y2), fill=255) + draw.rectangle((max(x1, x2 - border), y1, x2, y2), fill=255) + + def _draw_ring( + self, + draw:ImageDraw.ImageDraw, + size:tuple[int, int], + box:tuple[int, int, int, int], + width_px:int, + ) -> None: + scaled_box = self._scale_box(box, size) + scale = min(size[0] / 1280, size[1] / 720) + width = max(1, int(round(width_px * scale))) + draw.ellipse(scaled_box, outline=255, width=width) + + def _build_generic_main_menu_mask(self, size:tuple[int, int]) -> Image.Image: + """Build a language-agnostic main menu mask using only shared UI chrome. + + The CN and JP lobbies differ mainly in text/avatar art. Their outer menu boards, + top currency ornaments and right-side circular buttons share the same layout. + """ + mask = Image.new('L', size, 0) + draw = ImageDraw.Draw(mask) + + # Three large lobby boards: compare only their borders, not the localized text. + board_boxes = [ + (725, 166, 1178, 303), + (756, 320, 1188, 469), + (780, 479, 1193, 631), + ] + for box in board_boxes: + self._draw_border(draw, size, box, 16) + + # Top resource ornaments. + for box in [ + (560, 43, 619, 103), + (771, 43, 830, 103), + ]: + self._draw_ring(draw, size, box, 8) + + for box in [ + (684, 31, 745, 107), + (896, 31, 957, 107), + ]: + self._draw_border(draw, size, box, 8) + + # Top-right gear and vertical tool buttons: compare only the circular frames. + for box in [ + (1216, 8, 1276, 68), + (1217, 132, 1274, 189), + (1217, 225, 1274, 282), + (1217, 317, 1274, 374), + (1217, 409, 1274, 466), + (1217, 501, 1274, 558), + ]: + self._draw_ring(draw, size, box, 8) + + # Small board ornaments around the main menu columns. + for box in [ + (716, 155, 760, 207), + (1138, 142, 1187, 191), + (759, 314, 798, 357), + (1144, 305, 1189, 351), + (781, 477, 821, 522), + (1148, 470, 1195, 517), + ]: + self._draw_rect(draw, size, box) + + return mask def _load_imgs(self) -> None: """ load all template images""" @@ -79,14 +182,19 @@ def _load_imgs(self) -> None: mask_file = utils.sub_file(Folder.RES, mask_file) img_mainmenu = Image.open(img_file).convert('RGB') mask_mainmenu = Image.open(mask_file).convert('L') - self.temp_dict[loc] = (img_mainmenu, mask_mainmenu) + generic_mask = self._build_generic_main_menu_mask(img_mainmenu.size) + self.temp_dict[loc] = [ + (img_mainmenu, mask_mainmenu, 30.0), + (img_mainmenu, generic_mask, 28.0), + ] - def comp_temp(self, tmp:ImgTemp, thres:float=30) -> tuple[bool, float]: + def comp_temp(self, tmp:ImgTemp, thres:float|None=None) -> tuple[bool, float]: """ compare current screen to template params: tmp (ImgTemp): template img to compare to - thres (float): threshold, diff lower than which is considered a match + thres (float): threshold, diff lower than which is considered a match. + If None, use the threshold defined by the template variant. return: bool: True if the current screen matches the template float: average difference between current screen and loc template""" @@ -95,12 +203,17 @@ def comp_temp(self, tmp:ImgTemp, thres:float=30) -> tuple[bool, float]: return False, -1 img_io = io.BytesIO(img_bytes) img_input = Image.open(img_io).convert('RGB') - # if not img_file: - # return False, -1 - base_img, mask = self.temp_dict[tmp] + template_variants:list[tuple[Image.Image, Image.Image, float]] = self.temp_dict[tmp] + best_diff = None try: - diff = img_avg_diff(base_img, img_input, mask) - return diff < thres, diff + for base_img, mask, variant_thres in template_variants: + diff = img_avg_diff(base_img, img_input, mask) + if best_diff is None or diff < best_diff: + best_diff = diff + threshold = thres if thres is not None else variant_thres + if diff < threshold: + return True, diff + return False, best_diff if best_diff is not None else -1 except Exception as e: LOGGER.error("Error in testing template %s: %s", tmp.name, e, exc_info=True) - return False, -1 \ No newline at end of file + return False, -1 diff --git a/gui/main_gui.py b/gui/main_gui.py index 71bbc43..ca7464b 100644 --- a/gui/main_gui.py +++ b/gui/main_gui.py @@ -9,7 +9,7 @@ from tkinter import ttk, messagebox from bot_manager import BotManager, mjai_reaction_2_guide -from common.utils import Folder, GameMode, GAME_MODES, GameClientType +from common.utils import Folder, GameMode, GAME_MODES, MATCH_GAME_MODES, AUTO_JOIN_ROOMS, GameClientType from common.utils import UiState, sub_file, error_to_str from common.log_helper import LOGGER, LogHelper from common.settings import Settings @@ -110,17 +110,38 @@ def _create_widgets(self): # combo boxrd for auto join level and mode _frame = tk.Frame(self.tb2) _frame.pack(**pack_args) - self.auto_join_level_var = tk.StringVar(value=self.st.lan().GAME_LEVELS[self.st.auto_join_level]) - options = self.st.lan().GAME_LEVELS - combo_autojoin_level = ttk.Combobox(_frame, textvariable=self.auto_join_level_var, values=options, state="readonly", width=8) - combo_autojoin_level.grid(row=0, column=0, padx=3, pady=3) - combo_autojoin_level.bind("<>", self._on_autojoin_level_selected) - mode_idx = GAME_MODES.index(self.st.auto_join_mode) - self.auto_join_mode_var = tk.StringVar(value=self.st.lan().GAME_MODES[mode_idx]) - options = self.st.lan().GAME_MODES - combo_autojoin_mode = ttk.Combobox(_frame, textvariable=self.auto_join_mode_var, values=options, state="readonly", width=8) - combo_autojoin_mode.grid(row=1, column=0, padx=3, pady=3) - combo_autojoin_mode.bind("<>", self._on_autojoin_mode_selected) + room_idx = AUTO_JOIN_ROOMS.index(self.st.auto_join_room) + self.auto_join_room_var = tk.StringVar(value=self.st.lan().AUTO_JOIN_ROOMS[room_idx]) + self.combo_autojoin_room = ttk.Combobox( + _frame, + textvariable=self.auto_join_room_var, + values=self.st.lan().AUTO_JOIN_ROOMS, + state="readonly", + width=8 + ) + self.combo_autojoin_room.grid(row=0, column=0, padx=3, pady=3) + self.combo_autojoin_room.bind("<>", self._on_autojoin_room_selected) + self.auto_join_level_var = tk.StringVar() + self.combo_autojoin_level = ttk.Combobox( + _frame, + textvariable=self.auto_join_level_var, + values=[], + state="readonly", + width=10 + ) + self.combo_autojoin_level.grid(row=1, column=0, padx=3, pady=3) + self.combo_autojoin_level.bind("<>", self._on_autojoin_level_selected) + self.auto_join_mode_var = tk.StringVar() + self.combo_autojoin_mode = ttk.Combobox( + _frame, + textvariable=self.auto_join_mode_var, + values=[], + state="readonly", + width=10 + ) + self.combo_autojoin_mode.grid(row=2, column=0, padx=3, pady=3) + self.combo_autojoin_mode.bind("<>", self._on_autojoin_mode_selected) + self._update_autojoin_selectors() # timer self.timer = Timer(self.tb2, tb_ht, sw_ft_sz, self.st.lan().AUTO_JOIN_TIMER) self.timer.set_callback(self.bot_manager.disable_autojoin) # stop autojoin when time is up @@ -181,14 +202,53 @@ def report_callback_exception(self, exc, val, tb): def _on_autojoin_level_selected(self, _event): new_value = self.auto_join_level_var.get() # convert to index - self.st.auto_join_level = self.st.lan().GAME_LEVELS.index(new_value) + self.st.auto_join_level = self._get_autojoin_level_labels().index(new_value) + + def _on_autojoin_room_selected(self, _event): + new_room = self.auto_join_room_var.get() + self.st.auto_join_room = AUTO_JOIN_ROOMS[self.st.lan().AUTO_JOIN_ROOMS.index(new_room)] + if self.st.auto_join_room == AUTO_JOIN_ROOMS[1]: + self.st.auto_join_mode = MATCH_GAME_MODES[0] + self._update_autojoin_selectors() def _on_autojoin_mode_selected(self, _event): new_mode = self.auto_join_mode_var.get() # convert to string - new_mode = self.st.lan().GAME_MODES.index(new_mode) - new_mode = GAME_MODES[new_mode] + mode_labels = self._get_autojoin_mode_labels() + mode_values = self._get_autojoin_mode_values() + new_mode = mode_values[mode_labels.index(new_mode)] self.st.auto_join_mode = new_mode + + def _get_autojoin_level_labels(self) -> list[str]: + if self.st.auto_join_room == AUTO_JOIN_ROOMS[1]: + return self.st.lan().MATCH_LEVELS + return self.st.lan().GAME_LEVELS + + def _get_autojoin_mode_labels(self) -> list[str]: + if self.st.auto_join_room == AUTO_JOIN_ROOMS[1]: + return self.st.lan().MATCH_GAME_MODES + return self.st.lan().GAME_MODES + + def _get_autojoin_mode_values(self) -> list[str]: + if self.st.auto_join_room == AUTO_JOIN_ROOMS[1]: + return MATCH_GAME_MODES + return GAME_MODES + + def _update_autojoin_selectors(self): + """Refresh selector contents for ranked/match auto join.""" + level_labels = self._get_autojoin_level_labels() + mode_labels = self._get_autojoin_mode_labels() + mode_values = self._get_autojoin_mode_values() + + self.combo_autojoin_level.config(values=level_labels, state="readonly") + self.combo_autojoin_mode.config(values=mode_labels, state="readonly") + + self.st.auto_join_level = max(0, min(self.st.auto_join_level, len(level_labels)-1)) + if self.st.auto_join_mode not in mode_values: + self.st.auto_join_mode = mode_values[0] + + self.auto_join_level_var.set(level_labels[self.st.auto_join_level]) + self.auto_join_mode_var.set(mode_labels[mode_values.index(self.st.auto_join_mode)]) def _on_btn_start_browser_clicked(self): diff --git a/gui/settings_window.py b/gui/settings_window.py index d63a81a..360d4ce 100644 --- a/gui/settings_window.py +++ b/gui/settings_window.py @@ -33,6 +33,36 @@ def __init__(self, parent:tk.Frame, setting:Settings): style = ttk.Style(self) GUI_STYLE.set_style_normal(style) self.create_widgets() + + def _server_options(self) -> dict[str, str]: + """Localized Majsoul server options.""" + return { + "CN": self.st.lan().MAJSOUL_CN, + "JP": self.st.lan().MAJSOUL_JP, + "CUSTOM": self.st.lan().MAJSOUL_CUSTOM, + } + + def _selected_server_key(self) -> str: + """Return server key from localized combobox label.""" + selected_name = self.ms_server_var.get() + for key, label in self._server_options().items(): + if label == selected_name: + return key + return self.st.get_majsoul_server() + + def _sync_ms_url_from_server(self, _event=None): + """Refresh the readonly URL field when the server selection changes.""" + server_key = self._selected_server_key() + if getattr(self, "_last_server_key", None) == "CUSTOM": + self._custom_ms_url = self.ms_url_var.get().strip() + + self.ms_url_entry.configure(state="normal") + if server_key == "CUSTOM": + self.ms_url_var.set(self._custom_ms_url) + else: + self.ms_url_var.set(self.st.get_majsoul_server_url(server_key)) + self.ms_url_entry.configure(state="readonly") + self._last_server_key = server_key def create_widgets(self): @@ -67,11 +97,30 @@ def create_widgets(self): # majsoul url cur_row += 1 - _label = ttk.Label(main_frame, text=self.st.lan().MAJSOUL_URL) + _label = ttk.Label(main_frame, text=self.st.lan().MAJSOUL_SERVER) _label.grid(row=cur_row, column=0, **args_label) - self.ms_url_var = tk.StringVar(value=self.st.ms_url) - string_entry = ttk.Entry(main_frame, textvariable=self.ms_url_var, width=std_wid*3) - string_entry.grid(row=cur_row, column=1,columnspan=2, **args_entry) + url_frame = ttk.Frame(main_frame) + url_frame.grid(row=cur_row, column=1, columnspan=2, **args_entry) + server_options = self._server_options() + current_server_key = self.st.get_majsoul_server() + self._custom_ms_url = self.st.ms_url if current_server_key == "CUSTOM" else "" + self._last_server_key = current_server_key + self.ms_server_var = tk.StringVar(value=server_options[current_server_key]) + server_menu = ttk.Combobox( + url_frame, + textvariable=self.ms_server_var, + values=list(server_options.values()), + state="readonly", + width=8, + ) + server_menu.pack(side=tk.LEFT, padx=(0, 6)) + server_menu.bind("<>", self._sync_ms_url_from_server) + + current_url = self.st.ms_url if current_server_key == "CUSTOM" else self.st.get_majsoul_server_url(current_server_key) + self.ms_url_var = tk.StringVar(value=current_url) + self.ms_url_entry = ttk.Entry(url_frame, textvariable=self.ms_url_var, width=std_wid*2 + 2) + self.ms_url_entry.pack(side=tk.LEFT) + self._sync_ms_url_from_server() # extensions self.enable_extension_var = tk.BooleanVar(value=self.st.enable_chrome_ext) auto_launch_entry = ttk.Checkbutton( @@ -261,8 +310,11 @@ def _on_save(self): size_list = self.client_size_var.get().split(' x ') width_new = int(size_list[0]) height_new = int(size_list[1]) - # url - ms_url_new = self.ms_url_var.get() + ms_server_new = self._selected_server_key() + ms_url_new = self.ms_url_var.get().strip() + if ms_server_new == "CUSTOM" and not self.st.valid_url(ms_url_new): + messagebox.showerror("⚠", self.st.lan().MAJSOUL_URL_ERROR_PROMPT) + return # mitm & proxy inject mitm_port_new = int(self.mitm_port_var.get()) @@ -325,7 +377,7 @@ def _on_save(self): self.st.auto_launch_browser = self.auto_launch_var.get() self.st.browser_width = width_new self.st.browser_height = height_new - self.st.ms_url = ms_url_new + self.st.set_majsoul_server(ms_server_new, ms_url_new) self.st.enable_chrome_ext = self.enable_extension_var.get() self.st.mitm_port = mitm_port_new self.st.upstream_proxy = upstream_proxy_new diff --git a/libriichi3p/Put libriichi3p files in this folder b/libriichi3p/Put libriichi3p files in this folder deleted file mode 100644 index e69de29..0000000 diff --git a/libriichi3p/w__init__.py b/libriichi3p/w__init__.py new file mode 100644 index 0000000..b793d2e --- /dev/null +++ b/libriichi3p/w__init__.py @@ -0,0 +1,44 @@ +import sys +import platform + +assert sys.version_info >= (3, 10), f"Python version must be 3.10 or higher" +assert sys.version_info <= (3, 12), f"Python version must be 3.12 or lower" + +if platform.system() == "Windows": + if sys.version_info[1] == 10: + from .libriichi310x8664pcwindowsmsvc import * + elif sys.version_info[1] == 11: + from .libriichi311x8664pcwindowsmsvc import * + elif sys.version_info[1] == 12: + from .libriichi312x8664pcwindowsmsvc import * + else: + raise Exception("Not supported Python version on Windows") +elif platform.system() == "Darwin": + if platform.processor() == "arm": + if sys.version_info[1] == 10: + from .libriichi310aarch64appledarwin import * + elif sys.version_info[1] == 11: + from .libriichi311aarch64appledarwin import * + elif sys.version_info[1] == 12: + from .libriichi312aarch64appledarwin import * + else: + raise Exception("Not supported Python version on macOS") + else: + if sys.version_info[1] == 10: + from .libriichi310x8664appledarwin import * + elif sys.version_info[1] == 11: + from .libriichi311x8664appledarwin import * + elif sys.version_info[1] == 12: + from .libriichi312x8664appledarwin import * + else: + raise Exception("Not supported Python version on macOS") +elif platform.system() == "Linux": + if sys.version_info[1] == 10: + from .libriichi310x8664unknownlinuxgnu import * + elif sys.version_info[1] == 11: + from .libriichi311x8664unknownlinuxgnu import * + elif sys.version_info[1] == 12: + from .libriichi312x8664unknownlinuxgnu import * + else: + raise Exception("Not supported Python version on Linux") + \ No newline at end of file