From c448684c793665bc5e6c4a14a01f86e54df38a03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A3=B4=E7=89=B9?= Date: Mon, 23 Feb 2026 19:57:20 +0000 Subject: [PATCH 1/2] feat(mac): package mortal_mac_v0.2 and stabilize fullscreen/playwright runtime --- bot_manager.py | 1142 ++++++++++++----------- common/lan_str.py | 537 +++++------ common/log_helper.py | 77 +- common/settings.py | 359 +++++--- common/utils.py | 546 ++++++----- game/automation.py | 1741 ++++++++++++++++++----------------- game/browser.py | 1555 +++++++++++++++++++++++-------- gui/help_window.py | 235 +++-- gui/main_gui.py | 854 +++++++++-------- gui/settings_window.py | 798 ++++++++++------ mortal_mac_v0.2.spec | 51 + readme.md | 227 +++-- scripts/generate_mac_app.sh | 155 ++++ scripts/make_dmg_mac.sh | 35 + scripts/make_icns_mac.sh | 34 + 15 files changed, 5069 insertions(+), 3277 deletions(-) create mode 100644 mortal_mac_v0.2.spec create mode 100644 scripts/generate_mac_app.sh create mode 100644 scripts/make_dmg_mac.sh create mode 100644 scripts/make_icns_mac.sh diff --git a/bot_manager.py b/bot_manager.py index b1c6ee5..20e677a 100644 --- a/bot_manager.py +++ b/bot_manager.py @@ -1,237 +1,295 @@ -""" -This file contains the BotManager class, which manages the bot logic, game state, and automation -It also manages the browser and overlay display -The BotManager class is run in a separate thread, and provide interface methods for UI -""" +""" +This file contains the BotManager class, which manages the bot logic, game state, and automation +It also manages the browser and overlay display +The BotManager class is run in a separate thread, and provide interface methods for UI +""" # pylint: disable=broad-exception-caught import time import queue import threading - -from game.browser import GameBrowser -from game.game_state import GameState -from game.automation import Automation, UiState, JOIN_GAME, END_GAME -import mitm -import proxinject -import liqi -from common.mj_helper import MjaiType, GameInfo, MJAI_TILE_2_UNICODE, ActionUnicode, MJAI_TILES_34, MJAI_AKA_DORAS -from common.log_helper import LOGGER -from common.settings import Settings -from common.lan_str import LanStr -from common import utils -from common.utils import FPSCounter -from bot import Bot, get_bot - - +import socket +import sys + +from game.browser import GameBrowser +from game.game_state import GameState +from game.automation import Automation, UiState, JOIN_GAME, END_GAME +import mitm +import proxinject +import liqi +from common.mj_helper import MjaiType, GameInfo, MJAI_TILE_2_UNICODE, ActionUnicode, MJAI_TILES_34, MJAI_AKA_DORAS +from common.log_helper import LOGGER +from common.settings import Settings +from common.lan_str import LanStr +from common import utils +from common.utils import FPSCounter +from bot import Bot, get_bot + + METHODS_TO_IGNORE = [ liqi.LiqiMethod.checkNetworkDelay, liqi.LiqiMethod.heartbeat, liqi.LiqiMethod.loginBeat, + liqi.LiqiMethod.routeRequestConnection, + liqi.LiqiMethod.routeHeartbeat, liqi.LiqiMethod.fetchAccountActivityData, liqi.LiqiMethod.fetchServerTime, ] - -class BotManager: - """ Bot logic manager""" - def __init__(self, setting:Settings) -> None: - self.st = setting - self.game_state:GameState = None - - self.liqi_parser = liqi.LiqiProto() - self.mitm_server:mitm.MitmController = mitm.MitmController() # no domain restrictions for now - self.proxy_injector = proxinject.ProxyInjector() - self.browser = GameBrowser(self.st.browser_width, self.st.browser_height) - self.automation = Automation(self.browser, self.st) - self.bot:Bot = None - + +class BotManager: + """ Bot logic manager""" + def __init__(self, setting:Settings) -> None: + self.st = setting + self.game_state:GameState = None + + self.liqi_parser = liqi.LiqiProto() + self.mitm_server:mitm.MitmController = mitm.MitmController() # no domain restrictions for now + self.proxy_injector = proxinject.ProxyInjector() + self.browser = GameBrowser(self.st.browser_width, self.st.browser_height) + self.automation = Automation(self.browser, self.st) + self.bot:Bot = None + self._thread:threading.Thread = None self._stop_event = threading.Event() self.fps_counter = FPSCounter() - - self.lobby_flow_id:str = None # websocket flow Id for lobby - self.game_flow_id = None # websocket flow that corresponds to the game/match - - self.bot_need_update:bool = True # set this True to update bot in main thread - self.mitm_proxinject_need_update:bool = False # set this True to update mitm and prox inject in main thread - self.is_loading_bot:bool = False # is bot being loaded - self.main_thread_exception:Exception = None # Exception that had stopped the main thread - self.game_exception:Exception = None # game run time error (but does not break main thread) - - - def start(self): - """ Start bot manager thread""" - self._thread = threading.Thread( - target=self._run, - name="BotThread", - daemon=True - ) - self._thread.start() - - - def stop(self, join_thread:bool): - """ Stop bot manager thread""" - self._stop_event.set() - if join_thread: - self._thread.join() - - - def is_running(self) -> bool: - """ return True if bot manager thread is running""" - if self._thread and self._thread.is_alive(): - return True - else: - return False - - - def is_in_game(self) -> bool: - """ return True if the bot is currently in a game """ - if self.game_state: - return True - else: - return False - - - def get_game_info(self) -> GameInfo: - """ Get gameinfo derived from game_state. can be None""" - if self.game_state is None: - return None - - return self.game_state.get_game_info() - - - def is_game_syncing(self) -> bool: - """ is mjai syncing game messages (from disconnection) """ - if self.game_state: - return self.game_state.is_ms_syncing - - - def get_game_error(self) -> Exception: - """ return game error msg if any, or none if not - These are errors that do not break the main thread, but main impact individual games - e.g. game state error / ai bot error - """ - return self.game_exception - - - def get_game_client_type(self) -> utils.GameClientType: - """ return the running game client type. return None if none is running""" - if self.browser.is_running(): - return utils.GameClientType.PLAYWRIGHT - elif self.lobby_flow_id or self.game_flow_id: - return utils.GameClientType.PROXY - else: - return None - + self.active_mitm_port:int = self.st.mitm_port # runtime MITM port (may differ if default port is occupied) + + self.lobby_flow_id:str = None # websocket flow Id for lobby + self.game_flow_id = None # websocket flow that corresponds to the game/match + + self.bot_need_update:bool = True # set this True to update bot in main thread + self.mitm_proxinject_need_update:bool = False # set this True to update mitm and prox inject in main thread + self.is_loading_bot:bool = False # is bot being loaded + self.main_thread_exception:Exception = None # Exception that had stopped the main thread + self.game_exception:Exception = None # game run time error (but does not break main thread) + + + def start(self): + """ Start bot manager thread""" + self._thread = threading.Thread( + target=self._run, + name="BotThread", + daemon=True + ) + self._thread.start() + + + def stop(self, join_thread:bool): + """ Stop bot manager thread""" + self._stop_event.set() + if join_thread: + self._thread.join() + + + def is_running(self) -> bool: + """ return True if bot manager thread is running""" + if self._thread and self._thread.is_alive(): + return True + else: + return False + + + def is_in_game(self) -> bool: + """ return True if the bot is currently in a game """ + if self.game_state: + return True + else: + return False + + + def get_game_info(self) -> GameInfo: + """ Get gameinfo derived from game_state. can be None""" + if self.game_state is None: + return None + + return self.game_state.get_game_info() + + + def is_game_syncing(self) -> bool: + """ is mjai syncing game messages (from disconnection) """ + if self.game_state: + return self.game_state.is_ms_syncing + + + def get_game_error(self) -> Exception: + """ return game error msg if any, or none if not + These are errors that do not break the main thread, but main impact individual games + e.g. game state error / ai bot error + """ + return self.game_exception + + + def get_game_client_type(self) -> utils.GameClientType: + """ return the running game client type. return None if none is running""" + if self.browser.is_running(): + return utils.GameClientType.PLAYWRIGHT + elif self.lobby_flow_id or self.game_flow_id: + return utils.GameClientType.PROXY + else: + return None + def start_browser(self): """ Start the browser thread, open browser window """ ms_url = self.st.ms_url proxy = self.mitm_server.proxy_str + if sys.platform == "darwin": + self.browser.start( + ms_url, + proxy, + self.st.browser_width, + self.st.browser_height, + self.st.enable_chrome_ext, + fullscreen=self.st.browser_fullscreen, + high_quality=self.st.browser_high_quality + ) + return 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""" - 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: - return True - return False - - # mitm restart not working for now. disable this. - # def set_mitm_proxinject_update(self): - # """ restart mitm proxy server""" - # self.mitm_proxinject_need_update = True - - - def set_bot_update(self): - """ mark bot needs update""" - self.bot_need_update = True - - - def is_bot_created(self): - """ return true if self.bot is not None""" - return self.bot is not None - - - def is_bot_calculating(self): - """ return true if bot is calculating""" - if self.game_state and self.game_state.is_bot_calculating: - return True - else: - return False - - - def get_pending_reaction(self) -> dict: - """ returns the pending mjai output reaction (which hasn't been acted on)""" - if self.game_state: - reaction = self.game_state.get_pending_reaction() - return reaction - else: # None - return None - - - def enable_overlay(self): - """ Start the overlay thread""" - LOGGER.debug("Bot Manager enabling overlay") - self.st.enable_overlay = True - - - def disable_overlay(self): - """ disable browser overlay""" - LOGGER.debug("Bot Manager disabling overlay") - self.st.enable_overlay = False - - - def update_overlay(self): - """ update the overlay if conditions are met""" - if self._update_overlay_conditions_met(): - self._update_overlay_guide() - self._update_overlay_botleft() - - - def enable_automation(self): - """ enable automation""" - LOGGER.debug("Bot Manager enabling automation") - self.st.enable_automation = True - self.automation.decide_lobby_action() - - - def disable_automation(self): - """ disable automation""" - LOGGER.debug("Bot Manager disabling automation") - self.st.enable_automation = False - self.automation.stop_previous() - - - def enable_autojoin(self): - """ enable autojoin""" - LOGGER.debug("Enabling Auto Join") - self.st.auto_join_game = True - - - def disable_autojoin(self): - """ disable autojoin""" - LOGGER.debug("Disabling Auto Join") - self.st.auto_join_game = False - # stop any lobby tasks - if self.automation.is_running_execution(): - name, _d = self.automation.running_task_info() - if name in (JOIN_GAME, END_GAME): - self.automation.stop_previous() - + + def is_browser_zoom_off(self): + """ check browser zoom level, return true if zoomlevel is not 1""" + 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: + return True + return False + + # mitm restart not working for now. disable this. + # def set_mitm_proxinject_update(self): + # """ restart mitm proxy server""" + # self.mitm_proxinject_need_update = True + + + def set_bot_update(self): + """ mark bot needs update""" + self.bot_need_update = True + + + def is_bot_created(self): + """ return true if self.bot is not None""" + return self.bot is not None + + + def is_bot_calculating(self): + """ return true if bot is calculating""" + if self.game_state and self.game_state.is_bot_calculating: + return True + else: + return False + + + def get_pending_reaction(self) -> dict: + """ returns the pending mjai output reaction (which hasn't been acted on)""" + if self.game_state: + reaction = self.game_state.get_pending_reaction() + return reaction + else: # None + return None + + + def enable_overlay(self): + """ Start the overlay thread""" + LOGGER.debug("Bot Manager enabling overlay") + self.st.enable_overlay = True + + + def disable_overlay(self): + """ disable browser overlay""" + LOGGER.debug("Bot Manager disabling overlay") + self.st.enable_overlay = False + + + def update_overlay(self): + """ update the overlay if conditions are met""" + if self._update_overlay_conditions_met(): + self._update_overlay_guide() + self._update_overlay_botleft() + + + def enable_automation(self): + """ enable automation""" + LOGGER.debug("Bot Manager enabling automation") + self.st.enable_automation = True + self.automation.decide_lobby_action() + + + def disable_automation(self): + """ disable automation""" + LOGGER.debug("Bot Manager disabling automation") + self.st.enable_automation = False + self.automation.stop_previous() + + + def enable_autojoin(self): + """ enable autojoin""" + LOGGER.debug("Enabling Auto Join") + self.st.auto_join_game = True + + + def disable_autojoin(self): + """ disable autojoin""" + LOGGER.debug("Disabling Auto Join") + self.st.auto_join_game = False + # stop any lobby tasks + if self.automation.is_running_execution(): + name, _d = self.automation.running_task_info() + if name in (JOIN_GAME, END_GAME): + self.automation.stop_previous() + def _create_bot(self): """ create Bot object based on settings""" try: self.is_loading_bot = True - self.bot = None - self.bot = get_bot(self.st) - self.game_exception = None - LOGGER.info("Created bot: %s. Supported Modes: %s", self.bot.name, self.bot.supported_modes) - except Exception as e: - LOGGER.warning("Failed to create bot: %s", e, exc_info=True) + self.bot = None + self.bot = get_bot(self.st) + self.game_exception = None + LOGGER.info("Created bot: %s. Supported Modes: %s", self.bot.name, self.bot.supported_modes) + except Exception as e: + LOGGER.warning("Failed to create bot: %s", e, exc_info=True) self.bot = None self.game_exception = e self.is_loading_bot = False + + def _is_local_port_available(self, port:int) -> bool: + """ return True if local tcp port can be bound """ + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: + probe.settimeout(0.2) + if probe.connect_ex(("127.0.0.1", port)) == 0: + return False + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + try: + sock.bind(("127.0.0.1", port)) + return True + except OSError: + return False + + def _find_random_open_port(self, min_port:int=1000) -> int: + """ ask OS for a random available local tcp port """ + while True: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + port = int(sock.getsockname()[1]) + if port >= min_port: + return port + + def _wait_mitm_ready(self, timeout:float=3.0) -> bool: + """ wait for MITM thread to become available """ + start = time.time() + while time.time() - start < timeout: + if self.mitm_server.proxy_str and self.mitm_server.is_running(): + return True + if self.mitm_server.is_running() is False: + return False + time.sleep(0.05) + return bool(self.mitm_server.proxy_str) and self.mitm_server.is_running() + + def _try_start_mitm(self, port:int, mode:str) -> bool: + """ start mitm with given port and verify startup """ + self.mitm_server.start(port, mode, self.st.upstream_proxy) + if self._wait_mitm_ready(): + self.active_mitm_port = port + return True + self.mitm_server.stop() + return False def _create_mitm_and_proxinject(self): # create mitm and proxinject threads @@ -242,335 +300,355 @@ def _create_mitm_and_proxinject(self): else: mode = mitm.HTTP - self.mitm_server.start(self.st.mitm_port, mode, self.st.upstream_proxy) + preferred_port = self.st.mitm_port + start_port = preferred_port + if not self._is_local_port_available(preferred_port): + start_port = self._find_random_open_port() + LOGGER.warning( + "MITM port %d is occupied. Using port %d for this instance.", + preferred_port, + start_port + ) + started = self._try_start_mitm(start_port, mode) + if not started: + retry_port = self._find_random_open_port() + while retry_port == start_port: + retry_port = self._find_random_open_port() + LOGGER.warning("Failed to start MITM on port %d, retrying port %d", start_port, retry_port) + started = self._try_start_mitm(retry_port, mode) + if not started: + raise utils.MITMException("MITM server failed to start") + LOGGER.info("MITM server running on port %d", self.active_mitm_port) + res = self.mitm_server.install_mitm_cert() if not res: self.main_thread_exception = utils.MitmCertNotInstalled(self.mitm_server.cert_file) if self.st.enable_proxinject: - self.proxy_injector.start(self.st.inject_process_name, "127.0.0.1", self.st.mitm_port) - - - def _run(self): - """ Keep running the main loop (blocking)""" - try: - self._create_mitm_and_proxinject() - if self.st.auto_launch_browser: - self.start_browser() - - while self._stop_event.is_set() is False: # thread main loop - # keep processing majsoul game messages forwarded from mitm server - self.fps_counter.frame() - self._loop_pre_msg() - try: - msg = self.mitm_server.get_message() - self._process_msg(msg) - except queue.Empty: - time.sleep(0.002) - except Exception as e: - LOGGER.error("Error processing msg: %s",e, exc_info=True) - self.game_exception = e - self._loop_post_msg() - - # loop ended, clean up before exit - LOGGER.info("Shutting down browser") - self.browser.stop(True) - LOGGER.info("Shutting down MITM") - self.mitm_server.stop() - if self.proxy_injector.is_running(): - LOGGER.info("Shutting down proxy injector") - self.proxy_injector.stop(True) - LOGGER.info("Bot manager thread ending.") - - except Exception as e: - self.main_thread_exception = e - LOGGER.error("Bot Manager Thread Exception: %s", e, exc_info=True) - - - def _loop_pre_msg(self): - """ things to do every loop before processing msg""" - # update bot if needed - if self.bot_need_update and self.is_in_game() is False: - self._create_bot() - self.bot_need_update = False - - # update mitm if needed: when no one is using mitm - if self.mitm_proxinject_need_update: - if not (self.browser.is_running()): - LOGGER.debug("Updating mitm and proxy injector") - self.proxy_injector.stop(True) - self.mitm_server.stop() - self._create_mitm_and_proxinject() - self.mitm_proxinject_need_update = False - - - def _loop_post_msg(self): - # things to do in every loop after processing msg - # check mitm - if self.mitm_server.is_running() is False: - self.game_exception = utils.MITMException("MITM server stopped") - else: # clear exception - if isinstance(self.game_exception, utils.MITMException): - self.game_exception = None - - # check overlay - if self.browser and self.browser.is_page_normal(): - if self.st.enable_overlay: - if self.browser.is_overlay_working() is False: - LOGGER.debug("Bot manager attempting turning on browser overlay") - self.browser.start_overlay() - # self._update_overlay_guide() - else: - if self.browser.is_overlay_working(): - LOGGER.debug("Bot manager turning off browser overlay") - self.browser.stop_overlay() - - self.automation.automate_retry_pending(self.game_state) # retry failed automation - - if not self.game_exception: # skip on game error - self.automation.decide_lobby_action() - - - def _process_msg(self, msg:mitm.WSMessage): - """ process websocket message from mitm server""" - - if msg.type == mitm.WsType.START: - LOGGER.debug("Websocket Flow started: %s", msg.flow_id) - - elif msg.type == mitm.WsType.END: - LOGGER.debug("Websocket Flow ended: %s", msg.flow_id) - if msg.flow_id == self.game_flow_id: - LOGGER.info("Game flow ended. processing end game") - self._process_end_game() - self.game_flow_id = None - if msg.flow_id == self.lobby_flow_id: - # lobby flow ended - LOGGER.info("Lobby flow ended.") - self.lobby_flow_id = None - self.automation.on_exit_lobby() - - elif msg.type == mitm.WsType.MESSAGE: - # process ws message - try: - liqimsg = self.liqi_parser.parse(msg.content) - except Exception as e: - LOGGER.warning("Failed to parse liqi msg: %s\nError: %s", msg.content, e) - return - liqi_id = liqimsg.get("id") - liqi_type = liqimsg.get('type') - liqi_method = liqimsg.get('method') - # liqi_data = liqimsg['data'] - # liqi_datalen = len(liqimsg['data']) - - if liqi_method in METHODS_TO_IGNORE: - ... - - elif (liqi_type, liqi_method) == (liqi.MsgType.RES, liqi.LiqiMethod.oauth2Login): - # lobby login msg - if self.lobby_flow_id is None: # record first time in lobby - LOGGER.info("Lobby oauth2Login msg: %s", liqimsg) - LOGGER.info("Lobby login done. lobby flow ID = %s", msg.flow_id) - self.lobby_flow_id = msg.flow_id - self.automation.on_lobby_login(liqimsg) - else: - LOGGER.warning("Lobby flow exists %s, ignoring new lobby flow %s", self.lobby_flow_id, msg.flow_id) - - elif (liqi_type, liqi_method) == (liqi.MsgType.REQ, liqi.LiqiMethod.authGame): - # Game Start request msg: found game flow, initialize game state - if self.game_flow_id is None: - LOGGER.info("authGame msg: %s", liqimsg) - 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() - else: - LOGGER.warning("Game flow %s already started. ignoring new game flow %s", self.game_flow_id, msg.flow_id) - - 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) - # if self.game_state.is_game_ended: - # self._process_end_game() - - elif msg.flow_id == self.lobby_flow_id: - LOGGER.debug( - 'Lobby msg(suppressed): id=%s, type=%s, method=%s, len=%d', - liqi_id, liqi_type, liqi_method, len(str(liqimsg))) - - else: - LOGGER.debug('Other msg (ignored): %s', liqimsg) - - def _process_idle_automation(self, liqimsg:dict): - """ do some idle action based on liqi msg""" - liqi_method = liqimsg['method'] - if liqi_method == liqi.LiqiMethod.NotifyGameBroadcast: # reply to emoji - # {'id': -1, 'type': , 'method': '.lq.NotifyGameBroadcast', - # 'data': {'seat': 2, 'content': '{"emo":7}'}} - if liqimsg["data"]["seat"] != self.game_state.seat: # not self - self.automation.automate_send_emoji() - else: # move mouse around randomly - self.automation.automate_idle_mouse_move(0.05) - - def _process_end_game(self): - # End game processes - # self.game_flow_id = None - self.game_state = None - if self.browser: # fix for corner case - self.browser.overlay_clear_guidance() - self.game_exception = None - self.automation.on_end_game() - - - def _update_overlay_conditions_met(self) -> bool: - if not self.st.enable_overlay: - return False - if self.browser is None: - return False - if self.browser.is_page_normal() is False: - return False - return True - - - def _update_overlay_guide(self): - # Update overlay guide given pending reaction - reaction = self.get_pending_reaction() - if reaction: - guide, options = mjai_reaction_2_guide(reaction, 3, self.st.lan()) - self.browser.overlay_update_guidance(guide, self.st.lan().OPTIONS_TITLE, options) - else: - self.browser.overlay_clear_guidance() - - + self.proxy_injector.start(self.st.inject_process_name, "127.0.0.1", self.active_mitm_port) + + + def _run(self): + """ Keep running the main loop (blocking)""" + try: + self._create_mitm_and_proxinject() + if self.st.auto_launch_browser: + self.start_browser() + + while self._stop_event.is_set() is False: # thread main loop + # keep processing majsoul game messages forwarded from mitm server + self.fps_counter.frame() + self._loop_pre_msg() + try: + msg = self.mitm_server.get_message() + self._process_msg(msg) + except queue.Empty: + time.sleep(0.002) + except Exception as e: + LOGGER.error("Error processing msg: %s",e, exc_info=True) + self.game_exception = e + self._loop_post_msg() + + # loop ended, clean up before exit + LOGGER.info("Shutting down browser") + self.browser.stop(True) + LOGGER.info("Shutting down MITM") + self.mitm_server.stop() + if self.proxy_injector.is_running(): + LOGGER.info("Shutting down proxy injector") + self.proxy_injector.stop(True) + LOGGER.info("Bot manager thread ending.") + + except Exception as e: + self.main_thread_exception = e + LOGGER.error("Bot Manager Thread Exception: %s", e, exc_info=True) + + + def _loop_pre_msg(self): + """ things to do every loop before processing msg""" + # update bot if needed + if self.bot_need_update and self.is_in_game() is False: + self._create_bot() + self.bot_need_update = False + + # update mitm if needed: when no one is using mitm + if self.mitm_proxinject_need_update: + if not (self.browser.is_running()): + LOGGER.debug("Updating mitm and proxy injector") + self.proxy_injector.stop(True) + self.mitm_server.stop() + self._create_mitm_and_proxinject() + self.mitm_proxinject_need_update = False + + + def _loop_post_msg(self): + # things to do in every loop after processing msg + # check mitm + if self.mitm_server.is_running() is False: + self.game_exception = utils.MITMException("MITM server stopped") + else: # clear exception + if isinstance(self.game_exception, utils.MITMException): + self.game_exception = None + + # check overlay + if self.browser and self.browser.is_page_normal(): + if self.st.enable_overlay: + if self.browser.is_overlay_working() is False: + LOGGER.debug("Bot manager attempting turning on browser overlay") + self.browser.start_overlay() + # self._update_overlay_guide() + else: + if self.browser.is_overlay_working(): + LOGGER.debug("Bot manager turning off browser overlay") + self.browser.stop_overlay() + + self.automation.automate_retry_pending(self.game_state) # retry failed automation + + if not self.game_exception: # skip on game error + self.automation.decide_lobby_action() + + + def _process_msg(self, msg:mitm.WSMessage): + """ process websocket message from mitm server""" + + if msg.type == mitm.WsType.START: + LOGGER.debug("Websocket Flow started: %s", msg.flow_id) + + elif msg.type == mitm.WsType.END: + LOGGER.debug("Websocket Flow ended: %s", msg.flow_id) + if msg.flow_id == self.game_flow_id: + LOGGER.info("Game flow ended. processing end game") + self._process_end_game() + self.game_flow_id = None + if msg.flow_id == self.lobby_flow_id: + # lobby flow ended + LOGGER.info("Lobby flow ended.") + self.lobby_flow_id = None + self.automation.on_exit_lobby() + + elif msg.type == mitm.WsType.MESSAGE: + # process ws message + try: + liqimsg = self.liqi_parser.parse(msg.content) + except Exception as e: + LOGGER.warning("Failed to parse liqi msg: %s\nError: %s", msg.content, e) + return + liqi_id = liqimsg.get("id") + liqi_type = liqimsg.get('type') + liqi_method = liqimsg.get('method') + # liqi_data = liqimsg['data'] + # liqi_datalen = len(liqimsg['data']) + + if liqi_method in METHODS_TO_IGNORE: + ... + + elif (liqi_type, liqi_method) == (liqi.MsgType.RES, liqi.LiqiMethod.oauth2Login): + # lobby login msg + if self.lobby_flow_id is None: # record first time in lobby + LOGGER.info("Lobby oauth2Login msg: %s", liqimsg) + LOGGER.info("Lobby login done. lobby flow ID = %s", msg.flow_id) + self.lobby_flow_id = msg.flow_id + self.automation.on_lobby_login(liqimsg) + else: + LOGGER.warning("Lobby flow exists %s, ignoring new lobby flow %s", self.lobby_flow_id, msg.flow_id) + + elif (liqi_type, liqi_method) == (liqi.MsgType.REQ, liqi.LiqiMethod.authGame): + # Game Start request msg: found game flow, initialize game state + if self.game_flow_id is None: + LOGGER.info("authGame msg: %s", liqimsg) + 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() + else: + LOGGER.warning("Game flow %s already started. ignoring new game flow %s", self.game_flow_id, msg.flow_id) + + 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) + # if self.game_state.is_game_ended: + # self._process_end_game() + + elif msg.flow_id == self.lobby_flow_id: + LOGGER.debug( + 'Lobby msg(suppressed): id=%s, type=%s, method=%s, len=%d', + liqi_id, liqi_type, liqi_method, len(str(liqimsg))) + + else: + LOGGER.debug('Other msg (ignored): %s', liqimsg) + + def _process_idle_automation(self, liqimsg:dict): + """ do some idle action based on liqi msg""" + liqi_method = liqimsg['method'] + if liqi_method == liqi.LiqiMethod.NotifyGameBroadcast: # reply to emoji + # {'id': -1, 'type': , 'method': '.lq.NotifyGameBroadcast', + # 'data': {'seat': 2, 'content': '{"emo":7}'}} + if liqimsg["data"]["seat"] != self.game_state.seat: # not self + self.automation.automate_send_emoji() + else: # move mouse around randomly + self.automation.automate_idle_mouse_move(0.05) + + def _process_end_game(self): + # End game processes + # self.game_flow_id = None + self.game_state = None + if self.browser: # fix for corner case + self.browser.overlay_clear_guidance() + self.game_exception = None + self.automation.on_end_game() + + + def _update_overlay_conditions_met(self) -> bool: + if not self.st.enable_overlay: + return False + if self.browser is None: + return False + if self.browser.is_page_normal() is False: + return False + return True + + + def _update_overlay_guide(self): + # Update overlay guide given pending reaction + reaction = self.get_pending_reaction() + if reaction: + guide, options = mjai_reaction_2_guide(reaction, 3, self.st.lan()) + self.browser.overlay_update_guidance(guide, self.st.lan().OPTIONS_TITLE, options) + else: + self.browser.overlay_clear_guidance() + + def _update_overlay_botleft(self): # update overlay bottom left text # maj copilot - text = '😸' + self.st.lan().APP_TITLE - - # Model - model_text = '🤖' - if self.is_bot_created(): - model_text += self.st.lan().MODEL + ": " + self.st.model_type - else: - model_text += self.st.lan().MODEL_NOT_LOADED - - # autoplay - if self.st.enable_automation: - autoplay_text = '✅' + self.st.lan().AUTOPLAY + ': ' + self.st.lan().ON - else: - autoplay_text = '⬛' + self.st.lan().AUTOPLAY + ': ' + self.st.lan().OFF - if self.automation.is_running_execution(): - autoplay_text += "🖱️⏳" - - # line 4 - if self.main_thread_exception: - line = '❌' + self.st.lan().MAIN_THREAD_ERROR - elif self.game_exception: - line = '❌' + self.st.lan().GAME_ERROR - elif self.is_browser_zoom_off(): - line = '❌' + self.st.lan().CHECK_ZOOM - elif self.is_game_syncing(): - line = '⏳'+ self.st.lan().SYNCING - elif self.is_bot_calculating(): - line = '⏳'+ self.st.lan().CALCULATING - elif self.is_in_game(): - line = '▶️' + self.st.lan().GAME_RUNNING - else: - line = '🟢' + self.st.lan().READY_FOR_GAME - - text = '\n'.join((text, model_text, autoplay_text, line)) - self.browser.overlay_update_botleft(text) - - - def _do_automation(self, reaction:dict): - # auto play given mjai reaction - if not reaction: # no reaction given - return False - - try: - self.automation.automate_action(reaction, self.game_state) - except Exception as e: - LOGGER.error("Failed to automate action for %s: %s", reaction['type'], e, exc_info=True) - - -def mjai_reaction_2_guide(reaction:dict, max_options:int=3, lan_str:LanStr=LanStr()) -> tuple[str, list]: - """ Convert mjai reaction message to language specific AI guide - params: - reaction(dict): reaction (output) message from mjai bot - max_options(int): number of options to display. 0 to display no options - lan_str(LanString): language specific string constants - - return: - (action_str, options): action_str is the recommended action - options is a list of options (str, float), each option being a tuple of tile str and a percentage number - - sample output for Chinese: - ("立直,切[西]", [("[西]", 0.9111111), ("立直", 0.077777), ("[一索]", 0.0055555)]) - """ - - if reaction is None: - raise ValueError("Input reaction is None") - re_type = reaction['type'] - - def get_tile_str(mjai_tile:str): # unicode + language specific name - return MJAI_TILE_2_UNICODE[mjai_tile] + lan_str.mjai2str(mjai_tile) - pai = reaction.get('pai', None) - if pai: - tile_str = get_tile_str(pai) - - if re_type == MjaiType.DAHAI: - action_str = f"{lan_str.DISCARD}{tile_str}" - elif re_type == MjaiType.NONE: - action_str = ActionUnicode.PASS + lan_str.PASS - elif re_type == MjaiType.PON: - action_str = f"{ActionUnicode.PON}{lan_str.PON}{tile_str}" - elif re_type == MjaiType.CHI: - comsumed = reaction['consumed'] - comsumed_strs = [f"{get_tile_str(x)}" for x in comsumed] - action_str = f"{ActionUnicode.CHI}{lan_str.CHI}{tile_str}({''.join(comsumed_strs)})" - elif re_type == MjaiType.KAKAN: - action_str = f"{ActionUnicode.KAN}{lan_str.KAN}{tile_str}({lan_str.KAKAN})" - elif re_type == MjaiType.DAIMINKAN: - action_str = f"{ActionUnicode.KAN}{lan_str.KAN}{tile_str}({lan_str.DAIMINKAN})" - elif re_type == MjaiType.ANKAN: - tile_str = get_tile_str(reaction['consumed'][1]) - action_str = f"{ActionUnicode.KAN}{lan_str.KAN}{tile_str}({lan_str.ANKAN})" - elif re_type == MjaiType.REACH: # attach reach dahai options - reach_dahai_reaction = reaction['reach_dahai'] - dahai_action_str, _dahai_options = mjai_reaction_2_guide(reach_dahai_reaction, 0, lan_str) - action_str = f"{ActionUnicode.REACH}{lan_str.RIICHI}," + dahai_action_str - elif re_type == MjaiType.HORA: - if reaction['actor'] == reaction['target']: - action_str = f"{ActionUnicode.AGARI}{lan_str.AGARI}({lan_str.TSUMO})" - else: - action_str = f"{ActionUnicode.AGARI}{lan_str.AGARI}({lan_str.RON})" - elif re_type == MjaiType.RYUKYOKU: - action_str = f"{ActionUnicode.RYUKYOKU}{lan_str.RYUKYOKU}" - elif re_type == MjaiType.NUKIDORA: - action_str = f"{lan_str.NUKIDORA}{MJAI_TILE_2_UNICODE['N']}" - else: - action_str = lan_str.mjai2str(re_type) - - options = [] - if max_options > 0 and 'meta_options' in reaction: - # process options. display top options with their weights - meta_options = reaction['meta_options'][:max_options] - if meta_options: - for (code, q) in meta_options: # code is in MJAI_MASK_LIST - if code in MJAI_TILES_34 or code in MJAI_AKA_DORAS: - # if it is a tile - name_str = get_tile_str(code) - elif code == MjaiType.NUKIDORA: - name_str = lan_str.mjai2str(code) + MJAI_TILE_2_UNICODE['N'] - else: - name_str = lan_str.mjai2str(code) - options.append((name_str, q)) - - return (action_str, options) + app_name = "mortal_mac_v0.2" if sys.platform == "darwin" else self.st.lan().APP_TITLE + text = '😸' + app_name + + # Model + model_text = '🤖' + if self.is_bot_created(): + model_text += self.st.lan().MODEL + ": " + self.st.model_type + else: + model_text += self.st.lan().MODEL_NOT_LOADED + + # autoplay + if self.st.enable_automation: + autoplay_text = '✅' + self.st.lan().AUTOPLAY + ': ' + self.st.lan().ON + else: + autoplay_text = '⬛' + self.st.lan().AUTOPLAY + ': ' + self.st.lan().OFF + if self.automation.is_running_execution(): + autoplay_text += "🖱️⏳" + + # line 4 + if self.main_thread_exception: + line = '❌' + self.st.lan().MAIN_THREAD_ERROR + elif self.game_exception: + line = '❌' + self.st.lan().GAME_ERROR + elif self.is_browser_zoom_off(): + line = '❌' + self.st.lan().CHECK_ZOOM + elif self.is_game_syncing(): + line = '⏳'+ self.st.lan().SYNCING + elif self.is_bot_calculating(): + line = '⏳'+ self.st.lan().CALCULATING + elif self.is_in_game(): + line = '▶️' + self.st.lan().GAME_RUNNING + else: + line = '🟢' + self.st.lan().READY_FOR_GAME + + text = '\n'.join((text, model_text, autoplay_text, line)) + self.browser.overlay_update_botleft(text) + + + def _do_automation(self, reaction:dict): + # auto play given mjai reaction + if not reaction: # no reaction given + return False + + try: + self.automation.automate_action(reaction, self.game_state) + except Exception as e: + LOGGER.error("Failed to automate action for %s: %s", reaction['type'], e, exc_info=True) + + +def mjai_reaction_2_guide(reaction:dict, max_options:int=3, lan_str:LanStr=LanStr()) -> tuple[str, list]: + """ Convert mjai reaction message to language specific AI guide + params: + reaction(dict): reaction (output) message from mjai bot + max_options(int): number of options to display. 0 to display no options + lan_str(LanString): language specific string constants + + return: + (action_str, options): action_str is the recommended action + options is a list of options (str, float), each option being a tuple of tile str and a percentage number + + sample output for Chinese: + ("立直,切[西]", [("[西]", 0.9111111), ("立直", 0.077777), ("[一索]", 0.0055555)]) + """ + + if reaction is None: + raise ValueError("Input reaction is None") + re_type = reaction['type'] + + def get_tile_str(mjai_tile:str): # unicode + language specific name + return MJAI_TILE_2_UNICODE[mjai_tile] + lan_str.mjai2str(mjai_tile) + pai = reaction.get('pai', None) + if pai: + tile_str = get_tile_str(pai) + + if re_type == MjaiType.DAHAI: + action_str = f"{lan_str.DISCARD}{tile_str}" + elif re_type == MjaiType.NONE: + action_str = ActionUnicode.PASS + lan_str.PASS + elif re_type == MjaiType.PON: + action_str = f"{ActionUnicode.PON}{lan_str.PON}{tile_str}" + elif re_type == MjaiType.CHI: + comsumed = reaction['consumed'] + comsumed_strs = [f"{get_tile_str(x)}" for x in comsumed] + action_str = f"{ActionUnicode.CHI}{lan_str.CHI}{tile_str}({''.join(comsumed_strs)})" + elif re_type == MjaiType.KAKAN: + action_str = f"{ActionUnicode.KAN}{lan_str.KAN}{tile_str}({lan_str.KAKAN})" + elif re_type == MjaiType.DAIMINKAN: + action_str = f"{ActionUnicode.KAN}{lan_str.KAN}{tile_str}({lan_str.DAIMINKAN})" + elif re_type == MjaiType.ANKAN: + tile_str = get_tile_str(reaction['consumed'][1]) + action_str = f"{ActionUnicode.KAN}{lan_str.KAN}{tile_str}({lan_str.ANKAN})" + elif re_type == MjaiType.REACH: # attach reach dahai options + reach_dahai_reaction = reaction['reach_dahai'] + dahai_action_str, _dahai_options = mjai_reaction_2_guide(reach_dahai_reaction, 0, lan_str) + action_str = f"{ActionUnicode.REACH}{lan_str.RIICHI}," + dahai_action_str + elif re_type == MjaiType.HORA: + if reaction['actor'] == reaction['target']: + action_str = f"{ActionUnicode.AGARI}{lan_str.AGARI}({lan_str.TSUMO})" + else: + action_str = f"{ActionUnicode.AGARI}{lan_str.AGARI}({lan_str.RON})" + elif re_type == MjaiType.RYUKYOKU: + action_str = f"{ActionUnicode.RYUKYOKU}{lan_str.RYUKYOKU}" + elif re_type == MjaiType.NUKIDORA: + action_str = f"{lan_str.NUKIDORA}{MJAI_TILE_2_UNICODE['N']}" + else: + action_str = lan_str.mjai2str(re_type) + + options = [] + if max_options > 0 and 'meta_options' in reaction: + # process options. display top options with their weights + meta_options = reaction['meta_options'][:max_options] + if meta_options: + for (code, q) in meta_options: # code is in MJAI_MASK_LIST + if code in MJAI_TILES_34 or code in MJAI_AKA_DORAS: + # if it is a tile + name_str = get_tile_str(code) + elif code == MjaiType.NUKIDORA: + name_str = lan_str.mjai2str(code) + MJAI_TILE_2_UNICODE['N'] + else: + name_str = lan_str.mjai2str(code) + options.append((name_str, q)) + + return (action_str, options) diff --git a/common/lan_str.py b/common/lan_str.py index 565f627..e41abf1 100644 --- a/common/lan_str.py +++ b/common/lan_str.py @@ -1,272 +1,281 @@ -"""Language string constants""" - -class LanStr: - """ String constants for default language (English) """ - LANGUAGE_NAME = 'English' - - # GUI - APP_TITLE = 'Mahjong Copilot' - START_BROWSER = "Start Web Client" - WEB_OVERLAY = "Overlay" - AUTOPLAY = "Autoplay" - AUTO_JOIN_GAME = "Auto Join" - AUTO_JOIN_TIMER = "Auto Join Timer" - OPEN_LOG_FILE = "Open Log File" - SETTINGS = "Settings" - HELP = "Help" - LOADING = "Loading..." - EXIT = "Exit" - EIXT_CONFIRM = "Are you sure you want to exit?" - AI_OUTPUT = 'AI Guidance' - GAME_INFO = 'Game Info' - ON = "On" - OFF = "Off" - - # help - DOWNLOAD_UPDATE = "Download Update" - START_UPDATE = "Update & Restart" - CHECK_FOR_UPDATE = "Check Update" - CHECKING_UPDATE = "Checking for new update..." - UPDATE_AVAILABLE = "New update available" - NO_UPDATE_FOUND = "No new update found" - DOWNLOADING = "Downloading..." - UNZIPPING = "Unzipping..." - UPDATE_PREPARED = "Update prepared. Click the button to update and restart." - - ### Settings - SAVE = "Save" - CANCEL = "Cancel" - SETTINGS_TIPS = "A restart is needed to apply MITM related settings" - AUTO_LAUNCH_BROWSER = "Auto Launch Browser" - MITM_PORT = "MITM Server Port" +"""Language string constants""" + +class LanStr: + """ String constants for default language (English) """ + LANGUAGE_NAME = 'English' + + # GUI + APP_TITLE = 'Mahjong Copilot' + START_BROWSER = "Start Web Client" + WEB_OVERLAY = "Overlay" + AUTOPLAY = "Autoplay" + AUTO_JOIN_GAME = "Auto Join" + AUTO_JOIN_TIMER = "Auto Join Timer" + OPEN_LOG_FILE = "Open Log File" + SETTINGS = "Settings" + HELP = "Help" + LOADING = "Loading..." + EXIT = "Exit" + EIXT_CONFIRM = "Are you sure you want to exit?" + AI_OUTPUT = 'AI Guidance' + GAME_INFO = 'Game Info' + ON = "On" + OFF = "Off" + + # help + DOWNLOAD_UPDATE = "Download Update" + START_UPDATE = "Update & Restart" + CHECK_FOR_UPDATE = "Check Update" + CHECKING_UPDATE = "Checking for new update..." + UPDATE_AVAILABLE = "New update available" + NO_UPDATE_FOUND = "No new update found" + DOWNLOADING = "Downloading..." + UNZIPPING = "Unzipping..." + UPDATE_PREPARED = "Update prepared. Click the button to update and restart." + + ### Settings + SAVE = "Save" + CANCEL = "Cancel" + SETTINGS_TIPS = "A restart is needed to apply MITM related settings" + AUTO_LAUNCH_BROWSER = "Auto Launch Browser" + MITM_PORT = "MITM Server Port" UPSTREAM_PROXY = "Upstream Proxy" CLIENT_SIZE = "Client Size" + BROWSER_FULLSCREEN = "Browser Fullscreen" + BROWSER_HIGH_QUALITY = "High Quality Mode" MAJSOUL_URL = "Majsoul URL" - ENABLE_CHROME_EXT = "Enable Chrome Extensioins" - LANGUAGE = "Display Language" - CLIENT_INJECT_PROXY = "Auto Proxy Majsoul Windows Client" - MODEL_TYPE = "AI Model Type" - AI_MODEL_FILE = "Local Model File (4P)" - AI_MODEL_FILE_3P = "Local Model File (3P)" - AKAGI_OT_URL = "AkagiOT Server URL" - AKAGI_OT_APIKEY = "AkagiOT API Key" - MJAPI_URL = "MJAPI Server URL" - MJAPI_USER = "MJAPI User" - MJAPI_USAGE = "API Usage" - MJAPI_SECRET = "MJAPI Secret" - MJAPI_MODEL_SELECT = "MJAPI Model Select" - LOGIN_TO_REFRESH = "Log in to refresh" - MITM_PORT_ERROR_PROMPT = "Invalid MITM Port (must between 1000~65535)" - # autoplay - AUTO_PLAY_SETTINGS = "Autoplay Settings" - AUTO_IDLE_MOVE = "Idle Mouse Move" - DRAG_DAHAI = "Mouse drag dahai" - RANDOM_CHOICE = "Randomize AI Choice" - REPLY_EMOJI_CHANCE = "Reply Emoji Rate" - RANDOM_DELAY_RANGE = "Base Delay Range (sec)" - GAME_LEVELS = ["Bronze", "Silver", "Gold", "Jade", "Throne"] + ENABLE_CHROME_EXT = "Enable Chrome Extensioins" + LANGUAGE = "Display Language" + CLIENT_INJECT_PROXY = "Auto Proxy Majsoul Windows Client" + MODEL_TYPE = "AI Model Type" + AI_MODEL_FILE = "Local Model File (4P)" + AI_MODEL_FILE_3P = "Local Model File (3P)" + AKAGI_OT_URL = "AkagiOT Server URL" + AKAGI_OT_APIKEY = "AkagiOT API Key" + MJAPI_URL = "MJAPI Server URL" + MJAPI_USER = "MJAPI User" + MJAPI_USAGE = "API Usage" + MJAPI_SECRET = "MJAPI Secret" + MJAPI_MODEL_SELECT = "MJAPI Model Select" + LOGIN_TO_REFRESH = "Log in to refresh" + MITM_PORT_ERROR_PROMPT = "Invalid MITM Port (must between 1000~65535)" + # autoplay + AUTO_PLAY_SETTINGS = "Autoplay Settings" + AUTO_IDLE_MOVE = "Idle Mouse Move" + DRAG_DAHAI = "Mouse drag dahai" + RANDOM_CHOICE = "Randomize AI Choice" + REPLY_EMOJI_CHANCE = "Reply Emoji Rate" + RANDOM_DELAY_RANGE = "Short Delay Range (sec)" + GAME_LEVELS = ["Bronze", "Silver", "Gold", "Jade", "Throne"] GAME_MODES = ["4-P East","4-P South","3-P East","3-P South"] MOUSE_RANDOM_MOVE = "Randomize Move" - - # Status - MAIN_THREAD = "Main Thread" - MITM_SERVICE = "MITM Service" - BROWSER = "Browser" - PROXY_CLIENT = "Proxy Client" - GAME_RUNNING = "Game Running" - GAME_ERROR = "Game Error!" - SYNCING = "Syncing..." - CALCULATING = "Calculating..." - READY_FOR_GAME = "Ready" - GAME_STARTING = "Game Starting" - KYOKU = "Kyoku" - HONBA = "Honba" - MODEL = "Model" - MODEL_NOT_LOADED = "Model not loaded" - MODEL_LOADING = "Loading Model..." - MAIN_MENU = "Main Menu" - GAME_ENDING = "Game Ending" - GAME_NOT_RUNNING = "Not Launched" - # errors - LOCAL_MODEL_ERROR = "Local Model Loading Error!" - MITM_SERVER_ERROR = "MITM Service Error!" - MITM_CERT_NOT_INSTALLED = "Run as admin or manually install MITM cert." - MAIN_THREAD_ERROR = "Main Thread Error!" - MODEL_NOT_SUPPORT_MODE_ERROR = "Model not supporting game mode" - CONNECTION_ERROR = "Network Connection Error" - BROWSER_ZOOM_OFF = r"Set Browser Zoom level to 100% !" - CHECK_ZOOM = "Browser Zoom!" - # Reaction/Actions - PASS = "Skip" - DISCARD = "Discard" - CHI = "Chi" - PON = "Pon" - KAN = "Kan" - KAKAN = "Kakan" - DAIMINKAN = "Daiminkan" - ANKAN = "Ankan" - RIICHI = "Riichi" - AGARI = "Agari" - TSUMO = "Tsumo" - RON = "Ron" - RYUKYOKU = "Ryukyoku" - NUKIDORA = "Nukidora" - OPTIONS_TITLE = "Options:" - - MJAI_2_STR = { - '1m': '1 Man', '2m': '2 Man', '3m': '3 Man', '4m': '4 Man', '5m': '5 Man', - '6m': '6 Man', '7m': '7 Man', '8m': '8 Man', '9m': '9 Man', - '1p': '1 Pin', '2p': '2 Pin', '3p': '3 Pin', '4p': '4 Pin', '5p': '5 Pin', - '6p': '6 Pin', '7p': '7 Pin', '8p': '8 Pin', '9p': '9 Pin', - '1s': '1 Sou', '2s': '2 Sou', '3s': '3 Sou', '4s': '4 Sou', '5s': '5 Sou', - '6s': '6 Sou', '7s': '7 Sou', '8s': '8 Sou', '9s': '9 Sou', - 'E': 'East', 'S': 'South', 'W': 'West', 'N': 'North', - 'C': 'Chun', 'F': 'Hatsu', 'P': 'Haku', - '5mr': 'Red 5 Man', '5pr': 'Red 5 Pin', '5sr': 'Red 5 Sou', - 'reach': 'Riichi', 'chi_low': 'Chi Low', 'chi_mid': 'Chi Mid', 'chi_high': 'Chi High', 'pon': 'Pon', 'kan_select':'Kan', - 'hora': 'Agari', 'ryukyoku': 'Ryukyoku', 'none':'Skip', 'nukidora':'Nukidora' - } - - def mjai2str(self, mjai_option:str) -> str: - """ convert mjai option/tile to string in this language""" - if mjai_option not in self.MJAI_2_STR: - return mjai_option - return self.MJAI_2_STR[mjai_option] - - -class LanStrZHS(LanStr): - """ String constants for Chinese Simplified""" - LANGUAGE_NAME = '简体中文' - - # GUI - APP_TITLE = '麻将 Copilot' - START_BROWSER = "启动网页客户端" - WEB_OVERLAY = "网页 HUD" - AUTOPLAY = "自动打牌" - AUTO_JOIN_GAME = "自动加入" - AUTO_JOIN_TIMER = "自动加入定时停止" - OPEN_LOG_FILE = "打开日志文件" - SETTINGS = "设置" - HELP = "帮助" - LOADING = "加载中..." - EXIT = "退出" - EIXT_CONFIRM = "确定退出程序?" - AI_OUTPUT = 'AI 提示' - GAME_INFO = '游戏信息' - ON = "开" - OFF = "关" - - # help - DOWNLOAD_UPDATE = "下载更新" - START_UPDATE = "开始更新" - UPDATE_AVAILABLE = "有新的更新可用" - CHECK_FOR_UPDATE = "检查更新" - CHECKING_UPDATE = "正在检查更新..." - NO_UPDATE_FOUND = "未发现更新" - UNZIPPING = "解压中..." - DOWNLOADING = "下载中..." - UPDATE_PREPARED = "更新已准备好。点击按钮更新并重启。" - - # Settings - SAVE = "保存" - CANCEL = "取消" - SETTINGS_TIPS = "MITM 代理相关设置项重启后生效" - MITM_PORT = "MITM 服务端口" + REAL_MOUSE = "Real Mouse" + REAL_MOUSE_SPEED = "Real Mouse Speed (px/s)" + REAL_MOUSE_JITTER = "Move Jitter (px)" + REAL_MOUSE_CLICK_OFFSET = "Click Offset Radius (px)" + + # Status + MAIN_THREAD = "Main Thread" + MITM_SERVICE = "MITM Service" + BROWSER = "Browser" + PROXY_CLIENT = "Proxy Client" + GAME_RUNNING = "Game Running" + GAME_ERROR = "Game Error!" + SYNCING = "Syncing..." + CALCULATING = "Calculating..." + READY_FOR_GAME = "Ready" + GAME_STARTING = "Game Starting" + KYOKU = "Kyoku" + HONBA = "Honba" + MODEL = "Model" + MODEL_NOT_LOADED = "Model not loaded" + MODEL_LOADING = "Loading Model..." + MAIN_MENU = "Main Menu" + GAME_ENDING = "Game Ending" + GAME_NOT_RUNNING = "Not Launched" + # errors + LOCAL_MODEL_ERROR = "Local Model Loading Error!" + MITM_SERVER_ERROR = "MITM Service Error!" + MITM_CERT_NOT_INSTALLED = "Run as admin or manually install MITM cert." + MAIN_THREAD_ERROR = "Main Thread Error!" + MODEL_NOT_SUPPORT_MODE_ERROR = "Model not supporting game mode" + CONNECTION_ERROR = "Network Connection Error" + BROWSER_ZOOM_OFF = r"Set Browser Zoom level to 100% !" + CHECK_ZOOM = "Browser Zoom!" + # Reaction/Actions + PASS = "Skip" + DISCARD = "Discard" + CHI = "Chi" + PON = "Pon" + KAN = "Kan" + KAKAN = "Kakan" + DAIMINKAN = "Daiminkan" + ANKAN = "Ankan" + RIICHI = "Riichi" + AGARI = "Agari" + TSUMO = "Tsumo" + RON = "Ron" + RYUKYOKU = "Ryukyoku" + NUKIDORA = "Nukidora" + OPTIONS_TITLE = "Options:" + + MJAI_2_STR = { + '1m': '1 Man', '2m': '2 Man', '3m': '3 Man', '4m': '4 Man', '5m': '5 Man', + '6m': '6 Man', '7m': '7 Man', '8m': '8 Man', '9m': '9 Man', + '1p': '1 Pin', '2p': '2 Pin', '3p': '3 Pin', '4p': '4 Pin', '5p': '5 Pin', + '6p': '6 Pin', '7p': '7 Pin', '8p': '8 Pin', '9p': '9 Pin', + '1s': '1 Sou', '2s': '2 Sou', '3s': '3 Sou', '4s': '4 Sou', '5s': '5 Sou', + '6s': '6 Sou', '7s': '7 Sou', '8s': '8 Sou', '9s': '9 Sou', + 'E': 'East', 'S': 'South', 'W': 'West', 'N': 'North', + 'C': 'Chun', 'F': 'Hatsu', 'P': 'Haku', + '5mr': 'Red 5 Man', '5pr': 'Red 5 Pin', '5sr': 'Red 5 Sou', + 'reach': 'Riichi', 'chi_low': 'Chi Low', 'chi_mid': 'Chi Mid', 'chi_high': 'Chi High', 'pon': 'Pon', 'kan_select':'Kan', + 'hora': 'Agari', 'ryukyoku': 'Ryukyoku', 'none':'Skip', 'nukidora':'Nukidora' + } + + def mjai2str(self, mjai_option:str) -> str: + """ convert mjai option/tile to string in this language""" + if mjai_option not in self.MJAI_2_STR: + return mjai_option + return self.MJAI_2_STR[mjai_option] + + +class LanStrZHS(LanStr): + """ String constants for Chinese Simplified""" + LANGUAGE_NAME = '简体中文' + + # GUI + APP_TITLE = '麻将 Copilot' + START_BROWSER = "启动网页客户端" + WEB_OVERLAY = "网页 HUD" + AUTOPLAY = "自动打牌" + AUTO_JOIN_GAME = "自动加入" + AUTO_JOIN_TIMER = "自动加入定时停止" + OPEN_LOG_FILE = "打开日志文件" + SETTINGS = "设置" + HELP = "帮助" + LOADING = "加载中..." + EXIT = "退出" + EIXT_CONFIRM = "确定退出程序?" + AI_OUTPUT = 'AI 提示' + GAME_INFO = '游戏信息' + ON = "开" + OFF = "关" + + # help + DOWNLOAD_UPDATE = "下载更新" + START_UPDATE = "开始更新" + UPDATE_AVAILABLE = "有新的更新可用" + CHECK_FOR_UPDATE = "检查更新" + CHECKING_UPDATE = "正在检查更新..." + NO_UPDATE_FOUND = "未发现更新" + UNZIPPING = "解压中..." + DOWNLOADING = "下载中..." + UPDATE_PREPARED = "更新已准备好。点击按钮更新并重启。" + + # Settings + SAVE = "保存" + CANCEL = "取消" + SETTINGS_TIPS = "MITM 代理相关设置项重启后生效" + MITM_PORT = "MITM 服务端口" UPSTREAM_PROXY = "上游代理" CLIENT_SIZE = "客户端大小" + BROWSER_FULLSCREEN = "浏览器全屏模式" + BROWSER_HIGH_QUALITY = "高画质模式" MAJSOUL_URL = "雀魂网址" - ENABLE_CHROME_EXT = "启用浏览器插件" - LANGUAGE = "显示语言" - CLIENT_INJECT_PROXY = "自动代理雀魂 Windows 客户端" - MODEL_TYPE = "AI 模型类型" - AI_MODEL_FILE = "本地模型文件(四麻)" - AI_MODEL_FILE_3P = "本地模型文件(三麻)" - AKAGI_OT_URL = "AkagiOT 服务器地址" - AKAGI_OT_APIKEY = "AkagiOT API Key" - MJAPI_URL = "MJAPI 服务器地址" - MJAPI_USER = "MJAPI 用户名" - MJAPI_USAGE = "API 用量" - MJAPI_SECRET = "MJAPI 密钥" - MJAPI_MODEL_SELECT = "MJAPI 模型选择" - LOGIN_TO_REFRESH = "登录后刷新" - AUTO_LAUNCH_BROWSER = "自动启动浏览器" - MITM_PORT_ERROR_PROMPT = "错误的 MITM 服务端口(必须是1000~65535)" - # autoplay - AUTO_PLAY_SETTINGS = "自动打牌设置" - AUTO_IDLE_MOVE = "鼠标空闲移动" - DRAG_DAHAI = "鼠标拖拽出牌" - RANDOM_CHOICE = "AI 选项随机化(去重)" - REPLY_EMOJI_CHANCE = "回复表情概率" - - RANDOM_DELAY_RANGE = "基础延迟随机范围(秒)" - GAME_LEVELS = ["铜之间", "银之间", "金之间", "玉之间", "王座之间"] - GAME_MODES = ["四人东","四人南","三人东","三人南"] - MOUSE_RANDOM_MOVE = "鼠标移动随机化" - - # Status - MAIN_THREAD = "主程序" - MITM_SERVICE = "MITM 服务" - BROWSER = "浏览器" - PROXY_CLIENT = "代理客户端" - GAME_RUNNING = "对局进行中" - GAME_ERROR = "对局发生错误!" - SYNCING = "同步中…" - CALCULATING = "计算中…" - READY_FOR_GAME = "等待游戏" - GAME_STARTING = "准备开始" - KYOKU = "局" - HONBA = "本场" - MODEL = "模型" - MODEL_NOT_LOADED = "模型未加载" - MODEL_LOADING = "正在加载模型..." - MAIN_MENU = "游戏主菜单" - GAME_ENDING = "游戏结束" - GAME_NOT_RUNNING = "未启动" - #error - LOCAL_MODEL_ERROR = "本地模型加载错误!" - MITM_CERT_NOT_INSTALLED = "以管理员运行或手动安装 MITM 证书" - MITM_SERVER_ERROR = "MITM 服务错误!" - MAIN_THREAD_ERROR = "主进程发生错误!" - MODEL_NOT_SUPPORT_MODE_ERROR = "模型不支持游戏模式" - CONNECTION_ERROR = "网络连接错误" - BROWSER_ZOOM_OFF = r"请将浏览器缩放设置成 100% 以正常运行!" - CHECK_ZOOM = "浏览器缩放错误!" - - # Reaction/Actions - PASS = "跳过" - DISCARD = "切" - CHI = "吃" - PON = "碰" - KAN = "杠" - KAKAN = "加杠" - DAIMINKAN = "大明杠" - ANKAN = "暗杠" - RIICHI = "立直" - AGARI = "和牌" - TSUMO = "自摸" - RON = "荣和" - RYUKYOKU = "流局" - NUKIDORA = "拔北" - OPTIONS_TITLE = "候选项:" - - MJAI_2_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': '北', - 'C': '中', 'F': '發', 'P': '白', - '5mr': '赤伍萬', '5pr': '赤伍筒', '5sr': '赤伍索', - 'reach': '立直', 'chi_low': '吃-低', 'chi_mid': '吃-中', 'chi_high': '吃-高', 'pon': '碰', 'kan_select':'杠', - 'hora': '和牌', 'ryukyoku': '流局', 'none': '跳过', 'nukidora':'拔北' - } - - - -LAN_OPTIONS:dict[str, LanStr] = { - 'EN': LanStr(), - 'ZHS': LanStrZHS(), -} -""" dict of {language code: LanString instance}""" + ENABLE_CHROME_EXT = "启用浏览器插件" + LANGUAGE = "显示语言" + CLIENT_INJECT_PROXY = "自动代理雀魂 Windows 客户端" + MODEL_TYPE = "AI 模型类型" + AI_MODEL_FILE = "本地模型文件(四麻)" + AI_MODEL_FILE_3P = "本地模型文件(三麻)" + AKAGI_OT_URL = "AkagiOT 服务器地址" + AKAGI_OT_APIKEY = "AkagiOT API Key" + MJAPI_URL = "MJAPI 服务器地址" + MJAPI_USER = "MJAPI 用户名" + MJAPI_USAGE = "API 用量" + MJAPI_SECRET = "MJAPI 密钥" + MJAPI_MODEL_SELECT = "MJAPI 模型选择" + LOGIN_TO_REFRESH = "登录后刷新" + AUTO_LAUNCH_BROWSER = "自动启动浏览器" + MITM_PORT_ERROR_PROMPT = "错误的 MITM 服务端口(必须是1000~65535)" + # autoplay + AUTO_PLAY_SETTINGS = "自动打牌设置" + AUTO_IDLE_MOVE = "鼠标空闲移动" + DRAG_DAHAI = "鼠标拖拽出牌" + RANDOM_CHOICE = "AI 选项随机化(去重)" + REPLY_EMOJI_CHANCE = "回复表情概率" + + RANDOM_DELAY_RANGE = "短延迟范围(秒)" + GAME_LEVELS = ["铜之间", "银之间", "金之间", "玉之间", "王座之间"] + GAME_MODES = ["四人东","四人南","三人东","三人南"] + MOUSE_RANDOM_MOVE = "鼠标移动随机化" + REAL_MOUSE = "真实鼠标" + + # Status + MAIN_THREAD = "主程序" + MITM_SERVICE = "MITM 服务" + BROWSER = "浏览器" + PROXY_CLIENT = "代理客户端" + GAME_RUNNING = "对局进行中" + GAME_ERROR = "对局发生错误!" + SYNCING = "同步中…" + CALCULATING = "计算中…" + READY_FOR_GAME = "等待游戏" + GAME_STARTING = "准备开始" + KYOKU = "局" + HONBA = "本场" + MODEL = "模型" + MODEL_NOT_LOADED = "模型未加载" + MODEL_LOADING = "正在加载模型..." + MAIN_MENU = "游戏主菜单" + GAME_ENDING = "游戏结束" + GAME_NOT_RUNNING = "未启动" + #error + LOCAL_MODEL_ERROR = "本地模型加载错误!" + MITM_CERT_NOT_INSTALLED = "以管理员运行或手动安装 MITM 证书" + MITM_SERVER_ERROR = "MITM 服务错误!" + MAIN_THREAD_ERROR = "主进程发生错误!" + MODEL_NOT_SUPPORT_MODE_ERROR = "模型不支持游戏模式" + CONNECTION_ERROR = "网络连接错误" + BROWSER_ZOOM_OFF = r"请将浏览器缩放设置成 100% 以正常运行!" + CHECK_ZOOM = "浏览器缩放错误!" + + # Reaction/Actions + PASS = "跳过" + DISCARD = "切" + CHI = "吃" + PON = "碰" + KAN = "杠" + KAKAN = "加杠" + DAIMINKAN = "大明杠" + ANKAN = "暗杠" + RIICHI = "立直" + AGARI = "和牌" + TSUMO = "自摸" + RON = "荣和" + RYUKYOKU = "流局" + NUKIDORA = "拔北" + OPTIONS_TITLE = "候选项:" + + MJAI_2_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': '北', + 'C': '中', 'F': '發', 'P': '白', + '5mr': '赤伍萬', '5pr': '赤伍筒', '5sr': '赤伍索', + 'reach': '立直', 'chi_low': '吃-低', 'chi_mid': '吃-中', 'chi_high': '吃-高', 'pon': '碰', 'kan_select':'杠', + 'hora': '和牌', 'ryukyoku': '流局', 'none': '跳过', 'nukidora':'拔北' + } + + + +LAN_OPTIONS:dict[str, LanStr] = { + 'EN': LanStr(), + 'ZHS': LanStrZHS(), +} +""" dict of {language code: LanString instance}""" diff --git a/common/log_helper.py b/common/log_helper.py index 6e993c6..879ed83 100644 --- a/common/log_helper.py +++ b/common/log_helper.py @@ -1,34 +1,37 @@ """ Logging helper functions """ import datetime import logging +import os import queue from .utils import Folder, sub_file - -DEFAULT_LOGGER_NAME = 'majsoul_copilot' -LOGGER = logging.getLogger(DEFAULT_LOGGER_NAME) + +DEFAULT_LOGGER_NAME = 'majsoul_copilot' +LOGGER = logging.getLogger(DEFAULT_LOGGER_NAME) class LogHelper: - """ Log helper""" - log_file_name:str = None - initialized:bool = False - @staticmethod + """ Log helper""" + log_file_name:str = None + initialized:bool = False + @staticmethod def config_logging(file_prefix:str=DEFAULT_LOGGER_NAME, console=True, file=True): - """ Initialize logging format/output. Run once. - params: - file_prefix(str): prefix of the log file name - console (bool): if output to console - file (bool): if output to file - """ - if LogHelper.initialized: - LOGGER.warning("Logger %s already initialized", LOGGER.name) - return - + """ Initialize logging format/output. Run once. + params: + file_prefix(str): prefix of the log file name + console (bool): if output to console + file (bool): if output to file + """ + if LogHelper.initialized: + LOGGER.warning("Logger %s already initialized", LOGGER.name) + return + logger = LOGGER - logger.setLevel(logging.DEBUG) + level_name = os.environ.get("MJ_LOG_LEVEL", "INFO").upper() + log_level = getattr(logging, level_name, logging.INFO) + logger.setLevel(log_level) formatter = log_formatter() if console: console_handler = logging.StreamHandler() - console_handler.setLevel(logging.DEBUG) + console_handler.setLevel(log_level) console_handler.setFormatter(formatter) logger.addHandler(console_handler) @@ -36,28 +39,30 @@ def config_logging(file_prefix:str=DEFAULT_LOGGER_NAME, console=True, file=True) file_name = file_prefix + '_' + dt_string() + '.log' LogHelper.log_file_name = sub_file(Folder.LOG, file_name) file_handler = logging.FileHandler(LogHelper.log_file_name, encoding='utf-8') - file_handler.setLevel(logging.DEBUG) + file_handler.setLevel(log_level) file_handler.setFormatter(formatter) logger.addHandler(file_handler) - - LogHelper.initialized = True - -def log_formatter() -> str: - """ return the default log formatter""" - return logging.Formatter('%(asctime)s %(levelname)s [%(threadName)s]%(filename)s:%(lineno)d | %(message)s') - -def dt_string() -> str: - """ return datetime string""" - return datetime.datetime.now().strftime(r'%Y-%m-%d_%H-%M-%S') - + + LogHelper.initialized = True + +def log_formatter() -> str: + """ return the default log formatter""" + return logging.Formatter('%(asctime)s %(levelname)s [%(threadName)s]%(filename)s:%(lineno)d | %(message)s') + +def dt_string() -> str: + """ return datetime string""" + return datetime.datetime.now().strftime(r'%Y-%m-%d_%H-%M-%S') + class QueueHandler(logging.Handler): - """ Log handler to send logging records to a thread-safe queue """ + """ Log handler to send logging records to a thread-safe queue """ def __init__(self, log_queue:queue.Queue): super().__init__() self.log_queue = log_queue formatter = log_formatter() - self.setLevel(logging.DEBUG) + level_name = os.environ.get("MJ_LOG_LEVEL", "INFO").upper() + log_level = getattr(logging, level_name, logging.INFO) + self.setLevel(log_level) self.setFormatter(formatter) - - def emit(self, record): - self.log_queue.put(record) \ No newline at end of file + + def emit(self, record): + self.log_queue.put(record) diff --git a/common/settings.py b/common/settings.py index 397a277..4f4369a 100644 --- a/common/settings.py +++ b/common/settings.py @@ -1,159 +1,264 @@ """ Settings file and options """ import json +import pathlib +import shutil +import sys from typing import Callable from .log_helper import LOGGER from .lan_str import LanStr, LAN_OPTIONS from . import utils - -DEFAULT_SETTING_FILE = 'settings.json' - + +DEFAULT_SETTING_FILE = 'settings.json' + class Settings: - """ Settings class to load and save settings to json file""" - def __init__(self, json_file:str=DEFAULT_SETTING_FILE) -> None: - self._json_file = json_file - self._settings_dict:dict = self.load_json() - # read settings or set default values - # variable names must match keys in json, for saving later - - # UI settings - self.update_url:str = self._get_value("update_url", "https://update.mjcopilot.com", self.valid_url) # not shown + """ Settings class to load and save settings to json file""" + def __init__(self, json_file:str=DEFAULT_SETTING_FILE) -> None: + self._json_file = json_file + self._settings_dict:dict = self.load_json() + # read settings or set default values + # variable names must match keys in json, for saving later + + # UI settings + self.update_url:str = self._get_value("update_url", "https://update.mjcopilot.com", self.valid_url) # not shown self.auto_launch_browser:bool = self._get_value("auto_launch_browser", False, self.valid_bool) self.gui_set_dpi:bool = self._get_value("gui_set_dpi", True, self.valid_bool) 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.browser_fullscreen:bool = self._get_value("browser_fullscreen", False, self.valid_bool) + self.browser_high_quality:bool = self._get_value( + "browser_high_quality", sys.platform == "darwin", self.valid_bool) self.ms_url:str = self._get_value("ms_url", "https://game.maj-soul.com/1/",self.valid_url) 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 - self.enable_proxinject:bool = self._get_value("enable_proxinject", False, self.valid_bool) - self.inject_process_name:str = self._get_value("inject_process_name", "jantama_mahjongsoul") - self.language:str = self._get_value("language", list(LAN_OPTIONS.keys())[-1], self.valid_language) # language code - self.enable_overlay:bool = self._get_value("enable_overlay", True, self.valid_bool) # not shown - - # AI Model settings - self.model_type:str = self._get_value("model_type", "Local") - """ model type: local, mjapi""" - # for local model - self.model_file:str = self._get_value("model_file", "mortal.pth") - self.model_file_3p:str = self._get_value("model_file_3p", "mortal_3p.pth") - # akagi ot model - self.akagi_ot_url:str = self._get_value("akagi_ot_url", "") - self.akagi_ot_apikey:str = self._get_value("akagi_ot_apikey", "") - # for mjapi - self.mjapi_url:str = self._get_value("mjapi_url", "https://mjai.7xcnnw11phu.eu.org", self.valid_url) - self.mjapi_user:str = self._get_value("mjapi_user", "") - self.mjapi_secret:str = self._get_value("mjapi_secret", "") - self.mjapi_models:list = self._get_value("mjapi_models",[]) - self.mjapi_model_select:str = self._get_value("mjapi_model_select","baseline") - - # Automation settings - self.enable_automation:bool = self._get_value("enable_automation", False, self.valid_bool) - self.auto_idle_move:bool = self._get_value("auto_idle_move", False, self.valid_bool) - self.auto_random_move:bool = self._get_value("auto_random_move", False, self.valid_bool) - self.auto_reply_emoji_rate:float = self._get_value("auto_reply_emoji_rate", 0.3, lambda x: 0 <= x <= 1) + 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 + self.enable_proxinject:bool = self._get_value("enable_proxinject", False, self.valid_bool) + self.inject_process_name:str = self._get_value("inject_process_name", "jantama_mahjongsoul") + self.language:str = self._get_value("language", list(LAN_OPTIONS.keys())[-1], self.valid_language) # language code + self.enable_overlay:bool = self._get_value("enable_overlay", True, self.valid_bool) # not shown + + # AI Model settings + self.model_type:str = self._get_value("model_type", "Local") + """ model type: local, mjapi""" + # for local model + self.model_file:str = self._get_value("model_file", "mortal.pth") + self.model_file_3p:str = self._get_value("model_file_3p", "mortal_3p.pth") + # akagi ot model + self.akagi_ot_url:str = self._get_value("akagi_ot_url", "") + self.akagi_ot_apikey:str = self._get_value("akagi_ot_apikey", "") + # for mjapi + self.mjapi_url:str = self._get_value("mjapi_url", "https://mjai.7xcnnw11phu.eu.org", self.valid_url) + self.mjapi_user:str = self._get_value("mjapi_user", "") + self.mjapi_secret:str = self._get_value("mjapi_secret", "") + self.mjapi_models:list = self._get_value("mjapi_models",[]) + self.mjapi_model_select:str = self._get_value("mjapi_model_select","baseline") + + # Automation settings + self.enable_automation:bool = self._get_value("enable_automation", False, self.valid_bool) + self.auto_idle_move:bool = self._get_value("auto_idle_move", False, self.valid_bool) + self.auto_random_move:bool = self._get_value("auto_random_move", False, self.valid_bool) + self.auto_reply_emoji_rate:float = self._get_value("auto_reply_emoji_rate", 0.3, lambda x: 0 <= x <= 1) self.auto_emoji_intervel:float = self._get_value("auto_emoji_intervel", 5.0, lambda x: 1.0 < x < 30.0) self.auto_dahai_drag:bool = self._get_value("auto_dahai_drag", True, self.valid_bool) + self.use_real_mouse:bool = self._get_value("use_real_mouse", False, self.valid_bool) + self.real_mouse_speed_pps:float = self._get_value( + "real_mouse_speed_pps", 2200.0, lambda x: 300 <= x <= 20000) + self.real_mouse_jitter_px:float = self._get_value( + "real_mouse_jitter_px", 2.0, lambda x: 0 <= x <= 20) + self.real_mouse_click_offset_px:float = self._get_value( + "real_mouse_click_offset_px", 2.0, lambda x: 0 <= x <= 30) self.ai_randomize_choice:int = self._get_value("ai_randomize_choice", 1, lambda x: 0 <= x <= 5) - self.delay_random_lower:float = self._get_value("delay_random_lower", 1, lambda x: 0 <= x ) - self.delay_random_upper:float = self._get_value( - "delay_random_upper",max(2, self.delay_random_lower), lambda x: x >= self.delay_random_lower) - self.auto_retry_interval:float = self._get_value("auto_retry_interval", 1.5, lambda x: 0.5 < x < 30.0) # not shown - + self.delay_random_lower:float = self._get_value("delay_random_lower", 1, lambda x: 0 <= x ) + self.delay_random_upper:float = self._get_value( + "delay_random_upper",max(2, self.delay_random_lower), lambda x: x >= self.delay_random_lower) + 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_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) - + + self._apply_platform_defaults() + self._seed_models_from_bundle() + self._sync_local_model_files() self.save_json() LOGGER.info("Settings initialized and saved to %s", self._json_file) - + + def _apply_platform_defaults(self): + """Apply platform-specific defaults after loading persisted settings.""" + if sys.platform != "darwin": + return + # macOS minimal build: fixed language/model and no proxy/real-mouse features. + self.language = "ZHS" + self.model_type = "Local" + self.use_real_mouse = False + self.enable_proxinject = False + self.upstream_proxy = "" + self.enable_chrome_ext = False + self.auto_launch_browser = False + + def _seed_models_from_bundle(self): + """Copy bundled models into writable macOS model directory on first launch.""" + if sys.platform != "darwin": + return + if not getattr(sys, "frozen", False): + return + try: + bundle_models_dir = pathlib.Path(sys.executable).resolve().parent / utils.Folder.MODEL + if not bundle_models_dir.is_dir(): + return + dst_dir = pathlib.Path(utils.sub_folder(utils.Folder.MODEL)) + copied:list[str] = [] + for src in sorted(bundle_models_dir.glob("*.pth"), key=lambda p: p.name.lower()): + dst = dst_dir / src.name + if dst.exists(): + continue + shutil.copy2(src, dst) + copied.append(src.name) + if copied: + LOGGER.info("Seeded %d bundled model(s): %s", len(copied), ", ".join(copied)) + except Exception as e: # pylint: disable=broad-except + LOGGER.warning("Failed to seed bundled models: %s", e) + + def _sync_local_model_files(self): + """Auto-detect local model files on startup and keep selections valid.""" + if self.model_type != "Local": + return + + model_dir = pathlib.Path(utils.sub_folder(utils.Folder.MODEL)) + model_files = sorted( + [f.name for f in model_dir.iterdir() if f.is_file() and f.suffix.lower() == ".pth"], + key=str.lower + ) + if not model_files: + return + + def exists_in_model_dir(name:str) -> bool: + return bool(name) and (model_dir / name).is_file() + + def looks_like_3p(name:str) -> bool: + lowered = name.lower() + three_p_keys = ("3p", "3-p", "sanma", "3player", "3_player", "3mahjong", "三麻", "三人") + return any(k in lowered for k in three_p_keys) + + placeholder_names = {"mortal.pth", "mortal_3p.pth"} + three_p_candidates = [m for m in model_files if looks_like_3p(m)] + four_p_candidates = [m for m in model_files if not looks_like_3p(m)] or list(model_files) + + preferred_four_p = [m for m in four_p_candidates if m not in placeholder_names] or four_p_candidates + preferred_three_p = [m for m in three_p_candidates if m not in placeholder_names] or three_p_candidates + + if (not exists_in_model_dir(self.model_file)) or (self.model_file in placeholder_names): + new_4p = preferred_four_p[0] + if self.model_file != new_4p: + LOGGER.info("Auto-selected 4P local model: %s", new_4p) + self.model_file = new_4p + + # If no obvious 3P model exists, keep 3P empty to avoid loading a 4P file as 3P. + if (not exists_in_model_dir(self.model_file_3p)) or (self.model_file_3p in placeholder_names): + new_3p = preferred_three_p[0] if preferred_three_p else "" + if self.model_file_3p != new_3p: + if new_3p: + LOGGER.info("Auto-selected 3P local model: %s", new_3p) + else: + LOGGER.info("No 3P local model detected; 3P model selection cleared.") + self.model_file_3p = new_3p + def load_json(self) -> dict: """ Load settings from json file into dict""" try: - full = utils.sub_file(".", self._json_file) - with open(full, 'r',encoding='utf-8') as file: + full = self._settings_file() + # Be tolerant of UTF-8 BOM (common on Windows editors/PowerShell). + with open(full, 'r',encoding='utf-8-sig') as file: settings_dict:dict = json.load(file) - except Exception as e: - LOGGER.warning("Error loading settings. Will use defaults. Error: %s", e) - settings_dict = {} - - return settings_dict - + except Exception as e: + LOGGER.warning("Error loading settings. Will use defaults. Error: %s", e) + settings_dict = {} + + return settings_dict + def save_json(self): """ Save settings into json file""" # save all non-private variables (not starting with "_") into dict settings_to_save = {key: value for key, value in self.__dict__.items() if not key.startswith('_') and not callable(value)} - with open(self._json_file, 'w', encoding='utf-8') as file: + with open(self._settings_file(), 'w', encoding='utf-8') as file: json.dump(settings_to_save, file, indent=4, separators=(', ', ': ')) - - def _get_value(self, key:str, default_value:any, validator:Callable[[any],bool]=None) -> any: - """ Get value from settings dictionary, or return default_value if error""" - try: - value = self._settings_dict[key] - if not validator: - return value - if validator(value): - return value - else: - LOGGER.warning("setting %s uses default value '%s' because original value '%s' is invalid" - , key, default_value, value) - return default_value - except Exception as e: - LOGGER.warning("setting '%s' use default value '%s' due to error: %s", key, default_value,e) - return default_value - - def lan(self) -> LanStr: - """ return the LanString instance""" - return LAN_OPTIONS[self.language] - - ### Validate functions: return true if the value is valid - - def valid_language(self, lan_code:str): - """ return True if given language code is valid""" - return (lan_code in LAN_OPTIONS) - - def valid_mitm_port(self, port:int): - """ return true if port number if valid""" - if 1000 <= port <= 65535: - return True - else: - return False - - def valid_bool(self, value): - """ return true if value is bool""" - if isinstance(value,bool): - return True - else: - return False - - def valid_username(self, username:str) -> bool: - """ return true if username valid""" - if username: - if len(username) > 1: - return True - else: - return False - - def valid_game_level(self, level:int) -> bool: - """ return true if game level is valid""" - if 0 <= level <= 4: - # 0 Bronze 1 Silver 2 Gold 3 Jade 4 Throne - return True - else: - return False - - def valid_game_mode(self, mode:str) -> bool: - """ return true if game mode is valid""" - if mode in utils.GAME_MODES: - return True - else: - return False - - 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 \ No newline at end of file + + def _settings_file(self) -> str: + """Return settings.json path for current platform.""" + if sys.platform == "darwin": + base = pathlib.Path.home() / "Library" / "Application Support" / "mortal_mac_v0.2" + base.mkdir(parents=True, exist_ok=True) + return str((base / self._json_file).resolve()) + return utils.sub_file(".", self._json_file) + + def _get_value(self, key:str, default_value:any, validator:Callable[[any],bool]=None) -> any: + """ Get value from settings dictionary, or return default_value if error""" + try: + value = self._settings_dict[key] + if not validator: + return value + if validator(value): + return value + else: + LOGGER.warning("setting %s uses default value '%s' because original value '%s' is invalid" + , key, default_value, value) + return default_value + except Exception as e: + LOGGER.warning("setting '%s' use default value '%s' due to error: %s", key, default_value,e) + return default_value + + def lan(self) -> LanStr: + """ return the LanString instance""" + return LAN_OPTIONS[self.language] + + ### Validate functions: return true if the value is valid + + def valid_language(self, lan_code:str): + """ return True if given language code is valid""" + return (lan_code in LAN_OPTIONS) + + def valid_mitm_port(self, port:int): + """ return true if port number if valid""" + if 1000 <= port <= 65535: + return True + else: + return False + + def valid_bool(self, value): + """ return true if value is bool""" + if isinstance(value,bool): + return True + else: + return False + + def valid_username(self, username:str) -> bool: + """ return true if username valid""" + if username: + if len(username) > 1: + return True + else: + return False + + def valid_game_level(self, level:int) -> bool: + """ return true if game level is valid""" + if 0 <= level <= 4: + # 0 Bronze 1 Silver 2 Gold 3 Jade 4 Throne + return True + else: + return False + + def valid_game_mode(self, mode:str) -> bool: + """ return true if game mode is valid""" + if mode in utils.GAME_MODES: + return True + else: + return False + + 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 diff --git a/common/utils.py b/common/utils.py index 6a6f71b..fe6d3f7 100644 --- a/common/utils.py +++ b/common/utils.py @@ -1,152 +1,176 @@ -""" Common/utility methods -no logging in this file because logging might not have been initialized yet -""" - -from enum import Enum, auto -import pathlib -import sys -import ctypes -import time +""" Common/utility methods +no logging in this file because logging might not have been initialized yet +""" + +from enum import Enum, auto +import pathlib +import sys +import ctypes +import time import subprocess import threading import random import string from cryptography import x509 from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes import requests - -from .lan_str import LanStr - -# Constants -WEBSITE = "https://mjcopilot.com" - -MAJSOUL_DOMAINS = [ - "maj-soul.com", # China - "majsoul.com", # old? - "mahjongsoul.com", # Japan - "yo-star.com" # English -] - -class Folder: - """ Folder name consts""" - MODEL = "models" - BROWSER_DATA = "browser_data" - RES = 'resources' - LOG = 'log' - MITM_CONF = 'mitm_config' - PROXINJECT = 'proxinject' - UPDATE = "update" - TEMP = 'temp' - CHROME_EXT = 'chrome_ext' - - -class GameClientType(Enum): - """ Game client type""" - PLAYWRIGHT = auto() # playwright browser - PROXY = auto() # other client through mitm proxy - - -class GameMode(Enum): - """ Game Modes for bots/models""" - MJ4P = "4P" - MJ3P = "3P" - - -# for automation -GAME_MODES = ['4E', '4S', '3E', '3S'] - - -class UiState(Enum): - """ UI State for the game""" - NOT_RUNNING = 0 - MAIN_MENU = 1 - IN_GAME = 10 - GAME_ENDING = 20 - - -# === Exceptions === -class LocalModelException(Exception): - """ Exception for model file error""" - -class MITMException(Exception): - """ Exception for MITM error""" - -class MitmCertNotInstalled(Exception): - """ mitm certificate not installed""" - -class BotNotSupportingMode(Exception): - """ Bot not supporting current mode""" - def __init__(self, mode:GameMode): - super().__init__(mode) - - -def error_to_str(error:Exception, lan:LanStr) -> str: - """ Convert error to language specific string""" - if isinstance(error, LocalModelException): - return lan.LOCAL_MODEL_ERROR - elif isinstance(error, MitmCertNotInstalled): - return lan.MITM_CERT_NOT_INSTALLED + f"{error.args}" - elif isinstance(error, MITMException): - return lan.MITM_SERVER_ERROR - elif isinstance(error, BotNotSupportingMode): - return lan.MODEL_NOT_SUPPORT_MODE_ERROR + f' {error.args[0].value}' - elif isinstance(error, requests.exceptions.ConnectionError): - return lan.CONNECTION_ERROR + f': {error}' - elif isinstance(error, requests.exceptions.ReadTimeout): - return lan.CONNECTION_ERROR + f': {error}' - else: - return str(error) - - + +from .lan_str import LanStr + +# Constants +WEBSITE = "https://mjcopilot.com" + +MAJSOUL_DOMAINS = [ + "maj-soul.com", # China + "majsoul.com", # old? + "mahjongsoul.com", # Japan + "yo-star.com" # English +] + +class Folder: + """ Folder name consts""" + MODEL = "models" + BROWSER_DATA = "browser_data" + RES = 'resources' + LOG = 'log' + MITM_CONF = 'mitm_config' + PROXINJECT = 'proxinject' + UPDATE = "update" + TEMP = 'temp' + CHROME_EXT = 'chrome_ext' + + +class GameClientType(Enum): + """ Game client type""" + PLAYWRIGHT = auto() # playwright browser + PROXY = auto() # other client through mitm proxy + + +class GameMode(Enum): + """ Game Modes for bots/models""" + MJ4P = "4P" + MJ3P = "3P" + + +# for automation +GAME_MODES = ['4E', '4S', '3E', '3S'] + + +class UiState(Enum): + """ UI State for the game""" + NOT_RUNNING = 0 + MAIN_MENU = 1 + IN_GAME = 10 + GAME_ENDING = 20 + + +# === Exceptions === +class LocalModelException(Exception): + """ Exception for model file error""" + +class MITMException(Exception): + """ Exception for MITM error""" + +class MitmCertNotInstalled(Exception): + """ mitm certificate not installed""" + +class BotNotSupportingMode(Exception): + """ Bot not supporting current mode""" + def __init__(self, mode:GameMode): + super().__init__(mode) + + +def error_to_str(error:Exception, lan:LanStr) -> str: + """ Convert error to language specific string""" + if isinstance(error, LocalModelException): + return lan.LOCAL_MODEL_ERROR + elif isinstance(error, MitmCertNotInstalled): + return lan.MITM_CERT_NOT_INSTALLED + f"{error.args}" + elif isinstance(error, MITMException): + return lan.MITM_SERVER_ERROR + elif isinstance(error, BotNotSupportingMode): + return lan.MODEL_NOT_SUPPORT_MODE_ERROR + f' {error.args[0].value}' + elif isinstance(error, requests.exceptions.ConnectionError): + return lan.CONNECTION_ERROR + f': {error}' + elif isinstance(error, requests.exceptions.ReadTimeout): + return lan.CONNECTION_ERROR + f': {error}' + else: + return str(error) + + def sub_folder(folder_name:str) -> pathlib.Path: """ return the subfolder Path, create it if not exists""" try: - # PyInstaller creates a temp folder and stores path in _MEIPASS - base_path = pathlib.Path(sys._MEIPASS).parent # pylint: disable=W0212,E1101 + # In frozen app, keep read-only assets relative to executable (Contents/MacOS). + # In source runs, keep legacy behavior (project current directory). + if getattr(sys, "frozen", False): + app_base = pathlib.Path(sys.executable).resolve().parent + else: + app_base = pathlib.Path(".").resolve() + if sys.platform == "darwin": + mac_data_base = pathlib.Path.home() / "Library" / "Application Support" / "mortal_mac_v0.2" + writable_folders = { + Folder.MODEL, + Folder.BROWSER_DATA, + Folder.LOG, + Folder.MITM_CONF, + Folder.PROXINJECT, + Folder.UPDATE, + Folder.TEMP, + Folder.CHROME_EXT, + } + base_path = mac_data_base if folder_name in writable_folders else app_base + else: + base_path = app_base except Exception: #pylint: disable=broad-except - base_path = pathlib.Path('.') - - subfolder = base_path / folder_name + base_path = pathlib.Path('.').resolve() + + if folder_name in (".", ""): + subfolder = base_path + else: + subfolder = base_path / folder_name if not subfolder.exists(): - subfolder.mkdir(exist_ok=True) + subfolder.mkdir(parents=True, exist_ok=True) return subfolder.resolve() - - -def sub_file(folder:str, file:str) -> str: - """ return the file absolute path string, given folder and filename, create the folder if not exists""" - subfolder = sub_folder(folder) - file_str = str((subfolder / file).resolve()) - return file_str - - -def wait_for_file(file:str, timeout:int=5) -> bool: - """ Wait for file creation (blocking until the file exists) for {timeout} seconds - returns: - bool: True if file exists within timeout, False otherwise - """ - # keep checking if the file exists until timeout - start_time = time.time() - while not pathlib.Path(file).exists(): - if time.time() - start_time > timeout: - return False - time.sleep(0.5) - return True - - + + +def sub_file(folder:str, file:str) -> str: + """ return the file absolute path string, given folder and filename, create the folder if not exists""" + subfolder = sub_folder(folder) + file_str = str((subfolder / file).resolve()) + return file_str + + +def wait_for_file(file:str, timeout:int=5) -> bool: + """ Wait for file creation (blocking until the file exists) for {timeout} seconds + returns: + bool: True if file exists within timeout, False otherwise + """ + # keep checking if the file exists until timeout + start_time = time.time() + while not pathlib.Path(file).exists(): + if time.time() - start_time > timeout: + return False + time.sleep(0.5) + return True + + def sub_run_args() -> dict: """ return **args for subprocess.run""" - startup_info = subprocess.STARTUPINFO() - startup_info.dwFlags |= subprocess.STARTF_USESHOWWINDOW - startup_info.wShowWindow = subprocess.SW_HIDE args = { - 'capture_output':True, + 'capture_output':True, 'text': True, 'check': False, - 'shell': True, - 'startupinfo': startup_info} + } + if sys.platform == "win32": + startup_info = subprocess.STARTUPINFO() + startup_info.dwFlags |= subprocess.STARTF_USESHOWWINDOW + startup_info.wShowWindow = subprocess.SW_HIDE + args['startupinfo'] = startup_info return args - - + + def get_cert_serial_number(cert_file:str) ->str: """Extract the serial number as a hexadecimal string from a certificate.""" with open(cert_file, 'rb') as file: @@ -157,151 +181,175 @@ def get_cert_serial_number(cert_file:str) ->str: return hex_serial +def get_cert_sha1_fingerprint(cert_file:str) -> str: + """Extract certificate SHA1 fingerprint as uppercase hex string.""" + with open(cert_file, 'rb') as file: + cert_data = file.read() + cert = x509.load_pem_x509_certificate(cert_data, default_backend()) + return cert.fingerprint(hashes.SHA1()).hex().upper() + + def is_certificate_installed(cert_file:str) -> tuple[bool, str]: """Check if the given certificate is installed in the system certificate store. Returns: (bool, str): True if the certificate is found in the system store, str is the stdout""" - # Get the hex serial number from the certificate file try: - serial_number = get_cert_serial_number(cert_file) - if sys.platform == "win32": + serial_number = get_cert_serial_number(cert_file) # Use certutil to look up the certificate by its serial number in the Root store cmd = ['certutil', '-store', 'Root', serial_number] - store_found_phrase = serial_number + result = subprocess.run(cmd, **sub_run_args()) #pylint:disable=subprocess-run-check + output = result.stdout + result.stderr + if result.returncode == 0 and serial_number.upper() in output.upper(): + return True, output + return False, output elif sys.platform == "darwin": - # TODO test on MacOS - # Use security to find the certificate by its serial number in the System keychain - cmd = ['security', 'find-certificate', '-c', serial_number, '/Library/Keychains/System.keychain'] - store_found_phrase = 'attributes:' + cert_sha1 = get_cert_sha1_fingerprint(cert_file) + keychains = [ + pathlib.Path('/Library/Keychains/System.keychain'), + pathlib.Path.home() / 'Library' / 'Keychains' / 'login.keychain-db', + pathlib.Path.home() / 'Library' / 'Keychains' / 'login.keychain', + ] + outputs = [] + for keychain in keychains: + if not keychain.exists(): + continue + cmd = ['security', 'find-certificate', '-a', '-Z', str(keychain)] + result = subprocess.run(cmd, **sub_run_args()) #pylint:disable=subprocess-run-check + output = f"keychain={keychain}\n{result.stdout}{result.stderr}" + outputs.append(output) + if result.returncode == 0 and cert_sha1 in result.stdout.upper(): + return True, output + if outputs: + return False, "\n".join(outputs) + return False, "No keychain found for certificate lookup" else: # unsupported platform - return False - args = sub_run_args() - result = subprocess.run(cmd, **args) #pylint:disable=subprocess-run-check - # Check if the command output indicates the certificate was found - if result.returncode==0: - if store_found_phrase in result.stdout or store_found_phrase.lower() in result.stdout: - return True, result.stdout + result.stderr - return False, result.stdout + result.stderr + return False, f"Unsupported platform: {sys.platform}" except subprocess.SubprocessError as e: - # error occured while running the command + # error occured while running the command return False, str(e) except Exception as e: return False, str(e) - - + + def install_root_cert(cert_file:str): - """ Install Root certificate onto the system - params: - cert_file(str): certificate file to be installed - Returns: - (bool, str): True if the certificate is installed successfully, str is the stdout - """ - # Install cert. If the cert exists, system will skip installation + """ Install Root certificate onto the system + params: + cert_file(str): certificate file to be installed + Returns: + (bool, str): True if the certificate is installed successfully, str is the stdout + """ + # Install cert. If the cert exists, system will skip installation if sys.platform == "win32": print(f'"{cert_file}"') full_command = ["certutil","-addstore","Root",cert_file] - # full_command = [ - # 'powershell', '-Command', - # f"Start-Process 'certutil' -ArgumentList '-addstore', 'Root', '{cert_file}' -Wait -Verb 'RunAs';" - # f"exit $LASTEXITCODE" - # ] + # full_command = [ + # 'powershell', '-Command', + # f"Start-Process 'certutil' -ArgumentList '-addstore', 'Root', '{cert_file}' -Wait -Verb 'RunAs';" + # f"exit $LASTEXITCODE" + # ] p=subprocess.run(full_command, **sub_run_args()) - stdout, stderr = p.stdout, p.stderr + stdout, stderr = p.stdout, p.stderr elif sys.platform == "darwin": - # TODO Test on MAC system - cmd = ['sudo', 'security', 'add-trusted-cert', '-d', '-r', 'trustRoot', '-k', '/Library/Keychains/System.keychain', cert_file] - p = subprocess.run(cmd, capture_output=True, text=True, check=False) + login_keychain = pathlib.Path.home() / 'Library' / 'Keychains' / 'login.keychain-db' + if not login_keychain.exists(): + login_keychain = pathlib.Path.home() / 'Library' / 'Keychains' / 'login.keychain' + cmd = ['security', 'add-trusted-cert', '-d', '-r', 'trustRoot', '-k', str(login_keychain), cert_file] + p = subprocess.run(cmd, **sub_run_args()) stdout, stderr = p.stdout, p.stderr - else: - print("Unknown Platform. Please manually install MITM certificate:", cert_file) - return False, "" - - # Check if successful - text = f"{stdout}\n{stderr}" - if p.returncode == 0: # success - return True, text - else: # error - return False, text - - + else: + print("Unknown Platform. Please manually install MITM certificate:", cert_file) + return False, "" + + # Check if successful + text = f"{stdout}\n{stderr}" + if p.returncode == 0: # success + return True, text + else: # error + return False, text + + def list_children(folder:str, full_path:bool=False, incl_file:bool=True, incl_dir:bool=False) -> list[pathlib.Path]: - """ return the list of children in the folder - params: - folder(str): name of the folder - full_path(bool): True to return the full path, while False to return only the file name - incl_file(bool): True to include files in the list - incl_dir(bool): True to include directories in the list""" + """ return the list of children in the folder + params: + folder(str): name of the folder + full_path(bool): True to return the full path, while False to return only the file name + incl_file(bool): True to include files in the list + incl_dir(bool): True to include directories in the list""" try: def to_include(f:pathlib.Path) -> bool: return (incl_file and f.is_file()) or (incl_dir and f.is_dir()) - files = [f for f in pathlib.Path(folder).iterdir() if to_include(f)] + target = pathlib.Path(folder) + if not target.exists() and not target.is_absolute(): + target = sub_folder(folder) + files = [f for f in target.iterdir() if to_include(f)] + files.sort(key=lambda x: x.name.lower()) if full_path: return [str(f.resolve()) for f in files] else: return [f.name for f in files] - except: #pylint:disable=bare-except - return [] - - -def random_str(length:int) -> str: - """ Generate random string with specified length""" - return ''.join(random.choices(string.ascii_letters + string.digits, k=length)) - - -def set_dpi_awareness(): - """ Set DPI Awareness """ - if sys.platform == "win32": - try: - ctypes.windll.shcore.SetProcessDpiAwareness(1) # for Windows 8.1 and later - except AttributeError: - ctypes.windll.user32.SetProcessDPIAware() # for Windows Vista and later - except: #pylint:disable=bare-except - pass - - -ES_CONTINUOUS = 0x80000000 -ES_SYSTEM_REQUIRED = 0x00000001 -ES_DISPLAY_REQUIRED = 0x00000002 -def prevent_sleep(): - """ prevent system going into sleep/screen saver""" - if sys.platform == "win32": - ctypes.windll.kernel32.SetThreadExecutionState( - ES_CONTINUOUS | - ES_SYSTEM_REQUIRED | - ES_DISPLAY_REQUIRED - ) - - -class FPSCounter: - """Class for counting frames and calculating fps.""" - - def __init__(self): - self.lock = threading.Lock() - self.timestamps = [] # List to hold timestamps of frame calls - self.last_calc_time = time.time() # Last time fps was calculated - self.last_fps = 0 # Last calculated fps value - - def frame(self): - """Indicates that a frame has been rendered or processed. Adds the current time to timestamps.""" - with self.lock: - self.timestamps.append(time.time()) - - def reset(self): - """Resets the counter by clearing all recorded timestamps.""" - with self.lock: - self.timestamps.clear() - - @property - def fps(self): - """Returns the current frames per second, calculated as the number of frames in the past second.""" - if time.time() - self.last_calc_time < 0.5: - return self.last_fps - with self.lock: - # Filter out timestamps that are older than 1 second from the current time - cur_time = time.time() - self.timestamps = [t for t in self.timestamps if cur_time - t < 1] - self.last_fps = len(self.timestamps) - self.last_calc_time = cur_time - return self.last_fps - + except: #pylint:disable=bare-except + return [] + + +def random_str(length:int) -> str: + """ Generate random string with specified length""" + return ''.join(random.choices(string.ascii_letters + string.digits, k=length)) + + +def set_dpi_awareness(): + """ Set DPI Awareness """ + if sys.platform == "win32": + try: + ctypes.windll.shcore.SetProcessDpiAwareness(1) # for Windows 8.1 and later + except AttributeError: + ctypes.windll.user32.SetProcessDPIAware() # for Windows Vista and later + except: #pylint:disable=bare-except + pass + + +ES_CONTINUOUS = 0x80000000 +ES_SYSTEM_REQUIRED = 0x00000001 +ES_DISPLAY_REQUIRED = 0x00000002 +def prevent_sleep(): + """ prevent system going into sleep/screen saver""" + if sys.platform == "win32": + ctypes.windll.kernel32.SetThreadExecutionState( + ES_CONTINUOUS | + ES_SYSTEM_REQUIRED | + ES_DISPLAY_REQUIRED + ) + + +class FPSCounter: + """Class for counting frames and calculating fps.""" + + def __init__(self): + self.lock = threading.Lock() + self.timestamps = [] # List to hold timestamps of frame calls + self.last_calc_time = time.time() # Last time fps was calculated + self.last_fps = 0 # Last calculated fps value + + def frame(self): + """Indicates that a frame has been rendered or processed. Adds the current time to timestamps.""" + with self.lock: + self.timestamps.append(time.time()) + + def reset(self): + """Resets the counter by clearing all recorded timestamps.""" + with self.lock: + self.timestamps.clear() + + @property + def fps(self): + """Returns the current frames per second, calculated as the number of frames in the past second.""" + if time.time() - self.last_calc_time < 0.5: + return self.last_fps + with self.lock: + # Filter out timestamps that are older than 1 second from the current time + cur_time = time.time() + self.timestamps = [t for t in self.timestamps if cur_time - t < 1] + self.last_fps = len(self.timestamps) + self.last_calc_time = cur_time + return self.last_fps + diff --git a/game/automation.py b/game/automation.py index 1646498..842d9dd 100644 --- a/game/automation.py +++ b/game/automation.py @@ -1,877 +1,964 @@ -""" Game automation classes and algorithm""" -# Design: generate action steps based on mjai action (Automation) -# action steps are individual steps like delay, mouse click, etc. (ActionStep and derivatives) -# Screen positions are in 16x9 resolution, and are translated to client resolution in execution -# Then execute the steps in thread (AutomationTask). It calls the executor (browser for now) to carry out the actions. -# for in-game actions, verify on every ActionStep if the action has expired, and cancel execution if needed -# for example, Majsoul before you finish "Chi", another player may "Pon"/"Ron"/..., which cancels your "Chi" action - +""" Game automation classes and algorithm""" +# Design: generate action steps based on mjai action (Automation) +# action steps are individual steps like delay, mouse click, etc. (ActionStep and derivatives) +# Screen positions are in 16x9 resolution, and are translated to client resolution in execution +# Then execute the steps in thread (AutomationTask). It calls the executor (browser for now) to carry out the actions. +# for in-game actions, verify on every ActionStep if the action has expired, and cancel execution if needed +# for example, Majsoul before you finish "Chi", another player may "Pon"/"Ron"/..., which cancels your "Chi" action + from dataclasses import dataclass, field +import sys import time import random import threading -from typing import Iterable, Iterator - -from common.mj_helper import MjaiType, MSType, MJAI_TILES_19, MJAI_TILES_28, MJAI_TILES_SORTED -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 .img_proc import ImgTemp, GameVisual -from .browser import GameBrowser -from .game_state import GameInfo, GameState - - -class Positions: - """ Screen coordinates constants. in 16 x 9 resolution""" - TEHAI_X = [ - 2.23125, 3.021875, 3.8125, 4.603125, 5.39375, 6.184375, 6.975, - 7.765625, 8.55625, 9.346875, 10.1375, 10.928125, 11.71875, 12.509375] - TEHAI_Y = 8.3625 - TRUMO_SPACE = 0.246875 - BUTTONS:list[tuple] = [ - (10.875, 7), # 0 (None) - (8.6375, 7), # 1 - (6.4 , 7), # 2 - (10.875, 5.9), # 3 - (8.6375, 5.9), # 4 - (6.4 , 5.9), # 5 - (10.875, 4.8), # Not used - (8.6375, 4.8), # Not used - (6.4 , 4.8), # Not used - ] - """ button layout: - 5 4 3 - 2 1 0 - where action with higher priority takes lower position - None is always at 0 - """ - - CANDIDATES:list[tuple] = [ - (3.6625, 6.3), # - (4.49625, 6.3), - (5.33 , 6.3), - (6.16375, 6.3), - (6.9975, 6.3), - (7.83125, 6.3), # 5 mid - (8.665, 6.3), - (9.49875, 6.3), - (10.3325, 6.3), - (11.16625,6.3), - (12, 6.3), - ] - """ chi/pon/daiminkan candidates (combinations) positions - index = (-(len/2)+idx+0.5)*2+5 """ - - CANDIDATES_KAN:list[tuple] = [ - (4.325, 6.3), # - (5.4915, 6.3), - (6.6583, 6.3), - (7.825, 6.3), # 3 mid - (8.9917, 6.3), - (10.1583, 6.3), - (11.325, 6.3), - ] - """ kakan/ankan candidates (combinations) positions - idx_kan = int((-(len/2)+idx+0.5)*2+3)""" - - EMOJI_BUTTON = (15.675, 4.9625) - EMOJIS = [ - (12.4, 3.5), (13.65, 3.5), (14.8, 3.5), # 1 2 3 - (12.4, 5.0), (13.65, 5.0), (14.8, 5.0), # 4 5 6 - (12.4, 6.5), (13.65, 6.5), (14.8, 6.5), # 7 8 9 - ] - """ emoji positions - 0 1 2 - 3 4 5 - 6 7 8""" - - - GAMEOVER = [ - (14.35, 8.12), # OK button 确定按钮 - (6.825, 6.8), # 点击好感度礼物? - ] - MENUS = [ - (11.5, 2.75), # Ranked 段位场 - ] - - LEVELS = [ - (11.5, 3.375), # Bronze 铜之间 - (11.5, 4.825), # Silver 银之间 - (11.5, 6.15), # Gold 金之间 - (11.5, 5.425), # Jade 玉之间 滚轮 - (11.5, 6.825), # Throne 王座之间 滚轮 - ] - - MODES = [ - (11.6, 3.325), # 4E 四人东 - (11.6, 4.675), # 4S 四人南 - (11.6, 6.1), # 3E 三人东 - (11.6, 7.35), # 3S 三人南 - ] - - - -MJAI_2_MS_TYPE = { - MjaiType.NONE: MSType.none, - - MjaiType.CHI: MSType.chi, - MjaiType.PON: MSType.pon, - MjaiType.DAIMINKAN: MSType.daiminkan, - MjaiType.HORA: MSType.hora, # MJAI hora might also be mapped to zimo - - MjaiType.ANKAN: MSType.ankan, - MjaiType.KAKAN: MSType.kakan, - MjaiType.REACH: MSType.reach, - MjaiType.RYUKYOKU: MSType.ryukyoku, - MjaiType.NUKIDORA: MSType.nukidora, -} -""" Map mjai type to Majsoul operation type """ - -ACTION_PIORITY = [ - 0, # none # - 99, # Discard # There is no discard button. Make it off the chart and positioned last in the operation list - 4, # Chi # Opponent Discard - 3, # Pon # Opponent Discard - 3, # Ankan # Self Discard # If Ankan and Kakan are both available, use only kakan. - 2, # Daiminkan # Opponent Discard - 3, # Kakan # Self Discard - 2, # Reach # Self Discard - 1, # Zimo # Self Discard - 1, # Rong # Opponent Discard - 5, # Ryukyoku # Self Discard - 4, # Nukidora # Self Discard -] -""" Priority of the actions when allocated to buttons in Majsoul -None is always the lowest, at bottom-right corner""" - -def cvt_type_mjai_2_ms(mjai_type:str, gi:GameInfo) -> MSType: - """ Convert mjai type str to MSType enum""" - if gi.my_tsumohai and mjai_type == MjaiType.HORA: - return MSType.zimo - else: - return MJAI_2_MS_TYPE[mjai_type] - -@dataclass -class ActionStep: - """ representing an atomic action step like single click/move/wheel/etc.""" - ignore_step_change:bool = field(default=False, init=False) - -@dataclass -class ActionStepMove(ActionStep): - """ Move mouse to x,y (client res)""" - x:float - y:float - steps:int = field(default=5) # playwright mouse move steps - -@dataclass -class ActionStepClick(ActionStep): - """ Click mouse left at current position""" - delay:float = field(default=80) # delay before button down/up - -@dataclass -class ActionStepMouseDown(ActionStep): - """ Mouse down action""" - -@dataclass -class ActionStepMouseUp(ActionStep): - """ Mouse up action""" - -@dataclass -class ActionStepWheel(ActionStep): - """ Mouse wheel action""" - dx:float - dy:float - -@dataclass -class ActionStepDelay(ActionStep): - """ Delay action""" - delay:float - +from typing import Iterable, Iterator + +from common.mj_helper import MjaiType, MSType, MJAI_TILES_19, MJAI_TILES_28, MJAI_TILES_SORTED +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 .img_proc import ImgTemp, GameVisual +from .browser import GameBrowser +from .game_state import GameInfo, GameState + + +class Positions: + """ Screen coordinates constants. in 16 x 9 resolution""" + TEHAI_X = [ + 2.23125, 3.021875, 3.8125, 4.603125, 5.39375, 6.184375, 6.975, + 7.765625, 8.55625, 9.346875, 10.1375, 10.928125, 11.71875, 12.509375] + TEHAI_Y = 8.3625 + TRUMO_SPACE = 0.246875 + BUTTONS:list[tuple] = [ + (10.875, 7), # 0 (None) + (8.6375, 7), # 1 + (6.4 , 7), # 2 + (10.875, 5.9), # 3 + (8.6375, 5.9), # 4 + (6.4 , 5.9), # 5 + (10.875, 4.8), # Not used + (8.6375, 4.8), # Not used + (6.4 , 4.8), # Not used + ] + """ button layout: + 5 4 3 + 2 1 0 + where action with higher priority takes lower position + None is always at 0 + """ + + CANDIDATES:list[tuple] = [ + (3.6625, 6.3), # + (4.49625, 6.3), + (5.33 , 6.3), + (6.16375, 6.3), + (6.9975, 6.3), + (7.83125, 6.3), # 5 mid + (8.665, 6.3), + (9.49875, 6.3), + (10.3325, 6.3), + (11.16625,6.3), + (12, 6.3), + ] + """ chi/pon/daiminkan candidates (combinations) positions + index = (-(len/2)+idx+0.5)*2+5 """ + + CANDIDATES_KAN:list[tuple] = [ + (4.325, 6.3), # + (5.4915, 6.3), + (6.6583, 6.3), + (7.825, 6.3), # 3 mid + (8.9917, 6.3), + (10.1583, 6.3), + (11.325, 6.3), + ] + """ kakan/ankan candidates (combinations) positions + idx_kan = int((-(len/2)+idx+0.5)*2+3)""" + + EMOJI_BUTTON = (15.675, 4.9625) + EMOJIS = [ + (12.4, 3.5), (13.65, 3.5), (14.8, 3.5), # 1 2 3 + (12.4, 5.0), (13.65, 5.0), (14.8, 5.0), # 4 5 6 + (12.4, 6.5), (13.65, 6.5), (14.8, 6.5), # 7 8 9 + ] + """ emoji positions + 0 1 2 + 3 4 5 + 6 7 8""" + + + GAMEOVER = [ + (14.35, 8.12), # OK button 确定按钮 + (6.825, 6.8), # 点击好感度礼物? + ] + MENUS = [ + (11.5, 2.75), # Ranked 段位场 + ] + + LEVELS = [ + (11.5, 3.375), # Bronze 铜之间 + (11.5, 4.825), # Silver 银之间 + (11.5, 6.15), # Gold 金之间 + (11.5, 5.425), # Jade 玉之间 滚轮 + (11.5, 6.825), # Throne 王座之间 滚轮 + ] + + MODES = [ + (11.6, 3.325), # 4E 四人东 + (11.6, 4.675), # 4S 四人南 + (11.6, 6.1), # 3E 三人东 + (11.6, 7.35), # 3S 三人南 + ] + + + +MJAI_2_MS_TYPE = { + MjaiType.NONE: MSType.none, + + MjaiType.CHI: MSType.chi, + MjaiType.PON: MSType.pon, + MjaiType.DAIMINKAN: MSType.daiminkan, + MjaiType.HORA: MSType.hora, # MJAI hora might also be mapped to zimo + + MjaiType.ANKAN: MSType.ankan, + MjaiType.KAKAN: MSType.kakan, + MjaiType.REACH: MSType.reach, + MjaiType.RYUKYOKU: MSType.ryukyoku, + MjaiType.NUKIDORA: MSType.nukidora, +} +""" Map mjai type to Majsoul operation type """ + +ACTION_PIORITY = [ + 0, # none # + 99, # Discard # There is no discard button. Make it off the chart and positioned last in the operation list + 4, # Chi # Opponent Discard + 3, # Pon # Opponent Discard + 3, # Ankan # Self Discard # If Ankan and Kakan are both available, use only kakan. + 2, # Daiminkan # Opponent Discard + 3, # Kakan # Self Discard + 2, # Reach # Self Discard + 1, # Zimo # Self Discard + 1, # Rong # Opponent Discard + 5, # Ryukyoku # Self Discard + 4, # Nukidora # Self Discard +] +""" Priority of the actions when allocated to buttons in Majsoul +None is always the lowest, at bottom-right corner""" + +def cvt_type_mjai_2_ms(mjai_type:str, gi:GameInfo) -> MSType: + """ Convert mjai type str to MSType enum""" + if gi.my_tsumohai and mjai_type == MjaiType.HORA: + return MSType.zimo + else: + return MJAI_2_MS_TYPE[mjai_type] + +@dataclass +class ActionStep: + """ representing an atomic action step like single click/move/wheel/etc.""" + ignore_step_change:bool = field(default=False, init=False) + +@dataclass +class ActionStepMove(ActionStep): + """ Move mouse to x,y (client res)""" + x:float + y:float + steps:int = field(default=5) # playwright mouse move steps + +@dataclass +class ActionStepClick(ActionStep): + """ Click mouse left at current position""" + delay:float = field(default=80) # delay before button down/up + +@dataclass +class ActionStepMouseDown(ActionStep): + """ Mouse down action""" + +@dataclass +class ActionStepMouseUp(ActionStep): + """ Mouse up action""" + +@dataclass +class ActionStepWheel(ActionStep): + """ Mouse wheel action""" + dx:float + dy:float + +@dataclass +class ActionStepDelay(ActionStep): + """ Delay action""" + delay:float + class AutomationTask: """ Managing automation task and its thread an automation task corresponds to performing a bot reaction on game client (e.g. click dahai on web client)""" - def __init__(self, br:GameBrowser, name:str, desc:str=""): + def __init__(self, br:GameBrowser, name:str, desc:str="", use_real_mouse:bool=False, + real_mouse_speed_pps:float=2200.0, real_mouse_jitter_px:float=2.0, real_mouse_click_offset_px:float=2.0): """ params: br(GameBrowser): browser object name(str): name for the thread and task - desc(str): description of the task, for logging and readability""" + desc(str): description of the task, for logging and readability + use_real_mouse(bool): if True, use real mouse via ctypes instead of Playwright""" self.name = name self.desc = desc self.executor = br + self.use_real_mouse = use_real_mouse + self.real_mouse_speed_pps = max(300.0, real_mouse_speed_pps) + self.real_mouse_jitter_px = max(0.0, real_mouse_jitter_px) + self.real_mouse_click_offset_px = max(0.0, real_mouse_click_offset_px) self._stop_event = threading.Event() # set event to stop running self.last_exe_time:float = -1 # timestamp for the last actionstep execution - + self._thread:threading.Thread = None - - def stop(self, jointhread:bool=False): - """ stop the thread""" - if self._thread: - self._stop_event.set() - if jointhread: - self._thread.join() - - def is_running(self): - """ return true if thread is running""" - if self._thread and self._thread.is_alive(): - return True - else: - return False - + + def stop(self, jointhread:bool=False): + """ stop the thread""" + if self._thread: + self._stop_event.set() + if jointhread: + self._thread.join() + + def is_running(self): + """ return true if thread is running""" + if self._thread and self._thread.is_alive(): + return True + else: + return False + def run_step(self, step:ActionStep): """ run single action step""" + if self.use_real_mouse: + self._run_step_real_mouse(step) + else: + self._run_step_playwright(step) + self.last_exe_time = time.time() + + def _run_step_playwright(self, step:ActionStep): + """ run step using Playwright (simulated mouse)""" + if isinstance(step, ActionStepMove): + self.executor.mouse_move(step.x, step.y, step.steps, True) + elif isinstance(step, ActionStepClick): + self.executor.mouse_down(True) + time.sleep(step.delay/1000) + self.executor.mouse_up(True) + elif isinstance(step, ActionStepMouseDown): + self.executor.mouse_down(True) + elif isinstance(step, ActionStepMouseUp): + self.executor.mouse_up(True) + elif isinstance(step, ActionStepWheel): + self.executor.mouse_wheel(step.dx, step.dy, True) + elif isinstance(step, ActionStepDelay): + time.sleep(step.delay) + else: + raise NotImplementedError(f"Execution not implemented for step type {type(step)}") + + def _run_step_real_mouse(self, step:ActionStep): + """ run step using real mouse (ctypes)""" if isinstance(step, ActionStepMove): + # Recalculate mapping each move in case window moved between actions. + screen_x, screen_y = self.executor.viewport_to_screen(step.x, step.y) + self.executor.real_mouse_move( + screen_x, screen_y, + speed_pps=self.real_mouse_speed_pps, + jitter_px=self.real_mouse_jitter_px + ) + # Also sync Playwright mouse so page hover states update self.executor.mouse_move(step.x, step.y, step.steps, True) elif isinstance(step, ActionStepClick): - # self.executor.mouse_click(step.delay, True) - self.executor.mouse_down(True) - time.sleep(step.delay/1000) # sleep here instead of in browser thread - self.executor.mouse_up(True) - elif isinstance(step, ActionStepMouseDown): - self.executor.mouse_down(True) - elif isinstance(step, ActionStepMouseUp): - self.executor.mouse_up(True) - elif isinstance(step, ActionStepWheel): - self.executor.mouse_wheel(step.dx, step.dy, True) - elif isinstance(step, ActionStepDelay): - time.sleep(step.delay) - else: - raise NotImplementedError(f"Execution not implemented for step type {type(step)}") - self.last_exe_time = time.time() - - def start_action_steps(self, action_steps:Iterable[ActionStep], game_state:GameState = None): - """ start running action list/iterator in a thread""" - if self.is_running(): - return - - def task(): - if game_state: - op_step = game_state.last_op_step - else: - op_step = None - msg = f"Start executing task: {self.name}, {self.desc}" - LOGGER.debug(msg) - for step in action_steps: - if self._stop_event.is_set(): - LOGGER.debug("Cancel executing %s. Stop event set",self.name) - return - if game_state: - # check step change - # operation step change indicates there is new liqi operation, and old action has expired - new_step = game_state.last_op_step - if op_step != new_step and not step.ignore_step_change: - LOGGER.debug("Cancel executing %s due to step change(%d -> %d)", self.name, op_step, new_step) - return - self.run_step(step) - LOGGER.debug("Finished executing task: %s", self.name) - - self._thread = threading.Thread( - target=task, - name = self.name, - daemon=True - ) - self._thread.start() - -END_GAME = "Auto_EndGame" -JOIN_GAME = "Auto_JoinGame" - + self.executor.real_mouse_click( + delay=step.delay/1000, + click_offset_px=self.real_mouse_click_offset_px, + speed_pps=self.real_mouse_speed_pps, + jitter_px=self.real_mouse_jitter_px + ) + elif isinstance(step, ActionStepMouseDown): + self.executor.real_mouse_down() + elif isinstance(step, ActionStepMouseUp): + self.executor.real_mouse_up() + elif isinstance(step, ActionStepWheel): + self.executor.real_mouse_wheel(step.dx, step.dy) + elif isinstance(step, ActionStepDelay): + time.sleep(step.delay) + else: + raise NotImplementedError(f"Execution not implemented for step type {type(step)}") + + def start_action_steps(self, action_steps:Iterable[ActionStep], game_state:GameState = None): + """ start running action list/iterator in a thread""" + if self.is_running(): + return + + def task(): + if game_state: + op_step = game_state.last_op_step + else: + op_step = None + msg = f"Start executing task: {self.name}, {self.desc}" + LOGGER.debug(msg) + for step in action_steps: + if self._stop_event.is_set(): + LOGGER.debug("Cancel executing %s. Stop event set",self.name) + return + if game_state: + # check step change + # operation step change indicates there is new liqi operation, and old action has expired + new_step = game_state.last_op_step + if op_step != new_step and not step.ignore_step_change: + LOGGER.debug("Cancel executing %s due to step change(%d -> %d)", self.name, op_step, new_step) + return + self.run_step(step) + LOGGER.debug("Finished executing task: %s", self.name) + + self._thread = threading.Thread( + target=task, + name = self.name, + daemon=True + ) + self._thread.start() + +END_GAME = "Auto_EndGame" +JOIN_GAME = "Auto_JoinGame" + 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""" - def __init__(self, browser: GameBrowser, setting:Settings): - if browser is None: - raise ValueError("Browser is None") - self.executor = browser - self.st = setting - self.g_v = GameVisual(browser) - - self._task:AutomationTask = None # the task thread - self.ui_state:UiState = UiState.NOT_RUNNING # Where game UI is at. initially not running - - self.last_emoji_time:float = 0.0 # timestamp of last emoji sent - + """ 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""" + def __init__(self, browser: GameBrowser, setting:Settings): + if browser is None: + raise ValueError("Browser is None") + self.executor = browser + self.st = setting + self.g_v = GameVisual(browser) + + self._task:AutomationTask = None # the task thread + self.ui_state:UiState = UiState.NOT_RUNNING # Where game UI is at. initially not running + + self.last_emoji_time:float = 0.0 # timestamp of last emoji sent + def is_running_execution(self): """ if task is still running""" if self._task and self._task.is_running(): return True else: return False - - def running_task_info(self) -> tuple[str, str]: - """ return the running task's (name, desc). None if N/A""" - if self._task and self._task.is_running(): - return (self._task.name, self._task.desc) - else: - return None - - def stop_previous(self): - """ stop previous task execution if it is still running""" - if self.is_running_execution(): - LOGGER.info("Stopping previous action: %s", self._task.name) - self._task.stop() - self._task = None - - def can_automate(self, cancel_on_running:bool=False, limit_state:UiState=None) -> bool: - """return True if automation conditions met """ - if not self.st.enable_automation: # automation not enabled - return False - if not self.executor.is_page_normal(): # browser is not running - return False - if cancel_on_running and self.is_running_execution(): # cancel if previous task is running - return False - if limit_state and self.ui_state != limit_state: # cancel if current state != limit_state - return False - - return True - + + def _create_task(self, name:str, desc:str) -> AutomationTask: + """Create task with current real-mouse tuning settings.""" + use_real_mouse = self.st.use_real_mouse and sys.platform == "win32" + return AutomationTask( + self.executor, + name, + desc, + use_real_mouse, + self.st.real_mouse_speed_pps, + self.st.real_mouse_jitter_px, + self.st.real_mouse_click_offset_px + ) + + def running_task_info(self) -> tuple[str, str]: + """ return the running task's (name, desc). None if N/A""" + if self._task and self._task.is_running(): + return (self._task.name, self._task.desc) + else: + return None + + def stop_previous(self): + """ stop previous task execution if it is still running""" + if self.is_running_execution(): + LOGGER.info("Stopping previous action: %s", self._task.name) + self._task.stop() + self._task = None + + def can_automate(self, cancel_on_running:bool=False, limit_state:UiState=None) -> bool: + """return True if automation conditions met """ + if not self.st.enable_automation: # automation not enabled + return False + if not self.executor.is_page_normal(): # browser is not running + return False + if cancel_on_running and self.is_running_execution(): # cancel if previous task is running + return False + if limit_state and self.ui_state != limit_state: # cancel if current state != limit_state + return False + + return True + def get_delay(self, mjai_action:dict, gi:GameInfo, subtract:float=0.0): """ return the action initial delay based on action type and game info""" mjai_type = mjai_action['type'] - delay = random.uniform(self.st.delay_random_lower, self.st.delay_random_upper) # base delay + delay = self._sample_base_delay() if mjai_type == MjaiType.DAHAI: # extra time for first round and East if gi.is_first_round and gi.jikaze == 'E': delay += 4.5 - - extra_time:float = 0.0 - - # more time for 19 < 28 < others - pai = mjai_action['pai'] - if pai in MJAI_TILES_19 or pai == gi.my_tsumohai : - extra_time += 0.0 - elif pai in MJAI_TILES_28: - extra_time += 0.5 - else: - extra_time += random.uniform(0.75, 1.0) - if gi.n_other_reach() > 0: # extra time for other reach - extra_time += random.uniform(0.20, 0.30) * gi.n_other_reach() - extra_time = min(extra_time, 3.0) # cap extra time - delay += extra_time - - elif mjai_type == MjaiType.REACH: - delay += 1.0 - elif mjai_type == MjaiType.HORA: - delay += 0.0 - elif mjai_type == MjaiType.NUKIDORA: - delay += 0.0 - elif mjai_type == MjaiType.RYUKYOKU: # more time for RYUKYOKU - if gi.jikaze == 'E': - delay += 1.5 - delay += 2.0 - else: # chi/pon/kan/others - delay += 0.5 - - subtract = max(0, subtract-0.5) + + extra_time:float = 0.0 + + # more time for 19 < 28 < others + pai = mjai_action['pai'] + if pai in MJAI_TILES_19 or pai == gi.my_tsumohai : + extra_time += 0.0 + elif pai in MJAI_TILES_28: + extra_time += 0.5 + else: + extra_time += random.uniform(0.75, 1.0) + if gi.n_other_reach() > 0: # extra time for other reach + extra_time += random.uniform(0.20, 0.30) * gi.n_other_reach() + extra_time = min(extra_time, 3.0) # cap extra time + delay += extra_time + + elif mjai_type == MjaiType.REACH: + delay += 1.0 + elif mjai_type == MjaiType.HORA: + delay += 0.0 + elif mjai_type == MjaiType.NUKIDORA: + delay += 0.0 + elif mjai_type == MjaiType.RYUKYOKU: # more time for RYUKYOKU + if gi.jikaze == 'E': + delay += 1.5 + delay += 2.0 + else: # chi/pon/kan/others + delay += 0.5 + + subtract = max(0, subtract-0.5) delay = max(0, delay-subtract) # minimal delay =0 # LOGGER.debug("Subtract=%.2f, Delay=%.2f", subtract, delay) return delay - - - def automate_action(self, mjai_action:dict, game_state:GameState) -> bool: - """ execute action given by the mjai message - params: - mjai_action(dict): mjai output action msg - game_state(GameState): game state object - Returns: - bool: True means automation kicks off. False means not automating.""" - if not self.can_automate(): - return False - if game_state is None or mjai_action is None: - return False - - self.stop_previous() - gi = game_state.get_game_info() - assert gi is not None, "Game info is None" - op_step = game_state.last_op_step - mjai_type = mjai_action['type'] - - if self.st.ai_randomize_choice: # randomize choice - mjai_action = self.randomize_action(mjai_action, gi) - # Dahai action - if mjai_type == MjaiType.DAHAI: - if gi.self_reached: - # already in reach state. no need to automate dahai - LOGGER.info("Skip automating dahai, already in REACH") - game_state.last_reaction_pending = False # cancel pending state so i won't be retried - return False - more_steps:list[ActionStep] = self.steps_action_dahai(mjai_action, gi) - - # "button" action - elif mjai_type in [MjaiType.NONE, MjaiType.CHI, MjaiType.PON, MjaiType.DAIMINKAN, MjaiType.ANKAN, - MjaiType.KAKAN, MjaiType.HORA, MjaiType.REACH, MjaiType.RYUKYOKU, MjaiType.NUKIDORA]: - liqi_operation = game_state.last_operation - more_steps:list[ActionStep] = self.steps_button_action(mjai_action, gi, liqi_operation) - - else: - LOGGER.error("No automation for unrecognized mjai type: %s", mjai_type) - return False - - delay = self.get_delay(mjai_action, gi, game_state.last_reaction_time) # initial delay - action_steps:list[ActionStep] = [ActionStepDelay(delay)] - action_steps.extend(more_steps) - pai = mjai_action.get('pai',"") - calc_time = game_state.last_reaction_time - desc = ( - f"Automating action {mjai_type} {pai}" - f" (step={op_step}," - f" calc_time={calc_time:.2f}s, delay={delay:.2f}s, total_delay={calc_time+delay:.2f}s)" - ) - self._task = AutomationTask(self.executor, f"Auto_{mjai_type}_{pai}", desc) - self._task.start_action_steps(action_steps, game_state) - return True - - def randomize_action(self, action:dict, gi:GameInfo) -> dict: - """ Randomize ai choice: pick according to probaility from top 3 options""" - n = self.st.ai_randomize_choice # randomize strength. 0 = no random, 5 = according to probability - if n == 0: - return action - mjai_type = action['type'] - if mjai_type == MjaiType.DAHAI: - orig_pai = action['pai'] - options:dict = action['meta_options'] # e.g. {'1m':0.95, 'P':0.045, 'N':0.005, ...} - # get dahai options (tile only) from top 3 - top_ops:list = [(k,v) for k,v in options[:3] if k in MJAI_TILES_SORTED] - #pick from top3 according to probability - power = 1 / (0.2 * n) - sum_probs = sum([v**power for k,v in top_ops]) - top_ops_powered = [(k, v**power/sum_probs) for k,v in top_ops] - - # 1. Calculate cumulative probabilities - cumulative_probs = [top_ops_powered[0][1]] - for i in range(1, len(top_ops_powered)): - cumulative_probs.append(cumulative_probs[-1] + top_ops_powered[i][1]) - # 2. Pick an option based on a random number - rand_prob = random.random() # Random float: 0.0 <= x < 1.0 - chosen_pai = orig_pai # Default in case no option is selected, for safety - prob = top_ops_powered[0][1] - for i, cum_prob in enumerate(cumulative_probs): - if rand_prob < cum_prob: - chosen_pai = top_ops_powered[i][0] # This is the selected key based on probability - prob = top_ops_powered[i][1] # the probability - orig_prob = top_ops[i][1] - break - - if chosen_pai == orig_pai: # return original action if no change - change_str = f"{action['pai']} Unchanged" - else: - change_str = f"{action['pai']} -> {chosen_pai}" - - # generate new action for changed tile - tsumogiri = chosen_pai == gi.my_tsumohai - new_action = { - 'type': MjaiType.DAHAI, - 'actor': action['actor'], - 'pai': chosen_pai, - 'tsumogiri': tsumogiri - } - msg = f"Randomized dahai: {change_str} ([{n}] {orig_prob*100:.1f}% -> {prob*100:.1f}%)" - LOGGER.debug(msg) - return new_action - # other MJAI types - else: - return action - - - def last_exec_time(self) -> float: - """ return the time of last action execution. return -1 if N/A""" - if self._task: - return self._task.last_exe_time - else: - return -1 - - def automate_retry_pending(self, game_state:GameState): - """ retry pending action from game state""" - if not self.can_automate(True, UiState.IN_GAME): - return - if time.time() - self.last_exec_time() < self.st.auto_retry_interval: - # interval not reached, cancel - return False - if game_state is None: - return False - pend_action = game_state.get_pending_reaction() - if pend_action is None: - return - LOGGER.info("Retry automating pending reaction: %s", pend_action['type']) - self.automate_action(pend_action, game_state) - - - def automate_send_emoji(self): - """ Send emoji given chance + def _sample_base_delay(self) -> float: + """Sample base delay with heavy short-delay bias. + Distribution: + - 86%: short delay range from settings (biased toward lower bound) + - 12%: up to 5 seconds + - 2%: 5 to 10 seconds """ - if not self.can_automate(True, UiState.IN_GAME): - return - if time.time() - self.last_emoji_time < self.st.auto_emoji_intervel: # prevent spamming - return + short_low = max(0.0, float(self.st.delay_random_lower)) + short_high = max(short_low, float(self.st.delay_random_upper)) roll = random.random() - if roll > self.st.auto_reply_emoji_rate: # send when roll < rate - return - - idx = random.randint(0, 8) - x,y = Positions.EMOJI_BUTTON - steps = [ActionStepDelay(random.uniform(1.5, 3.0)), ActionStepMove(x*self.scaler, y*self.scaler)] - steps.append(ActionStepDelay(random.uniform(0.1, 0.2))) - steps.append(ActionStepClick()) - x,y = Positions.EMOJIS[idx] - steps.append(ActionStepMove(x*self.scaler,y*self.scaler)) - steps.append(ActionStepDelay(random.uniform(0.1, 0.2))) - steps.append(ActionStepClick()) - self._task = AutomationTask(self.executor, f"SendEmoji{idx}", f"Send emoji {idx}") + if roll < 0.86: + # Strongly bias to lower bound so most actions are very quick. + u = random.random() ** 3.2 + return short_low + (short_high - short_low) * u + + if roll < 0.98: + mid_low = max(short_high, 0.6) + mid_high = 5.0 + if mid_low >= mid_high: + return short_high + # Mild lower-bound bias inside medium bucket. + u = random.random() ** 1.8 + return mid_low + (mid_high - mid_low) * u + + # Rare long think-time bucket. + long_low = 5.0 + long_high = 10.0 + u = random.random() ** 1.4 + return long_low + (long_high - long_low) * u + + + def automate_action(self, mjai_action:dict, game_state:GameState) -> bool: + """ execute action given by the mjai message + params: + mjai_action(dict): mjai output action msg + game_state(GameState): game state object + Returns: + bool: True means automation kicks off. False means not automating.""" + if not self.can_automate(): + return False + if game_state is None or mjai_action is None: + return False + + self.stop_previous() + gi = game_state.get_game_info() + assert gi is not None, "Game info is None" + op_step = game_state.last_op_step + mjai_type = mjai_action['type'] + + if self.st.ai_randomize_choice: # randomize choice + mjai_action = self.randomize_action(mjai_action, gi) + # Dahai action + if mjai_type == MjaiType.DAHAI: + if gi.self_reached: + # already in reach state. no need to automate dahai + LOGGER.info("Skip automating dahai, already in REACH") + game_state.last_reaction_pending = False # cancel pending state so i won't be retried + return False + more_steps:list[ActionStep] = self.steps_action_dahai(mjai_action, gi) + + # "button" action + elif mjai_type in [MjaiType.NONE, MjaiType.CHI, MjaiType.PON, MjaiType.DAIMINKAN, MjaiType.ANKAN, + MjaiType.KAKAN, MjaiType.HORA, MjaiType.REACH, MjaiType.RYUKYOKU, MjaiType.NUKIDORA]: + liqi_operation = game_state.last_operation + more_steps:list[ActionStep] = self.steps_button_action(mjai_action, gi, liqi_operation) + + else: + LOGGER.error("No automation for unrecognized mjai type: %s", mjai_type) + return False + + delay = self.get_delay(mjai_action, gi, game_state.last_reaction_time) # initial delay + action_steps:list[ActionStep] = [ActionStepDelay(delay)] + action_steps.extend(more_steps) + pai = mjai_action.get('pai',"") + calc_time = game_state.last_reaction_time + desc = ( + f"Automating action {mjai_type} {pai}" + f" (step={op_step}," + f" calc_time={calc_time:.2f}s, delay={delay:.2f}s, total_delay={calc_time+delay:.2f}s)" + ) + self._task = self._create_task(f"Auto_{mjai_type}_{pai}", desc) + self._task.start_action_steps(action_steps, game_state) + return True + + def randomize_action(self, action:dict, gi:GameInfo) -> dict: + """ Randomize ai choice: pick according to probaility from top 3 options""" + n = self.st.ai_randomize_choice # randomize strength. 0 = no random, 5 = according to probability + if n == 0: + return action + mjai_type = action['type'] + if mjai_type == MjaiType.DAHAI: + orig_pai = action['pai'] + options:dict = action['meta_options'] # e.g. {'1m':0.95, 'P':0.045, 'N':0.005, ...} + # get dahai options (tile only) from top 3 + top_ops:list = [(k,v) for k,v in options[:3] if k in MJAI_TILES_SORTED] + #pick from top3 according to probability + power = 1 / (0.2 * n) + sum_probs = sum([v**power for k,v in top_ops]) + top_ops_powered = [(k, v**power/sum_probs) for k,v in top_ops] + + # 1. Calculate cumulative probabilities + cumulative_probs = [top_ops_powered[0][1]] + for i in range(1, len(top_ops_powered)): + cumulative_probs.append(cumulative_probs[-1] + top_ops_powered[i][1]) + + # 2. Pick an option based on a random number + rand_prob = random.random() # Random float: 0.0 <= x < 1.0 + chosen_pai = orig_pai # Default in case no option is selected, for safety + prob = top_ops_powered[0][1] + for i, cum_prob in enumerate(cumulative_probs): + if rand_prob < cum_prob: + chosen_pai = top_ops_powered[i][0] # This is the selected key based on probability + prob = top_ops_powered[i][1] # the probability + orig_prob = top_ops[i][1] + break + + if chosen_pai == orig_pai: # return original action if no change + change_str = f"{action['pai']} Unchanged" + else: + change_str = f"{action['pai']} -> {chosen_pai}" + + # generate new action for changed tile + tsumogiri = chosen_pai == gi.my_tsumohai + new_action = { + 'type': MjaiType.DAHAI, + 'actor': action['actor'], + 'pai': chosen_pai, + 'tsumogiri': tsumogiri + } + msg = f"Randomized dahai: {change_str} ([{n}] {orig_prob*100:.1f}% -> {prob*100:.1f}%)" + LOGGER.debug(msg) + return new_action + # other MJAI types + else: + return action + + + def last_exec_time(self) -> float: + """ return the time of last action execution. return -1 if N/A""" + if self._task: + return self._task.last_exe_time + else: + return -1 + + def automate_retry_pending(self, game_state:GameState): + """ retry pending action from game state""" + if not self.can_automate(True, UiState.IN_GAME): + return + if time.time() - self.last_exec_time() < self.st.auto_retry_interval: + # interval not reached, cancel + return False + if game_state is None: + return False + pend_action = game_state.get_pending_reaction() + if pend_action is None: + return + LOGGER.info("Retry automating pending reaction: %s", pend_action['type']) + self.automate_action(pend_action, game_state) + + + def automate_send_emoji(self): + """ Send emoji given chance + """ + if not self.can_automate(True, UiState.IN_GAME): + return + if time.time() - self.last_emoji_time < self.st.auto_emoji_intervel: # prevent spamming + return + roll = random.random() + if roll > self.st.auto_reply_emoji_rate: # send when roll < rate + return + + + idx = random.randint(0, 8) + x,y = Positions.EMOJI_BUTTON + steps = [ActionStepDelay(random.uniform(1.5, 3.0)), ActionStepMove(x*self.scaler, y*self.scaler)] + steps.append(ActionStepDelay(random.uniform(0.1, 0.2))) + steps.append(ActionStepClick()) + x,y = Positions.EMOJIS[idx] + steps.append(ActionStepMove(x*self.scaler,y*self.scaler)) + steps.append(ActionStepDelay(random.uniform(0.1, 0.2))) + steps.append(ActionStepClick()) + self._task = self._create_task(f"SendEmoji{idx}", f"Send emoji {idx}") self._task.start_action_steps(steps, None) self.last_emoji_time = time.time() - - def automate_idle_mouse_move(self, prob:float): - """ move mouse around to avoid AFK. according to probability""" - if not self.can_automate(True, UiState.IN_GAME): - return False - if not self.st.auto_idle_move: - return - - roll = random.random() - if prob > roll: - action_steps = self.steps_move_to_center(False) - action_steps += self.steps_move_to_center(False) - action_steps += self.steps_move_to_center(False) - self._task = AutomationTask(self.executor, "IdleMove", "Move mouse around in other's turn") + + def automate_idle_mouse_move(self, prob:float): + """ move mouse around to avoid AFK. according to probability""" + if not self.can_automate(True, UiState.IN_GAME): + return False + if not self.st.auto_idle_move: + return + + roll = random.random() + if prob > roll: + action_steps = self.steps_move_to_center(False) + action_steps += self.steps_move_to_center(False) + action_steps += self.steps_move_to_center(False) + self._task = self._create_task("IdleMove", "Move mouse around in other's turn") self._task.start_action_steps(action_steps, None) - - - def steps_action_dahai(self, mjai_action:dict, gi:GameInfo) -> list[ActionStep]: - """ generate steps for dahai (discard tile) action - params: - mjai_action(dict): mjai output action msg - gi(GameInfo): game info object - """ - - dahai = mjai_action['pai'] - tsumogiri = mjai_action['tsumogiri'] - if tsumogiri: # tsumogiri: discard right most - dahai_count = len([tile for tile in gi.my_tehai if tile != '?']) - assert dahai == gi.my_tsumohai, f"tsumogiri but dahai {dahai} != game tsumohai {gi.my_tsumohai}" - # Majsoul on East first round: 14-tile tehai + no tsumohai - if gi.is_first_round and gi.jikaze == 'E': - x = Positions.TEHAI_X[dahai_count] - y = Positions.TEHAI_Y - else: - x = Positions.TEHAI_X[dahai_count] + Positions.TRUMO_SPACE - y = Positions.TEHAI_Y - steps = self.steps_randomized_move(x, y) - else: # tedashi: find the index and discard - idx = gi.my_tehai.index(dahai) - steps = self.steps_randomized_move(Positions.TEHAI_X[idx], Positions.TEHAI_Y) - - # drag or click to dahai - if self.st.auto_dahai_drag: - steps += self.steps_mouse_drag_to_center() - else: - steps += self.steps_mouse_click() - steps += self.steps_move_to_center(True) # move to screen center to avoid highlighting a tile. - return steps - - - - def _process_oplist_for_kan(self, mstype_from_mjai, op_list:list) -> list: - """ Process operation list for kan, and return the new op list""" - # ankan and kakan use one Kan button, and candidates are merged = [kakan, ankan] - # determine the kan type used by mjai - kan_combs:list[str] = [] - idx_to_keep = None - idx_to_del = None - for idx, op in enumerate(op_list): - op_type = op['type'] - if op_type in (MSType.kakan, MSType.ankan): - if op_type== MSType.kakan: - kan_combs = op['combination'] + kan_combs - elif op_type == MSType.ankan: - kan_combs = kan_combs + op['combination'] - if op_type == mstype_from_mjai: - idx_to_keep = idx - else: - idx_to_del = idx - - # merge kan combinations into the used kan type, delete the other type from operation list - if idx_to_keep is None: - LOGGER.error("No matching type %s found in op list: %s", mstype_from_mjai, op_list) - return op_list - op_list[idx_to_keep]['combination'] = kan_combs - if idx_to_del is not None: - op_list.pop(idx_to_del) - return op_list - - - def steps_button_action(self, mjai_action:dict, gi:GameInfo, liqi_operation:dict) -> list[ActionStep]: - """Generate action steps for button actions (chi, pon, kan, etc.)""" - if 'operationList' not in liqi_operation: # no liqi operations provided - no buttons to click - return [] - - op_list:list = liqi_operation['operationList'] - op_list = op_list.copy() - op_list.append({'type': 0}) # add None/Pass button - op_list.sort(key=lambda x: ACTION_PIORITY[x['type']]) # sort operation list by priority - - mjai_type = mjai_action['type'] - mstype_from_mjai = cvt_type_mjai_2_ms(mjai_type, gi) - - if mstype_from_mjai in [MSType.ankan, MSType.kakan]: - op_list = self._process_oplist_for_kan(mstype_from_mjai, op_list) - - # Find the button coords and click (None, Chii, Pon, etc.) - steps = [] - the_op = None - for idx, op in enumerate(op_list): - if op['type'] == mstype_from_mjai: - x, y = Positions.BUTTONS[idx] - steps += self.steps_randomized_move_click(x,y) - the_op = op - break - if the_op is None: # something's wrong - LOGGER.error("for mjai %s liqi msg has no op list. Op list: %s", mjai_type, op_list) - return steps - - # Reach: process subsequent reach dahai action - if mstype_from_mjai == MSType.reach: - reach_dahai = mjai_action['reach_dahai'] - delay = self.get_delay(reach_dahai, gi) - steps.append(ActionStepDelay(delay)) - dahai_steps = self.steps_action_dahai(reach_dahai, gi) - steps += dahai_steps - return steps - - # chi / pon / kan: click candidate (choose from options) - elif mstype_from_mjai in [MSType.chi, MSType.pon, MSType.daiminkan, MSType.kakan, MSType.ankan]: - # e.g. {'type': 'chi', 'actor': 3, 'target': 2, 'pai': '4m', 'consumed': ['3m', '5mr'], ...}""" - mjai_consumed = mjai_action['consumed'] - mjai_consumed = sort_mjai_tiles(mjai_consumed) - if 'combination' in the_op: - combs = the_op['combination'] - else: - combs = [] - - if len(combs) == 1: # no need to click. return directly - return steps - elif len(combs) == 0: # something is wrong. no combination offered in liqi msg - LOGGER.warning("mjai type %s, but no combination in liqi operation list", mjai_type) - return steps - for idx, comb in enumerate(combs): - # for more than one candidate group, click on the matching group - consumed_liqi = [cvt_ms2mjai(t) for t in comb.split('|')] - consumed_liqi = sort_mjai_tiles(consumed_liqi) # convert to mjai tile format for comparison - if mjai_consumed == consumed_liqi: # match. This is the combination to click on - delay = len(combs) * (0.5 + random.random()) - steps.append(ActionStepDelay(delay)) - if mstype_from_mjai in [MSType.chi, MSType.pon, MSType.daiminkan]: - # well, pon/daiminkan only has 1 combination, wouldn't need choosing - candidate_idx = int((-(len(combs)/2)+idx+0.5)*2+5) - x,y = Positions.CANDIDATES[candidate_idx] - steps += self.steps_randomized_move_click(x,y) - elif mstype_from_mjai in [MSType.ankan, MSType.kakan]: - candidate_idx = int((-(len(combs)/2)+idx+0.5)*2+3) - x,y = Positions.CANDIDATES_KAN[candidate_idx] - steps += self.steps_randomized_move_click(x,y) - return steps - - # other mjai types: no additional clicks - else: - return steps - - @property - def scaler(self): - """ scaler for 16x9 -> game resolution""" - return self.executor.width/16 - - def steps_randomized_move(self, x:float, y:float) -> list[ActionStep]: - """ generate list of steps for a randomized mouse move - Params: - x, y: target position in 16x9 resolution - random_moves(int): number of random moves before target. None -> use settings""" - steps = [] - if self.st.auto_random_move: # random moves, within (-0.5, 0.5) x screen size of target - for _i in range(3): - rx = x + 16*random.uniform(-0.5, 0.5) - rx = max(0, min(16, rx)) - ry = y + 9*random.uniform(-0.5, 0.5) - ry = max(0, min(9, ry)) - steps.append(ActionStepMove(rx*self.scaler, ry*self.scaler, random.randint(2, 5))) - steps.append(ActionStepDelay(random.uniform(0.05, 0.11))) - # then move to target - tx, ty = x*self.scaler, y*self.scaler - steps.append(ActionStepMove(tx, ty, random.randint(2, 5))) - return steps - - def steps_randomized_move_click(self, x:float, y:float) -> list[ActionStep]: - """ generate list of steps for a randomized mouse move and click - Params: - x, y: target position in 16x9 resolution - random_moves(int): number of random moves before target. None -> use settings""" - steps = self.steps_randomized_move(x, y) - steps.append(ActionStepDelay(random.uniform(0.3, 0.5))) - steps.append(ActionStepClick(random.randint(60, 100))) - return steps - - def steps_mouse_click(self) -> list[ActionStep]: - """ generate list of steps for a simple mouse click""" - steps = [] - steps.append(ActionStepDelay(random.uniform(0.2, 0.4))) - steps.append(ActionStepClick(random.randint(60, 100))) - return steps - - def steps_mouse_drag_to_center(self) -> list[ActionStep]: - """ steps for dragging to center (e.g. for dahai)""" - steps = [] - steps.append(ActionStepDelay(random.uniform(0.1, 0.3))) - steps.append(ActionStepMouseDown()) - steps += self.steps_move_to_center(False) - steps.append(ActionStepDelay(random.uniform(0.1, 0.3))) - steps.append(ActionStepMouseUp()) - return steps - - def steps_move_to_center(self, ignore_step_change:bool=False) -> list[ActionStep]: - """ get action steps for moving the mouse to screen center""" - # Ignore step change (even during other players' turns) - steps = [] - delay_step = ActionStepDelay(random.uniform(0.2, 0.3)) - delay_step.ignore_step_change = ignore_step_change - steps.append(delay_step) - - xmid, ymid = 16 * random.uniform(0.25, 0.75), 9 * random.uniform(0.25, 0.75) - move_step = ActionStepMove(xmid*self.scaler, ymid*self.scaler, random.randint(2, 5)) - move_step.ignore_step_change = ignore_step_change - steps.append(move_step) - return steps - - def steps_random_wheels(self, total_dx:float, total_dy:float) -> list[ActionStep]: - """ list of steps for mouse wheel - params: - total_dx, total_dy: total distance to wheel move""" - # break the wheel action into several steps - steps = [] - times = random.randint(4, 6) - for _i in range(times): - dx = total_dx / times - dy = total_dy / times - steps.append(ActionStepWheel(dx, dy)) - steps.append(ActionStepDelay(random.uniform(0.05, 0.10))) - return steps - - def on_lobby_login(self, _liqimsg:dict): - """ lobby login handler""" - if self.ui_state != UiState.IN_GAME: - self.stop_previous() - self.ui_state = UiState.MAIN_MENU - - def on_enter_game(self): - """ enter game handler""" - self.stop_previous() - self.ui_state = UiState.IN_GAME - - def on_end_game(self): - """ end game handler""" - 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""" - if self.ui_state != UiState.IN_GAME: - self.stop_previous() - self.ui_state = UiState.NOT_RUNNING - - def automate_end_game(self): - """Automate Game end go back to menu""" - if not self.can_automate(): - return False - if self.st.auto_join_game is False: - return False - self.stop_previous() - - self._task = AutomationTask(self.executor, END_GAME, "Going back to main menu from game ending") + + + def steps_action_dahai(self, mjai_action:dict, gi:GameInfo) -> list[ActionStep]: + """ generate steps for dahai (discard tile) action + params: + mjai_action(dict): mjai output action msg + gi(GameInfo): game info object + """ + + dahai = mjai_action['pai'] + tsumogiri = mjai_action['tsumogiri'] + if tsumogiri: # tsumogiri: discard right most + dahai_count = len([tile for tile in gi.my_tehai if tile != '?']) + assert dahai == gi.my_tsumohai, f"tsumogiri but dahai {dahai} != game tsumohai {gi.my_tsumohai}" + # Majsoul on East first round: 14-tile tehai + no tsumohai + if gi.is_first_round and gi.jikaze == 'E': + x = Positions.TEHAI_X[dahai_count] + y = Positions.TEHAI_Y + else: + x = Positions.TEHAI_X[dahai_count] + Positions.TRUMO_SPACE + y = Positions.TEHAI_Y + steps = self.steps_randomized_move(x, y) + else: # tedashi: find the index and discard + idx = gi.my_tehai.index(dahai) + steps = self.steps_randomized_move(Positions.TEHAI_X[idx], Positions.TEHAI_Y) + + # drag or click to dahai + if self.st.auto_dahai_drag: + steps += self.steps_mouse_drag_to_center() + else: + steps += self.steps_mouse_click() + steps += self.steps_move_to_center(True) # move to screen center to avoid highlighting a tile. + return steps + + + + def _process_oplist_for_kan(self, mstype_from_mjai, op_list:list) -> list: + """ Process operation list for kan, and return the new op list""" + # ankan and kakan use one Kan button, and candidates are merged = [kakan, ankan] + # determine the kan type used by mjai + kan_combs:list[str] = [] + idx_to_keep = None + idx_to_del = None + for idx, op in enumerate(op_list): + op_type = op['type'] + if op_type in (MSType.kakan, MSType.ankan): + if op_type== MSType.kakan: + kan_combs = op['combination'] + kan_combs + elif op_type == MSType.ankan: + kan_combs = kan_combs + op['combination'] + if op_type == mstype_from_mjai: + idx_to_keep = idx + else: + idx_to_del = idx + + # merge kan combinations into the used kan type, delete the other type from operation list + if idx_to_keep is None: + LOGGER.error("No matching type %s found in op list: %s", mstype_from_mjai, op_list) + return op_list + op_list[idx_to_keep]['combination'] = kan_combs + if idx_to_del is not None: + op_list.pop(idx_to_del) + return op_list + + + def steps_button_action(self, mjai_action:dict, gi:GameInfo, liqi_operation:dict) -> list[ActionStep]: + """Generate action steps for button actions (chi, pon, kan, etc.)""" + if 'operationList' not in liqi_operation: # no liqi operations provided - no buttons to click + return [] + + op_list:list = liqi_operation['operationList'] + op_list = op_list.copy() + op_list.append({'type': 0}) # add None/Pass button + op_list.sort(key=lambda x: ACTION_PIORITY[x['type']]) # sort operation list by priority + + mjai_type = mjai_action['type'] + mstype_from_mjai = cvt_type_mjai_2_ms(mjai_type, gi) + + if mstype_from_mjai in [MSType.ankan, MSType.kakan]: + op_list = self._process_oplist_for_kan(mstype_from_mjai, op_list) + + # Find the button coords and click (None, Chii, Pon, etc.) + steps = [] + the_op = None + for idx, op in enumerate(op_list): + if op['type'] == mstype_from_mjai: + x, y = Positions.BUTTONS[idx] + steps += self.steps_randomized_move_click(x,y) + the_op = op + break + if the_op is None: # something's wrong + LOGGER.error("for mjai %s liqi msg has no op list. Op list: %s", mjai_type, op_list) + return steps + + # Reach: process subsequent reach dahai action + if mstype_from_mjai == MSType.reach: + reach_dahai = mjai_action['reach_dahai'] + delay = self.get_delay(reach_dahai, gi) + steps.append(ActionStepDelay(delay)) + dahai_steps = self.steps_action_dahai(reach_dahai, gi) + steps += dahai_steps + return steps + + # chi / pon / kan: click candidate (choose from options) + elif mstype_from_mjai in [MSType.chi, MSType.pon, MSType.daiminkan, MSType.kakan, MSType.ankan]: + # e.g. {'type': 'chi', 'actor': 3, 'target': 2, 'pai': '4m', 'consumed': ['3m', '5mr'], ...}""" + mjai_consumed = mjai_action['consumed'] + mjai_consumed = sort_mjai_tiles(mjai_consumed) + if 'combination' in the_op: + combs = the_op['combination'] + else: + combs = [] + + if len(combs) == 1: # no need to click. return directly + return steps + elif len(combs) == 0: # something is wrong. no combination offered in liqi msg + LOGGER.warning("mjai type %s, but no combination in liqi operation list", mjai_type) + return steps + for idx, comb in enumerate(combs): + # for more than one candidate group, click on the matching group + consumed_liqi = [cvt_ms2mjai(t) for t in comb.split('|')] + consumed_liqi = sort_mjai_tiles(consumed_liqi) # convert to mjai tile format for comparison + if mjai_consumed == consumed_liqi: # match. This is the combination to click on + delay = len(combs) * (0.5 + random.random()) + steps.append(ActionStepDelay(delay)) + if mstype_from_mjai in [MSType.chi, MSType.pon, MSType.daiminkan]: + # well, pon/daiminkan only has 1 combination, wouldn't need choosing + candidate_idx = int((-(len(combs)/2)+idx+0.5)*2+5) + x,y = Positions.CANDIDATES[candidate_idx] + steps += self.steps_randomized_move_click(x,y) + elif mstype_from_mjai in [MSType.ankan, MSType.kakan]: + candidate_idx = int((-(len(combs)/2)+idx+0.5)*2+3) + x,y = Positions.CANDIDATES_KAN[candidate_idx] + steps += self.steps_randomized_move_click(x,y) + return steps + + # other mjai types: no additional clicks + else: + return steps + + @property + def scaler(self): + """ scaler for 16x9 -> game resolution""" + return self.executor.width/16 + + def steps_randomized_move(self, x:float, y:float) -> list[ActionStep]: + """ generate list of steps for a randomized mouse move + Params: + x, y: target position in 16x9 resolution + random_moves(int): number of random moves before target. None -> use settings""" + steps = [] + if self.st.auto_random_move: # random moves, within (-0.5, 0.5) x screen size of target + for _i in range(3): + rx = x + 16*random.uniform(-0.5, 0.5) + rx = max(0, min(16, rx)) + ry = y + 9*random.uniform(-0.5, 0.5) + ry = max(0, min(9, ry)) + steps.append(ActionStepMove(rx*self.scaler, ry*self.scaler, random.randint(2, 5))) + steps.append(ActionStepDelay(random.uniform(0.05, 0.11))) + # then move to target + tx, ty = x*self.scaler, y*self.scaler + steps.append(ActionStepMove(tx, ty, random.randint(2, 5))) + return steps + + def steps_randomized_move_click(self, x:float, y:float) -> list[ActionStep]: + """ generate list of steps for a randomized mouse move and click + Params: + x, y: target position in 16x9 resolution + random_moves(int): number of random moves before target. None -> use settings""" + steps = self.steps_randomized_move(x, y) + steps.append(ActionStepDelay(random.uniform(0.3, 0.5))) + steps.append(ActionStepClick(random.randint(60, 100))) + return steps + + def steps_mouse_click(self) -> list[ActionStep]: + """ generate list of steps for a simple mouse click""" + steps = [] + steps.append(ActionStepDelay(random.uniform(0.2, 0.4))) + steps.append(ActionStepClick(random.randint(60, 100))) + return steps + + def steps_mouse_drag_to_center(self) -> list[ActionStep]: + """ steps for dragging to center (e.g. for dahai)""" + steps = [] + steps.append(ActionStepDelay(random.uniform(0.1, 0.3))) + steps.append(ActionStepMouseDown()) + steps += self.steps_move_to_center(False) + steps.append(ActionStepDelay(random.uniform(0.1, 0.3))) + steps.append(ActionStepMouseUp()) + return steps + + def steps_move_to_center(self, ignore_step_change:bool=False) -> list[ActionStep]: + """ get action steps for moving the mouse to screen center""" + # Ignore step change (even during other players' turns) + steps = [] + delay_step = ActionStepDelay(random.uniform(0.2, 0.3)) + delay_step.ignore_step_change = ignore_step_change + steps.append(delay_step) + + xmid, ymid = 16 * random.uniform(0.25, 0.75), 9 * random.uniform(0.25, 0.75) + move_step = ActionStepMove(xmid*self.scaler, ymid*self.scaler, random.randint(2, 5)) + move_step.ignore_step_change = ignore_step_change + steps.append(move_step) + return steps + + def steps_random_wheels(self, total_dx:float, total_dy:float) -> list[ActionStep]: + """ list of steps for mouse wheel + params: + total_dx, total_dy: total distance to wheel move""" + # break the wheel action into several steps + steps = [] + times = random.randint(4, 6) + for _i in range(times): + dx = total_dx / times + dy = total_dy / times + steps.append(ActionStepWheel(dx, dy)) + steps.append(ActionStepDelay(random.uniform(0.05, 0.10))) + return steps + + def on_lobby_login(self, _liqimsg:dict): + """ lobby login handler""" + if self.ui_state != UiState.IN_GAME: + self.stop_previous() + self.ui_state = UiState.MAIN_MENU + + def on_enter_game(self): + """ enter game handler""" + self.stop_previous() + self.ui_state = UiState.IN_GAME + + def on_end_game(self): + """ end game handler""" + 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""" + if self.ui_state != UiState.IN_GAME: + self.stop_previous() + self.ui_state = UiState.NOT_RUNNING + + def automate_end_game(self): + """Automate Game end go back to menu""" + if not self.can_automate(): + return False + if self.st.auto_join_game is False: + return False + self.stop_previous() + + self._task = self._create_task(END_GAME, "Going back to main menu from game ending") self._task.start_action_steps(self._end_game_iter(), None) return True - - def _end_game_iter(self) -> Iterator[ActionStep]: - # generate action steps for exiting a match until main menu tested - 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)) - - x,y = Positions.GAMEOVER[0] - for step in self.steps_randomized_move_click(x,y): - yield step - - def automate_join_game(self): - """ Automate join next game """ - if not self.can_automate(): - return False - 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})" - self._task = AutomationTask(self.executor, JOIN_GAME, desc) + + def _end_game_iter(self) -> Iterator[ActionStep]: + # generate action steps for exiting a match until main menu tested + 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)) + + x,y = Positions.GAMEOVER[0] + for step in self.steps_randomized_move_click(x,y): + yield step + + def automate_join_game(self): + """ Automate join next game """ + if not self.can_automate(): + return False + 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})" + self._task = self._create_task(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 - - 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] - for step in self.steps_randomized_move_click(x,y): - yield step - yield ActionStepDelay(random.uniform(0.5, 1.5)) - - # click on level - if self.st.auto_join_level >= 3: # jade/throne requires mouse wheel - wx,wy = Positions.LEVELS[1] # wheel at this position - 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)) - x,y = Positions.LEVELS[self.st.auto_join_level] - for step in self.steps_randomized_move_click(x,y): - yield step - yield ActionStepDelay(random.uniform(0.5, 1.5)) - - # click on mode - 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 - - def decide_lobby_action(self): - """ decide what "lobby action" to execute based on current state.""" - if not self.can_automate(True): - return - if self._task: # Cancel if interval not reached - if time.time() - self._task.last_exe_time < self.st.auto_retry_interval: - return False - - if self.ui_state == UiState.NOT_RUNNING: - pass - elif self.ui_state == UiState.MAIN_MENU: - self.automate_join_game() - elif self.ui_state == UiState.IN_GAME: - pass - elif self.ui_state == UiState.GAME_ENDING: - self.automate_end_game() - else: - LOGGER.error("Unknow UI state:%s", self.ui_state) + + def _join_game_iter(self) -> Iterator[ActionStep]: + # generate action steps for joining next game + + 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] + for step in self.steps_randomized_move_click(x,y): + yield step + yield ActionStepDelay(random.uniform(0.5, 1.5)) + + # click on level + if self.st.auto_join_level >= 3: # jade/throne requires mouse wheel + wx,wy = Positions.LEVELS[1] # wheel at this position + 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)) + x,y = Positions.LEVELS[self.st.auto_join_level] + for step in self.steps_randomized_move_click(x,y): + yield step + yield ActionStepDelay(random.uniform(0.5, 1.5)) + + # click on mode + 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 + + def decide_lobby_action(self): + """ decide what "lobby action" to execute based on current state.""" + if not self.can_automate(True): + return + if self._task: # Cancel if interval not reached + if time.time() - self._task.last_exe_time < self.st.auto_retry_interval: + return False + + if self.ui_state == UiState.NOT_RUNNING: + pass + elif self.ui_state == UiState.MAIN_MENU: + self.automate_join_game() + elif self.ui_state == UiState.IN_GAME: + pass + elif self.ui_state == UiState.GAME_ENDING: + self.automate_end_game() + else: + LOGGER.error("Unknow UI state:%s", self.ui_state) diff --git a/game/browser.py b/game/browser.py index ac8a0ee..5bcc6f7 100644 --- a/game/browser.py +++ b/game/browser.py @@ -1,201 +1,468 @@ """ Game Broswer class for controlling maj-soul web client operations""" +import json import logging +import sys import time import threading import queue import os - -from io import BytesIO -from playwright._impl._errors import TargetClosedError -from playwright.sync_api import sync_playwright, BrowserContext, Page -from common import utils -from common.utils import Folder, FPSCounter, list_children -from common.log_helper import LOGGER - +import pathlib +import ctypes +import math +import random + +from io import BytesIO +from playwright._impl._errors import TargetClosedError +from playwright.sync_api import sync_playwright, BrowserContext, Page +from common import utils +from common.utils import Folder, FPSCounter, list_children +from common.log_helper import LOGGER + 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""" - + """ Wrapper for Playwright browser controlling maj-soul operations + Browser runs in a thread, and actions are queued to be processed by the thread""" + def __init__(self, width:int, height:int): """ Set browser with viewport size (width, height)""" self.width = width self.height = height + self.real_mouse_offset_x = self._read_env_int("MJ_REAL_MOUSE_OFFSET_X", 0) + self.real_mouse_offset_y = self._read_env_int("MJ_REAL_MOUSE_OFFSET_Y", 0) + self.real_mouse_speed_pps = max(300.0, self._read_env_float("MJ_REAL_MOUSE_SPEED_PPS", 2200.0)) + self.real_mouse_jitter_px = max(0.0, self._read_env_float("MJ_REAL_MOUSE_JITTER_PX", 2.0)) + self.real_mouse_click_offset_px = max(0.0, self._read_env_float("MJ_REAL_MOUSE_CLICK_OFFSET_PX", 2.0)) + self.real_mouse_min_move_ms = max(30, self._read_env_int("MJ_REAL_MOUSE_MIN_MOVE_MS", 100)) + self.real_mouse_max_move_ms = max( + self.real_mouse_min_move_ms, + self._read_env_int("MJ_REAL_MOUSE_MAX_MOVE_MS", 450) + ) + self.browser_profile_dir = self._get_profile_dir() self._action_queue = queue.Queue() # thread safe queue for actions self._stop_event = threading.Event() # set this event to stop processing actions self._browser_thread = None self.init_vars() + @staticmethod + def _read_env_int(name:str, default:int=0) -> int: + """ Read integer env var with fallback.""" + try: + return int(os.environ.get(name, str(default))) + except (TypeError, ValueError): + return default + + @staticmethod + def _read_env_float(name:str, default:float=0.0) -> float: + """ Read float env var with fallback.""" + try: + return float(os.environ.get(name, str(default))) + except (TypeError, ValueError): + return default + + def _get_profile_dir(self, stable:bool=False) -> str: + """Return browser user data directory path. + + stable=True keeps cache/profile between launches (used by mac high-quality mode). + stable=False isolates profile per process. + """ + base_dir = pathlib.Path(utils.sub_folder(Folder.BROWSER_DATA)) + if stable: + profile_dir = base_dir / "profile_default" + else: + pid = os.getpid() + profile_dir = base_dir / f"instance_{pid}" + profile_dir.mkdir(parents=True, exist_ok=True) + return str(profile_dir.resolve()) + + @staticmethod + def _is_profile_dir_error(error:Exception) -> bool: + """Return True if launch error likely caused by locked/invalid profile directory.""" + msg = str(error).lower() + keywords = ( + "singleton", + "profile", + "user data directory", + "lock", + "in use", + "access is denied", + ) + return any(k in msg for k in keywords) + + def _launch_context_with_channel_fallback(self, chromium, launch_kwargs:dict) -> BrowserContext: + """Launch Chromium context, with optional system-channel fallback for missing binaries.""" + try: + return chromium.launch_persistent_context(**launch_kwargs) + except Exception as e: + msg = str(e) + if "Executable doesn't exist" not in msg and "playwright install" not in msg: + raise + for channel in ("msedge", "chrome"): + try: + LOGGER.warning( + "Playwright Chromium executable missing; falling back to channel=%s", + channel + ) + return chromium.launch_persistent_context(channel=channel, **launch_kwargs) + except Exception: + continue + raise + + @staticmethod + def _playwright_driver_package_dir() -> pathlib.Path | None: + """Locate Playwright driver 'package' directory. + + In PyInstaller one-dir builds, it's typically under: + /_internal/playwright/driver/package + + In normal Python runs, it's under the installed playwright package: + /playwright/driver/package + """ + # PyInstaller: sys._MEIPASS points to the unpacked/bundled internal dir. + try: + meipass = getattr(sys, "_MEIPASS", None) # pylint: disable=protected-access + except Exception: + meipass = None + if meipass: + bundled = pathlib.Path(meipass) / "playwright" / "driver" / "package" + if bundled.exists(): + return bundled + + # Regular Python: locate via playwright module path. + try: + import playwright # pylint: disable=import-outside-toplevel + pkg_dir = pathlib.Path(playwright.__file__).resolve().parent / "driver" / "package" + if pkg_dir.exists(): + return pkg_dir + except Exception: + return None + + return None + + @staticmethod + def _read_playwright_chromium_revision(driver_pkg_dir:pathlib.Path) -> str | None: + """Read expected Chromium revision from Playwright's browsers.json.""" + browsers_json = driver_pkg_dir / "browsers.json" + if not browsers_json.exists(): + return None + try: + with browsers_json.open("r", encoding="utf-8") as f: + data = json.load(f) + for browser in data.get("browsers", []): + if browser.get("name") == "chromium": + rev = browser.get("revision") + return str(rev) if rev else None + except Exception: + return None + return None + + @staticmethod + def _chromium_executable_relpath() -> pathlib.Path: + """Chromium executable relative path inside a browser revision folder.""" + if os.name == "nt": + return pathlib.Path("chrome-win") / "chrome.exe" + if sys.platform == "darwin": + return pathlib.Path("chrome-mac") / "Chromium.app" / "Contents" / "MacOS" / "Chromium" + return pathlib.Path("chrome-linux") / "chrome" + + def _chromium_executable_exists(self, browsers_root:str, revision:str, + driver_pkg_dir:pathlib.Path) -> bool: + """Return True if chromium exe exists for given browsers root. + + browsers_root can be: + - "0": Playwright special value meaning driver_pkg_dir/.local-browsers + - an absolute path to the cache root (containing chromium-/...) + - an absolute path to the '.local-browsers' directory itself + """ + try: + if browsers_root == "0": + root = driver_pkg_dir / ".local-browsers" + else: + root = pathlib.Path(browsers_root) + exe = root / f"chromium-{revision}" / self._chromium_executable_relpath() + return exe.exists() and exe.is_file() + except Exception: + return False + + def _prepare_playwright_browsers_path(self): + """Best-effort fix for packaged builds missing bundled Playwright browsers. + + Playwright sometimes resolves browsers under driver_pkg_dir/.local-browsers in frozen apps. + If the bundled browsers are missing, we fall back to the default Playwright cache + (e.g. %LOCALAPPDATA%\\ms-playwright on Windows) when available. + """ + driver_pkg_dir = self._playwright_driver_package_dir() + if driver_pkg_dir is None: + return + + revision = self._read_playwright_chromium_revision(driver_pkg_dir) + if not revision: + return + + env_val = os.environ.get("PLAYWRIGHT_BROWSERS_PATH") + if env_val and self._chromium_executable_exists(env_val, revision, driver_pkg_dir): + return + + # Prefer bundled browsers when present (keeps exe self-contained). + bundled_roots = [ + driver_pkg_dir / "ms-playwright", # mac packaging path (codesign-friendly) + driver_pkg_dir / ".local-browsers", # legacy path + ] + for bundled_root in bundled_roots: + if self._chromium_executable_exists(str(bundled_root), revision, driver_pkg_dir): + os.environ["PLAYWRIGHT_BROWSERS_PATH"] = str(bundled_root) + return + + # Fallback to default cache (common when playwright install was run without PLAYWRIGHT_BROWSERS_PATH=0). + if os.name == "nt": + local_app_data = os.environ.get("LOCALAPPDATA") + if local_app_data: + cache_root = pathlib.Path(local_app_data) / "ms-playwright" + if self._chromium_executable_exists(str(cache_root), revision, driver_pkg_dir): + os.environ["PLAYWRIGHT_BROWSERS_PATH"] = str(cache_root) + return + def init_vars(self): """ initialize internal variables""" self.context:BrowserContext = None self.page:Page = None # playwright page, only used by thread self.fps_counter = FPSCounter() - - # for tracking page info - self._page_title:str = None + + # for tracking page info + self._page_title:str = None self._last_update_time:float = 0 self.zoomlevel_check:float = None + self.nonfatal_nav_error_count:int = 0 + self.runtime_inner_width:float = float(self.width) + self.runtime_inner_height:float = float(self.height) + self.runtime_game_rect:tuple[float, float, float, float] = (0.0, 0.0, float(self.width), float(self.height)) + self._fullscreen_runtime:bool = False + self._overlay_start_pending:bool = False # overlay info self._canvas_id = None # for overlay self._last_botleft_text = None self._last_guide = None - - def __del__(self): - self.stop() - - def start(self, url:str, proxy:str=None, width:int=None, height:int=None, enable_chrome_ext:bool=False): + self._real_mouse_hwnd:int | None = None + + def __del__(self): + self.stop() + + def start(self, url:str, proxy:str=None, width:int=None, height:int=None, + enable_chrome_ext:bool=False, fullscreen:bool=False, high_quality:bool=False): """ Launch the browser in a thread, and start processing action queue params: url(str): url of the page to open upon browser launch proxy(str): proxy server to use. e.g. http://1.2.3.4:555" width, height: viewport width and height enable_ext: True to enable chrome extensions + fullscreen: start browser in fullscreen (mac only) + high_quality: use higher quality render args and stable profile cache (mac only) """ # using thread here to avoid playwright sync api not usable in async context (textual) issue if self.is_running(): logging.info('Browser already running.') - return - if width is not None: - self.width = width - if height is not None: - self.height = height + return + if width is not None: + self.width = width + if height is not None: + self.height = height self._clear_action_queue() self._stop_event.clear() self._browser_thread = threading.Thread( target=self._run_browser_and_action_queue, - args=(url, proxy, enable_chrome_ext), + args=(url, proxy, enable_chrome_ext, fullscreen, high_quality), name="BrowserThread", daemon=True) self._browser_thread.start() - def _run_browser_and_action_queue(self, url:str, proxy:str, enable_chrome_ext:bool=False): + def _run_browser_and_action_queue(self, url:str, proxy:str, enable_chrome_ext:bool=False, + fullscreen:bool=False, high_quality:bool=False): """ run browser and keep processing action queue (blocking)""" if proxy: proxy_object = {"server": proxy} else: proxy_object = None - - # read all subfolder names from Folder.CRX and form extension list + self._fullscreen_runtime = bool(sys.platform == "darwin" and fullscreen) + self.nonfatal_nav_error_count = 0 + + # In mac fullscreen/high-quality mode, use a persistent profile to reuse browser cache/resources. + profile_is_stable = bool(sys.platform == "darwin" and (high_quality or fullscreen)) + self.browser_profile_dir = self._get_profile_dir(stable=profile_is_stable) + if profile_is_stable: + LOGGER.info("Using persistent browser profile cache: %s", self.browser_profile_dir) + + # read all subfolder names from Folder.CRX and form extension 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) + # 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) - LOGGER.info('Starting Chromium, viewport=%dx%d, proxy=%s', self.width, self.height, proxy) + # In packaged builds, ensure Playwright can find a browser binary (or fallback to system cache). + self._prepare_playwright_browsers_path() + + launch_args = ["--noerrdialogs", "--no-sandbox"] + if sys.platform == "darwin" and fullscreen: + launch_args.append("--start-fullscreen") + if sys.platform == "darwin" and high_quality: + launch_args.extend([ + "--enable-gpu-rasterization", + "--force-color-profile=srgb", + ]) + if enable_chrome_ext: + launch_args.extend([disable_extensions_except_args, load_extension_args]) + + LOGGER.info( + "Starting Chromium, viewport=%dx%d, proxy=%s, profile=%s, fullscreen=%s, high_quality=%s", + self.width, self.height, proxy, self.browser_profile_dir, fullscreen, high_quality + ) 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 + chromium = playwright.chromium + launch_kwargs = { + "user_data_dir": self.browser_profile_dir, + "headless": False, + "proxy": proxy_object, + "ignore_https_errors": True, + "ignore_default_args": ["--enable-automation"], + "args": launch_args, + } + if self._fullscreen_runtime: + launch_kwargs["no_viewport"] = True 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"] + launch_kwargs["viewport"] = {"width": self.width, "height": self.height} + try: + self.context = self._launch_context_with_channel_fallback(chromium, launch_kwargs) + except Exception as e: + if profile_is_stable and self._is_profile_dir_error(e): + fallback_profile = self._get_profile_dir(stable=False) + LOGGER.warning( + "Stable browser profile failed (%s). Retrying with isolated profile: %s", + e, fallback_profile ) - except Exception as e: + self.browser_profile_dir = fallback_profile + launch_kwargs["user_data_dir"] = self.browser_profile_dir + try: + self.context = self._launch_context_with_channel_fallback(chromium, launch_kwargs) + except Exception as fallback_error: + LOGGER.error("Error launching browser with fallback profile: %s", fallback_error, exc_info=True) + return + else: LOGGER.error('Error launching the browser: %s', e, exc_info=True) return try: - self.page = self.context.new_page() - self.page.goto(url) + pages = [] + for p in self.context.pages: + try: + if not p.is_closed(): + pages.append(p) + except Exception: # pylint: disable=broad-except + continue + if pages: + non_extension_pages = [] + for p in pages: + try: + page_url = str(p.url or "") + except Exception: # pylint: disable=broad-except + page_url = "" + if not page_url.startswith("chrome-extension://"): + non_extension_pages.append(p) + self.page = non_extension_pages[0] if non_extension_pages else pages[0] + for extra_page in pages: + if extra_page is self.page: + continue + try: + extra_url = str(extra_page.url or "") + if extra_url in ("", "about:blank"): + extra_page.close() + except Exception: # pylint: disable=broad-except + pass + else: + self.page = self.context.new_page() + self.page.goto(url, wait_until="domcontentloaded") + self._refresh_runtime_view_metrics() except Exception as e: LOGGER.error('Error opening page. Check if certificate is installed. \n%s',e) # # Do not allow new page tab - # def on_page(page:Page): - # LOGGER.info("Closing additional page. Only one Majsoul page is allowed") - # page.close() - - # if not enable_extensions: - # self.context.on("page", on_page) - + # def on_page(page:Page): + # LOGGER.info("Closing additional page. Only one Majsoul page is allowed") + # page.close() + + # if not enable_extensions: + # self.context.on("page", on_page) + self._clear_action_queue() # keep running actions until stop event is set while self._stop_event.is_set() is False: self.fps_counter.frame() - 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") - self._last_update_time = time.time() - except Exception as e: - LOGGER.warning("Page error %s. exiting.", e) + if self._is_page_closed(): + LOGGER.warning("Page closed. exiting browser loop.") break + if time.time() - self._last_update_time > 1: + try: + self._refresh_runtime_view_metrics() + self.nonfatal_nav_error_count = 0 + self._last_update_time = time.time() + except Exception as e: + if self._is_transient_page_exception(e): + self.nonfatal_nav_error_count += 1 + if self.nonfatal_nav_error_count in (1, 10, 50): + LOGGER.warning( + "Transient page navigation error (count=%d): %s", + self.nonfatal_nav_error_count, e + ) + time.sleep(0.01) + continue + LOGGER.warning("Page error %s. exiting.", e) + break try: action = self._action_queue.get_nowait() action() - # LOGGER.debug("Browser action %s",str(action)) - except queue.Empty: - time.sleep(0.002) - except Exception as e: - LOGGER.error('Error processing action: %s', e, exc_info=True) - - # stop event is set: close browser + # LOGGER.debug("Browser action %s",str(action)) + except queue.Empty: + time.sleep(0.002) + except Exception as e: + LOGGER.error('Error processing action: %s', e, exc_info=True) + + # stop event is set: close browser LOGGER.debug("Closing browser") try: - if self.page.is_closed() is False: + if not self._is_page_closed(): self.page.close() if self.context: self.context.close() - except TargetClosedError as e: - # ok if closed already - pass - except Exception as e: - LOGGER.error('Error closing browser: %s', e ,exc_info=True) - self.init_vars() - return - - def _clear_action_queue(self): - """ Clear the action queue""" - while True: - try: - self._action_queue.get_nowait() - except queue.Empty: - break - - def stop(self, join_thread:bool=False): - """ Shutdown browser thread""" - if self.is_running(): - self._stop_event.set() - if join_thread: - self._browser_thread.join() - self._browser_thread = None - + except TargetClosedError as e: + # ok if closed already + pass + except Exception as e: + LOGGER.error('Error closing browser: %s', e ,exc_info=True) + self.init_vars() + return + + def _clear_action_queue(self): + """ Clear the action queue""" + while True: + try: + self._action_queue.get_nowait() + except queue.Empty: + break + + def stop(self, join_thread:bool=False): + """ Shutdown browser thread""" + if self.is_running(): + self._stop_event.set() + if join_thread: + self._browser_thread.join() + self._browser_thread = None + def is_running(self): """ return True if browser thread is still running""" if self._browser_thread and self._browser_thread.is_alive(): @@ -203,161 +470,267 @@ def is_running(self): else: return False - def is_page_normal(self): - """ return True if page is loaded """ - if self.page: - if self._page_title: - return True + def _is_page_closed(self) -> bool: + """Return True when page is unavailable or already closed.""" + if self.page is None: + return True + try: + return self.page.is_closed() + except Exception: # pylint: disable=broad-except + return True + + @staticmethod + def _is_transient_page_exception(error:Exception) -> bool: + """Return True for navigation-time JS context errors that should not stop browser loop.""" + msg = str(error).lower() + transient_markers = ( + "execution context was destroyed", + "cannot find context with specified id", + "most likely because of a navigation", + "navigated", + ) + return any(marker in msg for marker in transient_markers) + + @staticmethod + def _compute_runtime_game_rect(inner_width:float, inner_height:float) -> tuple[float, float, float, float]: + """Compute 16:9 game area fitted inside current browser inner viewport.""" + inner_width = max(1.0, float(inner_width)) + inner_height = max(1.0, float(inner_height)) + target_ratio = 16 / 9 + current_ratio = inner_width / inner_height + if current_ratio >= target_ratio: + game_height = inner_height + game_width = game_height * target_ratio + offset_x = (inner_width - game_width) / 2 + offset_y = 0.0 else: - return False + game_width = inner_width + game_height = game_width / target_ratio + offset_x = 0.0 + offset_y = (inner_height - game_height) / 2 + return (offset_x, offset_y, game_width, game_height) + + def _refresh_runtime_view_metrics(self): + """Sample runtime viewport metrics from page and update mapping/overlay states.""" + if self._is_page_closed(): + return + info = self.page.evaluate("""() => ({ + title: document.title || "", + zoomScale: (window.visualViewport && typeof window.visualViewport.scale === "number") + ? window.visualViewport.scale : 1, + innerWidth: window.innerWidth || document.documentElement.clientWidth || 0, + innerHeight: window.innerHeight || document.documentElement.clientHeight || 0 + })""") + title = str(info.get("title", "") or "") + zoom_scale = info.get("zoomScale", 1) + inner_w = float(info.get("innerWidth", self.width) or self.width) + inner_h = float(info.get("innerHeight", self.height) or self.height) + self._page_title = title + self.zoomlevel_check = float(zoom_scale) if isinstance(zoom_scale, (int, float)) else 1.0 + + prev_w = self.runtime_inner_width + prev_h = self.runtime_inner_height + prev_rect = self.runtime_game_rect + self.runtime_inner_width = max(1.0, inner_w) + self.runtime_inner_height = max(1.0, inner_h) + self.runtime_game_rect = self._compute_runtime_game_rect(self.runtime_inner_width, self.runtime_inner_height) + if ( + abs(prev_w - self.runtime_inner_width) > 1 + or abs(prev_h - self.runtime_inner_height) > 1 + or any(abs(a - b) > 1 for a, b in zip(prev_rect, self.runtime_game_rect)) + ): + LOGGER.debug( + "Runtime viewport updated: inner=%.0fx%.0f game_rect=(x=%.1f,y=%.1f,w=%.1f,h=%.1f)", + self.runtime_inner_width, + self.runtime_inner_height, + self.runtime_game_rect[0], + self.runtime_game_rect[1], + self.runtime_game_rect[2], + self.runtime_game_rect[3], + ) + + def _runtime_overlay_size(self) -> tuple[int, int]: + """Overlay canvas target size. Prefer runtime viewport when available.""" + width = int(round(self.runtime_inner_width)) if self.runtime_inner_width > 0 else int(self.width) + height = int(round(self.runtime_inner_height)) if self.runtime_inner_height > 0 else int(self.height) + return (max(1, width), max(1, height)) + + def _translate_runtime_viewport_point(self, x:float, y:float) -> tuple[float, float]: + """Map legacy fixed-viewport points into runtime fullscreen viewport.""" + if not self._fullscreen_runtime: + return (x, y) + base_w = max(1.0, float(self.width)) + base_h = max(1.0, float(self.height)) + nx = min(max(float(x) / base_w, 0.0), 1.0) + ny = min(max(float(y) / base_h, 0.0), 1.0) + offset_x, offset_y, game_w, game_h = self.runtime_game_rect + mapped_x = offset_x + nx * game_w + mapped_y = offset_y + ny * game_h + return (mapped_x, mapped_y) + def is_page_normal(self): + """ return True if page is loaded """ + return not self._is_page_closed() + def is_overlay_working(self): """ return True if overlay is on and working""" - if self.page is None: + if self._is_page_closed(): return False if self._canvas_id is None: return False return True - - def mouse_move(self, x:int, y:int, steps:int=5, blocking:bool=False): - """ Queue action: mouse move to (x,y) on viewport - if block, wait until action is done""" - finish_event = threading.Event() - self._action_queue.put(lambda: self._action_mouse_move(x, y, steps, finish_event)) - if blocking: - finish_event.wait() - - def mouse_click(self, delay:float=80, blocking:bool=False): - """ Queue action: mouse click at (x,y) on viewport - if block, wait until action is done""" - finish_event = threading.Event() - self._action_queue.put(lambda: self._action_mouse_click(delay, finish_event)) - if blocking: - finish_event.wait() - - def mouse_down(self, blocking:bool=False): - """ Queue action: mouse down on page""" - finish_event = threading.Event() - self._action_queue.put(lambda: self._action_mouse_down(finish_event)) - if blocking: - finish_event.wait() - - def mouse_up(self,blocking:bool=False): - """ Queue action: mouse up on page""" - finish_event = threading.Event() - self._action_queue.put(lambda: self._action_mouse_up(finish_event)) - if blocking: - finish_event.wait() - - def mouse_wheel(self, dx:float, dy:float, blocking:bool=False): - """ Queue action for mouse wheel""" - finish_event = threading.Event() - self._action_queue.put(lambda: self._action_mouse_wheel(dx, dy, finish_event)) - if blocking: - finish_event.wait() - - def auto_hu(self): - """ Queue action: Autohu action""" - self._action_queue.put(self._action_autohu) - + + def mouse_move(self, x:int, y:int, steps:int=5, blocking:bool=False): + """ Queue action: mouse move to (x,y) on viewport + if block, wait until action is done""" + finish_event = threading.Event() + self._action_queue.put(lambda: self._action_mouse_move(x, y, steps, finish_event)) + if blocking: + finish_event.wait() + + def mouse_click(self, delay:float=80, blocking:bool=False): + """ Queue action: mouse click at (x,y) on viewport + if block, wait until action is done""" + finish_event = threading.Event() + self._action_queue.put(lambda: self._action_mouse_click(delay, finish_event)) + if blocking: + finish_event.wait() + + def mouse_down(self, blocking:bool=False): + """ Queue action: mouse down on page""" + finish_event = threading.Event() + self._action_queue.put(lambda: self._action_mouse_down(finish_event)) + if blocking: + finish_event.wait() + + def mouse_up(self,blocking:bool=False): + """ Queue action: mouse up on page""" + finish_event = threading.Event() + self._action_queue.put(lambda: self._action_mouse_up(finish_event)) + if blocking: + finish_event.wait() + + def mouse_wheel(self, dx:float, dy:float, blocking:bool=False): + """ Queue action for mouse wheel""" + finish_event = threading.Event() + self._action_queue.put(lambda: self._action_mouse_wheel(dx, dy, finish_event)) + if blocking: + finish_event.wait() + + def auto_hu(self): + """ Queue action: Autohu action""" + self._action_queue.put(self._action_autohu) + def start_overlay(self): """ Queue action: Start showing the overlay""" + if self._overlay_start_pending: + return self._last_botleft_text = None self._last_guide = None + self._overlay_start_pending = True self._action_queue.put(self._action_start_overlay) - + def stop_overlay(self): """ Queue action: Stop showing the overlay""" + self._overlay_start_pending = False self._action_queue.put(self._action_stop_overlay) - - def overlay_update_guidance(self, guide_str:str, option_subtitle:str, options:list): - """ Queue action: update text area - params: - guide_str(str): AI guide str (recommendation action) - option_subtitle(str): subtitle for options (display before option list) - options(list): list of (str, float), indicating action/tile with its probability """ - if self._last_guide == (guide_str, option_subtitle, options): # skip if same guide - return - self._action_queue.put(lambda: self._action_overlay_update_guide(guide_str, option_subtitle, options)) - - def overlay_clear_guidance(self): - """ Queue action: clear overlay text area""" - if self._last_guide is None: # skip if already cleared - return - self._action_queue.put(self._action_overlay_clear_guide) - - def overlay_update_botleft(self, text:str): - """ update bot-left corner text area - params: - text(str): Text, can have linebreak '\n'. None to clear text - """ - if text == self._last_botleft_text: # skip if same text - return - self._action_queue.put(lambda: self._action_overlay_update_botleft(text)) - - - def screen_shot(self) -> bytes | None: - """ Take broswer page screenshot and return buff if success, or None if not""" - if not self.is_page_normal(): - return None - res_queue = queue.Queue() - try: - self._action_queue.put(lambda: self._action_screen_shot(res_queue)) - res:BytesIO = res_queue.get(True,5) - except queue.Empty: - return None - except Exception as e: - LOGGER.error("Error taking screenshot: %s", e, exc_info=True) - return None - - if res is None: - return None - else: - return res - + + def overlay_update_guidance(self, guide_str:str, option_subtitle:str, options:list): + """ Queue action: update text area + params: + guide_str(str): AI guide str (recommendation action) + option_subtitle(str): subtitle for options (display before option list) + options(list): list of (str, float), indicating action/tile with its probability """ + if self._last_guide == (guide_str, option_subtitle, options): # skip if same guide + return + self._action_queue.put(lambda: self._action_overlay_update_guide(guide_str, option_subtitle, options)) + + def overlay_clear_guidance(self): + """ Queue action: clear overlay text area""" + if self._last_guide is None: # skip if already cleared + return + self._action_queue.put(self._action_overlay_clear_guide) + + def overlay_update_botleft(self, text:str): + """ update bot-left corner text area + params: + text(str): Text, can have linebreak '\n'. None to clear text + """ + if text == self._last_botleft_text: # skip if same text + return + self._action_queue.put(lambda: self._action_overlay_update_botleft(text)) + + + def screen_shot(self) -> bytes | None: + """ Take broswer page screenshot and return buff if success, or None if not""" + if not self.is_page_normal(): + return None + res_queue = queue.Queue() + try: + self._action_queue.put(lambda: self._action_screen_shot(res_queue)) + res:BytesIO = res_queue.get(True,5) + except queue.Empty: + return None + except Exception as e: + LOGGER.error("Error taking screenshot: %s", e, exc_info=True) + return None + + if res is None: + return None + else: + return res + def _action_mouse_move(self, x:int, y:int, steps:int, finish_event:threading.Event): """ move mouse to (x,y) with steps, and set finish_event when done""" - self.page.mouse.move(x=x, y=y, steps=steps) - finish_event.set() - - def _action_mouse_click(self, delay:float, finish_event:threading.Event): - """ mouse click on page at (x,y)""" - # self.page.mouse.click(x=x, y=y, delay=delay) - self.page.mouse.down() - time.sleep(delay/1000) - self.page.mouse.up() - finish_event.set() - - def _action_mouse_down(self, finish_event:threading.Event): - """ mouse down on page""" - self.page.mouse.down() - finish_event.set() - - def _action_mouse_up(self, finish_event:threading.Event): - """ mouse up on page""" - self.page.mouse.up() + target_x, target_y = self._translate_runtime_viewport_point(x, y) + self.page.mouse.move(x=target_x, y=target_y, steps=steps) finish_event.set() - - def _action_mouse_wheel(self, dx:float, dy:float, finish_event:threading.Event): - self.page.mouse.wheel(dx, dy) - finish_event.set() - - def _action_autohu(self): - """ call autohu function in page""" - self.page.evaluate("() => view.DesktopMgr.Inst.setAutoHule(true)") - + + def _action_mouse_click(self, delay:float, finish_event:threading.Event): + """ mouse click on page at (x,y)""" + # self.page.mouse.click(x=x, y=y, delay=delay) + self.page.mouse.down() + time.sleep(delay/1000) + self.page.mouse.up() + finish_event.set() + + def _action_mouse_down(self, finish_event:threading.Event): + """ mouse down on page""" + self.page.mouse.down() + finish_event.set() + + def _action_mouse_up(self, finish_event:threading.Event): + """ mouse up on page""" + self.page.mouse.up() + finish_event.set() + + def _action_mouse_wheel(self, dx:float, dy:float, finish_event:threading.Event): + self.page.mouse.wheel(dx, dy) + finish_event.set() + + def _action_autohu(self): + """ call autohu function in page""" + self.page.evaluate("() => view.DesktopMgr.Inst.setAutoHule(true)") + def _action_start_overlay(self): """ Display overlay on page. Will ignore if already exist, or page is None""" if self.is_overlay_working(): # skip if overlay already working + self._overlay_start_pending = False return + canvas_w, canvas_h = self._runtime_overlay_size() self._canvas_id = utils.random_str(8) # random 8-byte alpha-numeric string js_code = f"""(() => {{ // Create a canvas element and add it to the document body const canvas = document.createElement('canvas'); canvas.id = '{self._canvas_id}'; - canvas.width = {self.width}; // Width of the canvas - canvas.height = {self.height}; // Height of the canvas + const syncSize = () => {{ + canvas.width = window.innerWidth || {canvas_w}; + canvas.height = window.innerHeight || {canvas_h}; + }}; + syncSize(); + canvas.__syncSize = syncSize; + window.addEventListener('resize', syncSize); // Set styles to ensure the canvas is on top canvas.style.position = 'fixed'; // Use 'fixed' or 'absolute' positioning @@ -368,181 +741,615 @@ def _action_start_overlay(self): document.body.appendChild(canvas); }})()""" self.page.evaluate(js_code) - + self._overlay_start_pending = False + def _action_stop_overlay(self): """ Remove overlay from page""" + self._overlay_start_pending = False if self.is_overlay_working() is False: return js_code = f"""(() => {{ const canvas = document.getElementById('{self._canvas_id}'); if (canvas) {{ + if (canvas.__syncSize) {{ + window.removeEventListener('resize', canvas.__syncSize); + }} canvas.remove(); }} }})()""" self.page.evaluate(js_code) self._canvas_id = None - self._botleft_text = None + self._last_botleft_text = None self._last_guide = None def _overlay_text_params(self): - font_size = int(self.height/45) # e.g., 22 - line_space = int(self.height/45/2) + canvas_w, canvas_h = self._runtime_overlay_size() + font_size = max(10, int(canvas_h/45)) # e.g., 22 + line_space = max(4, int(canvas_h/45/2)) min_box_width = font_size * 15 # Minimum box width initial_box_height = line_space * 2 + (font_size + line_space) * 6 # based on number of lines - box_top = int(self.height * 0.44) # Distance from the top - box_left = int(self.width * 0.14) # Distance from the left + box_top = int(canvas_h * 0.44) # Distance from the top + box_left = int(canvas_w * 0.14) # Distance from the left return (font_size, line_space, min_box_width, initial_box_height, box_top, box_left) - - def _action_overlay_update_guide(self, line1: str, option_title: str, options: list[tuple[str, float]]): - if not self.is_overlay_working(): + + def _action_overlay_update_guide(self, line1: str, option_title: str, options: list[tuple[str, float]]): + if not self.is_overlay_working(): + return + + font_size, line_space, min_box_width, initial_box_height, box_top, box_left = self._overlay_text_params() + if options: + options_data = [[text, f"{perc*100:4.0f}%"] for text, perc in options] + else: + options_data = [] + + js_code = f""" + (() => {{ + const canvas = document.getElementById('{self._canvas_id}'); + if (!canvas || !canvas.getContext) {{ + return; + }} + const ctx = canvas.getContext('2d'); + + // Measure the first line of text to determine box width + ctx.font = "{font_size * 2}px Arial"; + const firstLineMetrics = ctx.measureText("{line1}"); + let box_width = Math.max(firstLineMetrics.width + {font_size}*2, {min_box_width}); // set minimal width + let box_height = {initial_box_height}; // Pre-defined box height based on number of lines + + // Clear the drawing area + ctx.clearRect({box_left}, {box_top}, canvas.width-{box_left}, {initial_box_height}); + // Draw the semi-transparent background box + ctx.clearRect({box_left}, {box_top}, box_width, box_height); + ctx.fillStyle = "rgba(0, 0, 0, 0.5)"; + ctx.fillRect({box_left}, {box_top}, box_width, box_height); + + // Reset font to draw the first line + ctx.fillStyle = "#FFFFFF"; + ctx.textBaseline = "top"; + ctx.fillText("{line1}", {box_left} + {font_size}, {box_top} + {line_space} * 2); + + // Adjust y-position for the subtitle and option lines + let yPos = {box_top} + {font_size * 2} + {line_space} * 4; // Position after the first line + ctx.font = "{font_size}px Arial"; // Font size for options subtitle and lines + + // Draw options subtitle + ctx.fillText("{option_title}", {box_left} + {font_size}*2, yPos); + yPos += {font_size} + {line_space}; // Adjust yPos for option lines + + // Draw each option line + const options = {options_data}; + options.forEach(option => {{ + const [text, perc] = option; + ctx.fillText(text, {box_left} + {font_size}*2, yPos); // Draw option text + // Calculate right-aligned percentage position and draw + const percWidth = ctx.measureText(perc).width; + ctx.fillText(perc, {box_left} + {font_size}*11, yPos); + yPos += {font_size} + {line_space}; // Adjust yPos for the next line + }}); + }})();""" + self.page.evaluate(js_code) + self._last_guide = (line1, option_title, options) + + def _action_overlay_clear_guide(self): + """ delete text and the background box""" + if self.is_overlay_working() is False: + return + font_size, line_space, min_box_width, initial_box_height, box_top, box_left = self._overlay_text_params() + + js_code = f"""(() => {{ + const canvas = document.getElementById('{self._canvas_id}'); + if (!canvas || !canvas.getContext) {{ + return; + }} + const ctx = canvas.getContext('2d'); + + // Clear the drawing area + ctx.clearRect({box_left}, {box_top}, canvas.width-{box_left}, {initial_box_height}); + }});""" + self.page.evaluate(js_code) + self._last_guide = None + + def _action_overlay_update_botleft(self, text:str=None): + if self.is_overlay_working() is False: return - font_size, line_space, min_box_width, initial_box_height, box_top, box_left = self._overlay_text_params() - if options: - options_data = [[text, f"{perc*100:4.0f}%"] for text, perc in options] - else: - options_data = [] + _canvas_w, canvas_h = self._runtime_overlay_size() + font_size = max(10, int(canvas_h/48)) + box_top = 0.885 + box_left = 0 + box_width = 0.115 + box_height = 1- box_top + + # Escape JavaScript special characters and convert newlines + js_text = text.replace('\\', '\\\\').replace('"', '\\"').replace('\n', '\\n') if text else '' + + js_code = f"""(() => {{ + // find canvas context + const canvas = document.getElementById('{self._canvas_id}'); + if (!canvas || !canvas.getContext) {{ + return; + }} + const ctx = canvas.getContext('2d'); + + // clear box + const box_left = canvas.width * {box_left}; + const box_top = canvas.height * {box_top}; + const box_width = canvas.width * {box_width}; + const box_height = canvas.height * {box_height}; + ctx.clearRect(box_left, box_top, box_width, box_height); + + // transparent box background + ctx.fillStyle = "rgba(0, 0, 0, 0.2)"; + ctx.fillRect(box_left, box_top, box_width, box_height); + + // draw text + const text = "{js_text}" + if (!text) {{ + return; // Skip drawing if text is empty + }} + + ctx.fillStyle = "#FFFFFF"; + ctx.textBaseline = "top" + ctx.font = "{font_size}px Arial"; + + // Split text into lines and draw each line + const lines = text.split('\\n'); + const textX = {font_size} * 0.25 + let startY = canvas.height * {box_top} + {font_size}*0.5; + const lineHeight = {font_size} * 1.2; // Adjust line height as needed + lines.forEach((line, index) => {{ + ctx.fillText(line, canvas.width * {box_left} + textX, startY + (lineHeight * index)); + }}); + }})()""" + self.page.evaluate(js_code) + self._last_botleft_text = text + + def _overlay_update_indicators(self, bars:list): + """ Update the indicators on overlay """ + # TODO + for x,y,height in bars: + pass + + + def _read_window_geometry(self) -> dict | None: + """Read window geometry from browser JS context.""" + if not self.is_page_normal(): + return None + result = {} + finish_event = threading.Event() + def action(): + try: + info = self.page.evaluate("""() => { + return { + screenX: window.screenX, + screenY: window.screenY, + outerWidth: window.outerWidth, + outerHeight: window.outerHeight, + innerWidth: window.innerWidth, + innerHeight: window.innerHeight, + dpr: window.devicePixelRatio || 1 + }; + }""") + result['info'] = info + except Exception as e: + LOGGER.error("Error getting viewport origin: %s", e) + finish_event.set() + self._action_queue.put(action) + finish_event.wait(timeout=3) + return result.get('info') + + def _get_hwnd_class_name(self, hwnd:int) -> str: + """Get Win32 class name for a window handle.""" + if os.name != "nt" or not hwnd: + return "" + user32 = ctypes.windll.user32 + if not user32.IsWindow(hwnd): + return "" + cls_buf = ctypes.create_unicode_buffer(64) + user32.GetClassNameW(hwnd, cls_buf, 64) + return cls_buf.value + + def _is_chrome_hwnd(self, hwnd:int) -> bool: + """Return true for Chromium top-level windows.""" + cls_name = self._get_hwnd_class_name(hwnd) + return cls_name.startswith("Chrome_WidgetWin") + + def _get_hwnd_metrics(self, hwnd:int) -> dict | None: + """Get window/client geometry in screen pixels for given hwnd.""" + if os.name != "nt" or not hwnd: + return None - js_code = f""" - (() => {{ - const canvas = document.getElementById('{self._canvas_id}'); - if (!canvas || !canvas.getContext) {{ - return; - }} - const ctx = canvas.getContext('2d'); + class RECT(ctypes.Structure): + _fields_ = [("left", ctypes.c_long), ("top", ctypes.c_long), + ("right", ctypes.c_long), ("bottom", ctypes.c_long)] - // Measure the first line of text to determine box width - ctx.font = "{font_size * 2}px Arial"; - const firstLineMetrics = ctx.measureText("{line1}"); - let box_width = Math.max(firstLineMetrics.width + {font_size}*2, {min_box_width}); // set minimal width - let box_height = {initial_box_height}; // Pre-defined box height based on number of lines - - // Clear the drawing area - ctx.clearRect({box_left}, {box_top}, {self.width}-{box_left}, {initial_box_height}); - // Draw the semi-transparent background box - ctx.clearRect({box_left}, {box_top}, box_width, box_height); - ctx.fillStyle = "rgba(0, 0, 0, 0.5)"; - ctx.fillRect({box_left}, {box_top}, box_width, box_height); - - // Reset font to draw the first line - ctx.fillStyle = "#FFFFFF"; - ctx.textBaseline = "top"; - ctx.fillText("{line1}", {box_left} + {font_size}, {box_top} + {line_space} * 2); - - // Adjust y-position for the subtitle and option lines - let yPos = {box_top} + {font_size * 2} + {line_space} * 4; // Position after the first line - ctx.font = "{font_size}px Arial"; // Font size for options subtitle and lines - - // Draw options subtitle - ctx.fillText("{option_title}", {box_left} + {font_size}*2, yPos); - yPos += {font_size} + {line_space}; // Adjust yPos for option lines - - // Draw each option line - const options = {options_data}; - options.forEach(option => {{ - const [text, perc] = option; - ctx.fillText(text, {box_left} + {font_size}*2, yPos); // Draw option text - // Calculate right-aligned percentage position and draw - const percWidth = ctx.measureText(perc).width; - ctx.fillText(perc, {box_left} + {font_size}*11, yPos); - yPos += {font_size} + {line_space}; // Adjust yPos for the next line - }}); - }})();""" - self.page.evaluate(js_code) - self._last_guide = (line1, option_title, options) + class POINT(ctypes.Structure): + _fields_ = [("x", ctypes.c_long), ("y", ctypes.c_long)] - def _action_overlay_clear_guide(self): - """ delete text and the background box""" - if self.is_overlay_working() is False: - return - font_size, line_space, min_box_width, initial_box_height, box_top, box_left = self._overlay_text_params() + user32 = ctypes.windll.user32 + if not user32.IsWindow(hwnd): + return None + if not user32.IsWindowVisible(hwnd): + return None - js_code = f"""(() => {{ - const canvas = document.getElementById('{self._canvas_id}'); - if (!canvas || !canvas.getContext) {{ - return; - }} - const ctx = canvas.getContext('2d'); + win_rect = RECT() + if not user32.GetWindowRect(hwnd, ctypes.byref(win_rect)): + return None - // Clear the drawing area - ctx.clearRect({box_left}, {box_top}, {self.width}-{box_left}, {initial_box_height}); - }});""" - self.page.evaluate(js_code) - self._last_guide = None + client_rect = RECT() + if not user32.GetClientRect(hwnd, ctypes.byref(client_rect)): + return None + client_w = client_rect.right - client_rect.left + client_h = client_rect.bottom - client_rect.top + if client_w <= 0 or client_h <= 0: + return None - def _action_overlay_update_botleft(self, text:str=None): - if self.is_overlay_working() is False: - return + client_origin = POINT(0, 0) + if not user32.ClientToScreen(hwnd, ctypes.byref(client_origin)): + return None - font_size = int(self.height/48) - box_top = 0.885 - box_left = 0 - box_width = 0.115 - box_height = 1- box_top + return { + "win_left": float(win_rect.left), + "win_top": float(win_rect.top), + "win_w": float(win_rect.right - win_rect.left), + "win_h": float(win_rect.bottom - win_rect.top), + "client_left": float(client_origin.x), + "client_top": float(client_origin.y), + "client_w": float(client_w), + "client_h": float(client_h), + } + + def _hwnd_match_score(self, hwnd:int, js_info:dict) -> float | None: + """Score how well hwnd matches current page window geometry.""" + metrics = self._get_hwnd_metrics(hwnd) + if metrics is None: + return None + try: + screen_x = float(js_info.get("screenX", 0) or 0) + screen_y = float(js_info.get("screenY", 0) or 0) + outer_w = float(js_info.get("outerWidth", 0) or 0) + outer_h = float(js_info.get("outerHeight", 0) or 0) + inner_w = float(js_info.get("innerWidth", 0) or 0) + inner_h = float(js_info.get("innerHeight", 0) or 0) + dpr = float(js_info.get("dpr", 1.0) or 1.0) + except Exception: + return None + if outer_w <= 0 or outer_h <= 0 or inner_w <= 0 or inner_h <= 0: + return None - # Escape JavaScript special characters and convert newlines - js_text = text.replace('\\', '\\\\').replace('"', '\\"').replace('\n', '\\n') if text else '' + scales = [1.0] + if abs(dpr - 1.0) > 0.01: + scales.append(dpr) + # Dynamic scale inferred from actual window size helps cross-DPI matching. + scales.append(max(0.5, min(4.0, metrics["win_w"] / outer_w))) + scales.append(max(0.5, min(4.0, metrics["client_w"] / inner_w))) + + best = None + for scale in scales: + score = ( + abs(metrics["win_left"] - screen_x * scale) + + abs(metrics["win_top"] - screen_y * scale) + + abs(metrics["win_w"] - outer_w * scale) + + abs(metrics["win_h"] - outer_h * scale) + + abs(metrics["client_w"] - inner_w * scale) + + abs(metrics["client_h"] - inner_h * scale) + ) + if best is None or score < best: + best = score + return best + + def _get_foreground_chrome_hwnd(self, js_info:dict) -> int | None: + """Use current foreground window when it is clearly the game browser.""" + if os.name != "nt": + return None + user32 = ctypes.windll.user32 + hwnd = int(user32.GetForegroundWindow() or 0) + if not hwnd: + return None + GA_ROOT = 2 + hwnd = int(user32.GetAncestor(hwnd, GA_ROOT) or hwnd) + if not self._is_chrome_hwnd(hwnd): + return None + score = self._hwnd_match_score(hwnd, js_info) + if score is None: + return None + return hwnd if score < 1800 else None - js_code = f"""(() => {{ - // find canvas context - const canvas = document.getElementById('{self._canvas_id}'); - if (!canvas || !canvas.getContext) {{ - return; - }} - const ctx = canvas.getContext('2d'); - - // clear box - const box_left = canvas.width * {box_left}; - const box_top = canvas.height * {box_top}; - const box_width = canvas.width * {box_width}; - const box_height = canvas.height * {box_height}; - ctx.clearRect(box_left, box_top, box_width, box_height); - - // transparent box background - ctx.fillStyle = "rgba(0, 0, 0, 0.2)"; - ctx.fillRect(box_left, box_top, box_width, box_height); - - // draw text - const text = "{js_text}" - if (!text) {{ - return; // Skip drawing if text is empty - }} - - ctx.fillStyle = "#FFFFFF"; - ctx.textBaseline = "top" - ctx.font = "{font_size}px Arial"; - - // Split text into lines and draw each line - const lines = text.split('\\n'); - const textX = {font_size} * 0.25 - let startY = canvas.height * {box_top} + {font_size}*0.5; - const lineHeight = {font_size} * 1.2; // Adjust line height as needed - lines.forEach((line, index) => {{ - ctx.fillText(line, canvas.width * {box_left} + textX, startY + (lineHeight * index)); - }}); - }})()""" - self.page.evaluate(js_code) - self._last_botleft_text = text + def _find_hwnd_by_probe_point(self, js_info:dict) -> int | None: + """Probe a few points derived from JS geometry and resolve their root hwnd.""" + if os.name != "nt": + return None + + class POINT(ctypes.Structure): + _fields_ = [("x", ctypes.c_long), ("y", ctypes.c_long)] + + try: + screen_x = float(js_info.get("screenX", 0) or 0) + screen_y = float(js_info.get("screenY", 0) or 0) + outer_w = float(js_info.get("outerWidth", 0) or 0) + outer_h = float(js_info.get("outerHeight", 0) or 0) + inner_w = float(js_info.get("innerWidth", 0) or 0) + inner_h = float(js_info.get("innerHeight", 0) or 0) + dpr = float(js_info.get("dpr", 1.0) or 1.0) + except Exception: + return None + if outer_w <= 0 or outer_h <= 0 or inner_w <= 0 or inner_h <= 0: + return None - def _overlay_update_indicators(self, bars:list): - """ Update the indicators on overlay """ - # TODO - for x,y,height in bars: - pass + border_x = (outer_w - inner_w) / 2 + title_bar_y = outer_h - inner_h - border_x + origin_x_css = screen_x + border_x + origin_y_css = screen_y + title_bar_y + + probe_css_points = [ + (origin_x_css + 20, origin_y_css + 20), + (screen_x + outer_w * 0.5, screen_y + min(outer_h * 0.5, 120)), + ] + scales = [1.0] + if abs(dpr - 1.0) > 0.01: + scales.append(dpr) + + user32 = ctypes.windll.user32 + GA_ROOT = 2 + best_hwnd = None + best_score = None + for px_css, py_css in probe_css_points: + for scale in scales: + point = POINT(round(px_css * scale), round(py_css * scale)) + hwnd = int(user32.WindowFromPoint(point) or 0) + if not hwnd: + continue + hwnd = int(user32.GetAncestor(hwnd, GA_ROOT) or hwnd) + if not self._is_chrome_hwnd(hwnd): + continue + score = self._hwnd_match_score(hwnd, js_info) + if score is None: + continue + if best_score is None or score < best_score: + best_score = score + best_hwnd = hwnd + + if best_hwnd is not None and best_score is not None and best_score < 2200: + return best_hwnd + return None + + def _find_matching_chrome_hwnd(self, js_info:dict) -> int | None: + """Find best matching Chromium hwnd by global enumeration.""" + if os.name != "nt": + return None + user32 = ctypes.windll.user32 + hwnd_scores:list[tuple[float, int]] = [] + enum_proc = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_void_p, ctypes.c_void_p) + + def _enum_cb(hwnd, _lparam): + hwnd = int(hwnd) + if not self._is_chrome_hwnd(hwnd): + return True + score = self._hwnd_match_score(hwnd, js_info) + if score is not None: + hwnd_scores.append((float(score), hwnd)) + return True + + cb = enum_proc(_enum_cb) + user32.EnumWindows(cb, 0) + if not hwnd_scores: + return None + hwnd_scores.sort(key=lambda item: item[0]) + return hwnd_scores[0][1] + + def _resolve_browser_hwnd(self, js_info:dict) -> int | None: + """Resolve browser hwnd using cache, foreground, probe and enumeration.""" + if os.name != "nt": + return None + + if self._real_mouse_hwnd: + score = self._hwnd_match_score(self._real_mouse_hwnd, js_info) + if score is not None and score < 2200: + return self._real_mouse_hwnd + + for resolver in ( + self._get_foreground_chrome_hwnd, + self._find_hwnd_by_probe_point, + self._find_matching_chrome_hwnd, + ): + hwnd = resolver(js_info) + if hwnd: + self._real_mouse_hwnd = hwnd + return hwnd + return None + + def _get_client_origin_and_scale(self, hwnd:int, js_info:dict) -> tuple[float, float, float, float] | None: + """Get client-area screen origin and viewport scale from HWND.""" + if os.name != "nt": + return None + class RECT(ctypes.Structure): + _fields_ = [("left", ctypes.c_long), ("top", ctypes.c_long), + ("right", ctypes.c_long), ("bottom", ctypes.c_long)] + class POINT(ctypes.Structure): + _fields_ = [("x", ctypes.c_long), ("y", ctypes.c_long)] + + user32 = ctypes.windll.user32 + h = ctypes.c_void_p(hwnd) + + rect = RECT() + if not user32.GetClientRect(h, ctypes.byref(rect)): + return None + client_w = rect.right - rect.left + client_h = rect.bottom - rect.top + if client_w <= 0 or client_h <= 0: + return None + + pt = POINT(0, 0) + if not user32.ClientToScreen(h, ctypes.byref(pt)): + return None + + inner_w = float(js_info.get('innerWidth', 0) or 0) + inner_h = float(js_info.get('innerHeight', 0) or 0) + dpr = float(js_info.get('dpr', 1.0) or 1.0) + scale_x = (client_w / inner_w) if inner_w > 0 else dpr + scale_y = (client_h / inner_h) if inner_h > 0 else dpr + return (float(pt.x), float(pt.y), float(scale_x), float(scale_y)) + + def _get_viewport_geometry(self) -> tuple[float, float, float, float] | None: + """Get viewport mapping as (origin_x, origin_y, scale_x, scale_y) in screen coords.""" + info = self._read_window_geometry() + if info is None: + return None + + hwnd = self._resolve_browser_hwnd(info) + if hwnd is not None: + mapped = self._get_client_origin_and_scale(hwnd, info) + if mapped is not None: + return mapped + + # Fallback: estimate via JS outer/inner metrics. + border_x = (info['outerWidth'] - info['innerWidth']) / 2 + title_bar_y = info['outerHeight'] - info['innerHeight'] - border_x + origin_x_css = info['screenX'] + border_x + origin_y_css = info['screenY'] + title_bar_y + dpr = float(info.get('dpr', 1.0) or 1.0) + return (float(origin_x_css * dpr), float(origin_y_css * dpr), dpr, dpr) + + def get_viewport_screen_origin(self) -> tuple[int, int]: + """ Get browser viewport top-left in screen pixels (for real mouse).""" + geometry = self._get_viewport_geometry() + if geometry is None: + return (0, 0) + origin_x, origin_y, _scale_x, _scale_y = geometry + return (round(origin_x), round(origin_y)) + + def viewport_to_screen(self, viewport_x:float, viewport_y:float) -> tuple[int, int]: + """ Convert viewport CSS coordinates to screen coordinates for SetCursorPos.""" + geometry = self._get_viewport_geometry() + if geometry is None: + return (int(viewport_x), int(viewport_y)) + origin_x, origin_y, scale_x, scale_y = geometry + screen_x = round(origin_x + viewport_x * scale_x) + self.real_mouse_offset_x + screen_y = round(origin_y + viewport_y * scale_y) + self.real_mouse_offset_y + return (screen_x, screen_y) + + def _get_cursor_pos(self) -> tuple[int, int]: + """Get current real cursor position in screen coordinates.""" + class POINT(ctypes.Structure): + _fields_ = [("x", ctypes.c_long), ("y", ctypes.c_long)] + pt = POINT() + ctypes.windll.user32.GetCursorPos(ctypes.byref(pt)) + return int(pt.x), int(pt.y) + + def real_mouse_move(self, screen_x:int, screen_y:int, + speed_pps:float|None=None, jitter_px:float|None=None): + """Move real mouse with smooth human-like path instead of teleporting.""" + target_x = int(screen_x) + target_y = int(screen_y) + user32 = ctypes.windll.user32 + start_x, start_y = self._get_cursor_pos() + dx = target_x - start_x + dy = target_y - start_y + dist = math.hypot(dx, dy) + speed_pps = max(300.0, float(speed_pps if speed_pps is not None else self.real_mouse_speed_pps)) + jitter_px = max(0.0, float(jitter_px if jitter_px is not None else self.real_mouse_jitter_px)) + if dist < 1.0: + user32.SetCursorPos(target_x, target_y) + return + + # Duration from travel speed; clamp to keep movement visible but responsive. + move_sec = dist / speed_pps + min_sec = self.real_mouse_min_move_ms / 1000.0 + max_sec = self.real_mouse_max_move_ms / 1000.0 + move_sec = max(min_sec, min(max_sec, move_sec + random.uniform(0.00, 0.03))) + + # Use a curved path + small fading jitter to avoid a dead-straight trajectory. + nx = dx / dist + ny = dy / dist + px = -ny + py = nx + bend = min(22.0, max(4.0, dist * 0.06)) + bend *= random.choice((-1.0, 1.0)) + mid_t = random.uniform(0.35, 0.65) + cx = start_x + dx * mid_t + px * bend + cy = start_y + dy * mid_t + py * bend + + steps = int(max(14, min(80, move_sec / 0.008))) + start_ts = time.perf_counter() + for i in range(1, steps + 1): + t = i / steps + # cubic ease-in-out + eased = 3 * t * t - 2 * t * t * t + omt = 1.0 - eased + bx = (omt * omt * start_x) + (2 * omt * eased * cx) + (eased * eased * target_x) + by = (omt * omt * start_y) + (2 * omt * eased * cy) + (eased * eased * target_y) + + if i < steps: + jitter = jitter_px * (1.0 - eased) + bx += random.uniform(-jitter, jitter) + by += random.uniform(-jitter, jitter) + + user32.SetCursorPos(int(round(bx)), int(round(by))) + expected = start_ts + move_sec * t + sleep_sec = expected - time.perf_counter() + if sleep_sec > 0: + time.sleep(sleep_sec) + + user32.SetCursorPos(target_x, target_y) + + def _random_offset_point(self, radius_px:float) -> tuple[int, int]: + """Sample a random point within a circle around current cursor.""" + base_x, base_y = self._get_cursor_pos() + if radius_px <= 0: + return (base_x, base_y) + theta = random.uniform(0.0, math.tau) + # sqrt keeps points uniformly distributed in circle area + r = math.sqrt(random.random()) * radius_px + return (int(round(base_x + math.cos(theta) * r)), + int(round(base_y + math.sin(theta) * r))) + + def real_mouse_click(self, delay:float=0.08, + click_offset_px:float|None=None, speed_pps:float|None=None, jitter_px:float|None=None): + """ Simulate a real mouse click (left button down + delay + up) + params: + delay: delay in seconds between down and up""" + offset = self.real_mouse_click_offset_px if click_offset_px is None else click_offset_px + offset = max(0.0, float(offset)) + if offset > 0: + ox, oy = self._random_offset_point(offset) + self.real_mouse_move(ox, oy, speed_pps=speed_pps, jitter_px=jitter_px) + self.real_mouse_down() + time.sleep(delay) + self.real_mouse_up() + + def real_mouse_down(self): + """ Real mouse left button down""" + MOUSEEVENTF_LEFTDOWN = 0x0002 + ctypes.windll.user32.mouse_event(MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0) + + def real_mouse_up(self): + """ Real mouse left button up""" + MOUSEEVENTF_LEFTUP = 0x0004 + ctypes.windll.user32.mouse_event(MOUSEEVENTF_LEFTUP, 0, 0, 0, 0) + + def real_mouse_wheel(self, dx:float, dy:float): + """ Real mouse wheel scroll + params: + dx: horizontal scroll (not used on Windows) + dy: vertical scroll amount""" + MOUSEEVENTF_WHEEL = 0x0800 + ctypes.windll.user32.mouse_event(MOUSEEVENTF_WHEEL, 0, 0, int(dy), 0) + def _action_screen_shot(self, res_queue:queue.Queue, time_ms:int=5000): """ take screen shot from browser page Params: res_queue: queue for saving the image buff data""" if self.is_page_normal(): try: - ss_bytes:BytesIO = self.page.screenshot(timeout=time_ms) + # Use CSS-pixel screenshot to avoid Retina/device-pixel blowup in fullscreen. + ss_bytes:BytesIO = self.page.screenshot(timeout=time_ms, 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") + res_queue.put(None) + LOGGER.debug("Page not loaded, no screenshot") diff --git a/gui/help_window.py b/gui/help_window.py index 716416b..7be0b10 100644 --- a/gui/help_window.py +++ b/gui/help_window.py @@ -1,79 +1,89 @@ """ Help Window for tkinter GUI""" +import sys from typing import Callable import tkinter as tk from tkinter import ttk, messagebox from tkhtmlview import HTMLScrolledText - -from common.log_helper import LOGGER -from common.settings import Settings + +from common.log_helper import LOGGER +from common.settings import Settings from updater import Updater, UpdateStatus from .utils import GUI_STYLE +MAC_APP_NAME = "mortal_mac_v0.2" +MAC_MANUAL_UPDATE_HINT = "发现新版本,请下载最新 DMG 手动更新。" + class HelpWindow(tk.Toplevel): - """ dialog window for help information and update """ - def __init__(self, parent:tk.Frame, st:Settings, updater:Updater): - super().__init__(parent) + """ dialog window for help information and update """ + def __init__(self, parent:tk.Frame, st:Settings, updater:Updater): + super().__init__(parent) self.st = st # Settings object self.updater = updater - - title_str = f"{st.lan().HELP} {st.lan().APP_TITLE} v{self.updater.local_version}" - self.title(title_str) - parent_x = parent.winfo_x() - parent_y = parent.winfo_y() - self.geometry(f'+{parent_x+10}+{parent_y+10}') - self.win_size = (750, 700) - self.geometry(f"{self.win_size[0]}x{self.win_size[1]}") # Set the window size - # self.resizable(False, False) - - self.html_text:str = None - self.html_box = HTMLScrolledText( - self, html=st.lan().HELP+st.lan().LOADING, - wrap=tk.CHAR, font=GUI_STYLE.font_normal(), height=25, - state=tk.DISABLED) - self.html_box.pack(padx=10, pady=10, side=tk.TOP, fill=tk.BOTH, expand=True) - self.frame_bot = tk.Frame(self, height=30) - self.frame_bot.pack(expand=True, fill=tk.X, padx=10, pady=10) - col_widths = [int(w*self.win_size[0]) for w in (0.1, 0.4, 0.1)] - for idx, width in enumerate(col_widths): - self.frame_bot.grid_columnconfigure(idx, minsize=width, weight=1) - - # Updater button - self.update_button = ttk.Button(self.frame_bot, text=st.lan().CHECK_FOR_UPDATE, state=tk.DISABLED, width=16) - self.update_button.grid(row=0, column=0, sticky=tk.NSEW, padx=10, pady=10) - # label - self.update_str_var = tk.StringVar(value="") - self.update_label = ttk.Label(self.frame_bot, textvariable=self.update_str_var) - self.update_label.grid(row=0, column=1, sticky=tk.NSEW, padx=10, pady=10) - self.update_cmd:Callable = lambda: None - # OK Button - self.ok_button = ttk.Button(self.frame_bot, text="OK", command=self._on_close, width=8) - self.ok_button.grid(row=0, column=2, sticky=tk.NSEW, padx=10, pady=10) - - self.after_idle(self._refresh_ui) - - - def _check_for_update(self): - LOGGER.info("Checking for update.") - self.update_button.configure(state=tk.DISABLED) - self.updater.check_update() - - + app_name = MAC_APP_NAME if sys.platform == "darwin" else st.lan().APP_TITLE + title_str = f"{st.lan().HELP} {app_name} v{self.updater.local_version}" + self.title(title_str) + self.configure(bg=GUI_STYLE.bg_color) + parent_x = parent.winfo_x() + parent_y = parent.winfo_y() + self.geometry(f'+{parent_x+10}+{parent_y+10}') + self.win_size = (750, 700) + self.geometry(f"{self.win_size[0]}x{self.win_size[1]}") + + self.html_text:str = None + self.html_box = HTMLScrolledText( + self, html=st.lan().HELP+st.lan().LOADING, + wrap=tk.CHAR, font=GUI_STYLE.font_normal(), height=25, + state=tk.DISABLED, + background=GUI_STYLE.card_bg) + self.html_box.pack(padx=10, pady=10, side=tk.TOP, fill=tk.BOTH, expand=True) + + self.frame_bot = tk.Frame(self, height=30, bg=GUI_STYLE.bg_color) + self.frame_bot.pack(expand=True, fill=tk.X, padx=10, pady=10) + col_widths = [int(w*self.win_size[0]) for w in (0.1, 0.4, 0.1)] + for idx, width in enumerate(col_widths): + self.frame_bot.grid_columnconfigure(idx, minsize=width, weight=1) + + # Updater button + self.update_button = ttk.Button(self.frame_bot, text=st.lan().CHECK_FOR_UPDATE, state=tk.DISABLED, width=16) + self.update_button.grid(row=0, column=0, sticky=tk.NSEW, padx=10, pady=10) + # label + self.update_str_var = tk.StringVar(value="") + self.update_label = ttk.Label(self.frame_bot, textvariable=self.update_str_var) + self.update_label.grid(row=0, column=1, sticky=tk.NSEW, padx=10, pady=10) + self.update_cmd:Callable = lambda: None + # OK Button + self.ok_button = ttk.Button(self.frame_bot, text="OK", command=self._on_close, width=8) + self.ok_button.grid(row=0, column=2, sticky=tk.NSEW, padx=10, pady=10) + + self.after_idle(self._refresh_ui) + + + def _check_for_update(self): + LOGGER.info("Checking for update.") + self.update_button.configure(state=tk.DISABLED) + self.updater.check_update() + + def _download_update(self): + if sys.platform == "darwin": + return LOGGER.info("Download and unzip update.") self.update_button.configure(state=tk.DISABLED) self.updater.prepare_update() def _start_update(self): + if sys.platform == "darwin": + return LOGGER.info("Starting update process. will kill program and restart.") self.update_button.configure(state=tk.DISABLED) if messagebox.askokcancel(self.st.lan().START_UPDATE, self.st.lan().UPDATE_PREPARED): self.updater.start_update() - - + + def _refresh_ui(self): lan = self.st.lan() # Update html text if available @@ -81,50 +91,85 @@ def _refresh_ui(self): if self.updater.help_html: self.html_text = self.updater.help_html self.html_box.set_html(self.html_text) + + if sys.platform == "darwin": + match self.updater.update_status: + case UpdateStatus.NONE: + self.update_str_var.set("") + self._check_for_update() + case UpdateStatus.CHECKING: + self.update_str_var.set(lan.CHECKING_UPDATE) + case UpdateStatus.NO_UPDATE: + self.update_str_var.set(lan.NO_UPDATE_FOUND) + self.update_button.configure( + text=lan.CHECK_FOR_UPDATE, + state=tk.NORMAL, + command=self._check_for_update) + case UpdateStatus.NEW_VERSION: + self.update_str_var.set(f"{MAC_MANUAL_UPDATE_HINT} v{self.updater.web_version}") + self.update_button.configure( + text=lan.CHECK_FOR_UPDATE, + state=tk.NORMAL, + command=self._check_for_update) + case UpdateStatus.ERROR: + self.update_str_var.set(str(self.updater.update_exception)) + self.update_button.configure( + text=lan.CHECK_FOR_UPDATE, + state=tk.NORMAL, + command=self._check_for_update) + case _: + # Keep macOS release in manual-update mode only. + self.update_str_var.set(MAC_MANUAL_UPDATE_HINT) + self.update_button.configure( + text=lan.CHECK_FOR_UPDATE, + state=tk.NORMAL, + command=self._check_for_update) + self.after(100, self._refresh_ui) + return # update button and status match self.updater.update_status: - case UpdateStatus.NONE: - self.update_str_var.set("") - self._check_for_update() - case UpdateStatus.CHECKING: - self.update_str_var.set(lan.CHECKING_UPDATE) - case UpdateStatus.NO_UPDATE: - self.update_str_var.set(lan.NO_UPDATE_FOUND) - self.update_button.configure( - text = lan.CHECK_FOR_UPDATE, - state=tk.NORMAL, - command=self._check_for_update) - case UpdateStatus.NEW_VERSION: - self.update_str_var.set(lan.UPDATE_AVAILABLE + f" v{self.updater.web_version}") - self.update_button.configure( - text=lan.DOWNLOAD_UPDATE, - state=tk.NORMAL, - command=self._download_update - ) - self.update_cmd = self.updater.prepare_update - case UpdateStatus.DOWNLOADING: - self.update_str_var.set(lan.DOWNLOADING + f" {self.updater.dl_progress}") - case UpdateStatus.UNZIPPING: - self.update_str_var.set(lan.UNZIPPING) - case UpdateStatus.PREPARED: - self.update_str_var.set(lan.UPDATE_PREPARED) - self.update_button.configure( - text=lan.START_UPDATE, - state=tk.NORMAL, - command = self.updater.start_update) - case UpdateStatus.ERROR: - self.update_str_var.set(str(self.updater.update_exception)) - self.update_button.configure( - text=lan.CHECK_FOR_UPDATE, - state=tk.NORMAL, - command=self._check_for_update) - case _: - pass - - self.after(100, self._refresh_ui) - - - def _on_close(self): - self.destroy() - + case UpdateStatus.NONE: + self.update_str_var.set("") + self._check_for_update() + case UpdateStatus.CHECKING: + self.update_str_var.set(lan.CHECKING_UPDATE) + case UpdateStatus.NO_UPDATE: + self.update_str_var.set(lan.NO_UPDATE_FOUND) + self.update_button.configure( + text = lan.CHECK_FOR_UPDATE, + state=tk.NORMAL, + command=self._check_for_update) + case UpdateStatus.NEW_VERSION: + self.update_str_var.set(lan.UPDATE_AVAILABLE + f" v{self.updater.web_version}") + self.update_button.configure( + text=lan.DOWNLOAD_UPDATE, + state=tk.NORMAL, + command=self._download_update + ) + self.update_cmd = self.updater.prepare_update + case UpdateStatus.DOWNLOADING: + self.update_str_var.set(lan.DOWNLOADING + f" {self.updater.dl_progress}") + case UpdateStatus.UNZIPPING: + self.update_str_var.set(lan.UNZIPPING) + case UpdateStatus.PREPARED: + self.update_str_var.set(lan.UPDATE_PREPARED) + self.update_button.configure( + text=lan.START_UPDATE, + state=tk.NORMAL, + command = self.updater.start_update) + case UpdateStatus.ERROR: + self.update_str_var.set(str(self.updater.update_exception)) + self.update_button.configure( + text=lan.CHECK_FOR_UPDATE, + state=tk.NORMAL, + command=self._check_for_update) + case _: + pass + + self.after(100, self._refresh_ui) + + + def _on_close(self): + self.destroy() + diff --git a/gui/main_gui.py b/gui/main_gui.py index 71bbc43..c7990bf 100644 --- a/gui/main_gui.py +++ b/gui/main_gui.py @@ -1,440 +1,482 @@ -""" -Main GUI implementation for Mahjong Copilot -The GUI is a desktop app based on tkinter library -GUI functions: controlling browser settings, displaying AI guidance info, game status -""" - +""" +Main GUI implementation for Mahjong Copilot +The GUI is a desktop app based on tkinter library +GUI functions: controlling browser settings, displaying AI guidance info, game status +""" + import os +import subprocess +import sys import tkinter as tk -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 UiState, sub_file, error_to_str -from common.log_helper import LOGGER, LogHelper -from common.settings import Settings -from common.mj_helper import GameInfo, MJAI_TILE_2_UNICODE -from updater import Updater, UpdateStatus -from .utils import GUI_STYLE -from .settings_window import SettingsWindow +from tkinter import ttk, messagebox +import ttkbootstrap as ttkb + +from bot_manager import BotManager, mjai_reaction_2_guide +from common.utils import Folder, GameMode, GAME_MODES, GameClientType +from common.utils import UiState, sub_file, error_to_str +from common.log_helper import LOGGER, LogHelper +from common.settings import Settings +from common.mj_helper import GameInfo, MJAI_TILE_2_UNICODE +from updater import Updater, UpdateStatus +from .utils import GUI_STYLE +from .settings_window import SettingsWindow from .help_window import HelpWindow from .widgets import * # pylint: disable=wildcard-import, unused-wildcard-import +MAC_APP_NAME = "mortal_mac_v0.2" -class MainGUI(tk.Tk): + +class MainGUI(ttkb.Window): """ Main GUI Window""" def __init__(self, setting:Settings, bot_manager:BotManager): - super().__init__() + super().__init__(themename="darkly") self.bot_manager = bot_manager self.st = setting - self.updater = Updater(self.st.update_url) - self.after_idle(self.updater.load_help) - self.after_idle(self.updater.check_update) # check update when idle - - icon = tk.PhotoImage(file=sub_file(Folder.RES,'icon.png')) - self.iconphoto(True, icon) - self.protocol("WM_DELETE_WINDOW", self._on_exit) # confirmation before close window - size = (620,540) - self.geometry(f"{size[0]}x{size[1]}") - self.minsize(*size) - # Styling - scaling_factor = self.winfo_fpixels('1i') / 96 - GUI_STYLE.set_dpi_scaling(scaling_factor) - style = ttk.Style(self) - GUI_STYLE.set_style_normal(style) - # icon resources: - self.icon_green = sub_file(Folder.RES,'green.png') - self.icon_red = sub_file(Folder.RES,'red.png') - self.icon_yellow = sub_file(Folder.RES,'yellow.png') - self.icon_gray =sub_file(Folder.RES,'gray.png') - self.icon_ready = sub_file(Folder.RES,'ready.png') - - # create window widgets - self._create_widgets() - - self.bot_manager.start() # start the main program - self.gui_update_delay = 50 # in ms - self._update_gui_info() # start updating gui info - - + self.is_mac = sys.platform == "darwin" + self.updater = None + if not self.is_mac: + self.updater = Updater(self.st.update_url) + self.after_idle(self.updater.load_help) + self.after_idle(self.updater.check_update) # check update when idle + + icon = tk.PhotoImage(file=sub_file(Folder.RES,'icon.png')) + self.iconphoto(True, icon) + self.protocol("WM_DELETE_WINDOW", self._on_exit) # confirmation before close window + size = (680,560) + self.geometry(f"{size[0]}x{size[1]}") + self.minsize(*size) + # Styling + scaling_factor = self.winfo_fpixels('1i') / 96 + GUI_STYLE.set_dpi_scaling(scaling_factor) + style = ttk.Style(self) + GUI_STYLE.set_style_normal(style) + # icon resources: + self.icon_green = sub_file(Folder.RES,'green.png') + self.icon_red = sub_file(Folder.RES,'red.png') + self.icon_yellow = sub_file(Folder.RES,'yellow.png') + self.icon_gray =sub_file(Folder.RES,'gray.png') + self.icon_ready = sub_file(Folder.RES,'ready.png') + + # create window widgets + self._create_widgets() + + self.bot_manager.start() # start the main program + self.gui_update_delay = 50 # in ms + self._update_gui_info() # start updating gui info + + def _create_widgets(self): """ Create all widgets in the main window""" # Main window properties - self.title(self.st.lan().APP_TITLE) - - # container for grid control - self.grid_frame = tk.Frame(self) - self.grid_frame.pack(fill=tk.BOTH, expand=True) - self.grid_frame.grid_columnconfigure(0, weight=1) - grid_args = {'column':0, 'sticky': tk.EW, 'padx': 5, 'pady': 2} - - # === toolbar frame (row 0) === - cur_row = 0 - tb_ht = 70 - pack_args = {'side':tk.LEFT, 'padx':4, 'pady':4} - self.toolbar = ToolBar(self.grid_frame, tb_ht) - self.toolbar.grid(row=cur_row, **grid_args) - self.grid_frame.grid_rowconfigure(cur_row, weight=0) - - # start game button - self.toolbar.add_sep() - self.btn_start_browser = self.toolbar.add_button( - self.st.lan().START_BROWSER, 'majsoul.png', self._on_btn_start_browser_clicked) + app_title = MAC_APP_NAME if sys.platform == "darwin" else self.st.lan().APP_TITLE + self.title(app_title) + self.btn_help = None + + # container for grid control + self.grid_frame = ttk.Frame(self) + self.grid_frame.pack(fill=tk.BOTH, expand=True) + self.grid_frame.grid_columnconfigure(0, weight=1) + grid_args = {'column':0, 'sticky': tk.EW, 'padx': 5, 'pady': 2} + + # === toolbar frame (row 0) === + cur_row = 0 + tb_ht = 70 + pack_args = {'side':tk.LEFT, 'padx':4, 'pady':4} + self.toolbar = ToolBar(self.grid_frame, tb_ht) + self.toolbar.grid(row=cur_row, **grid_args) + self.grid_frame.grid_rowconfigure(cur_row, weight=0) + + # start game button + self.toolbar.add_sep() + self.btn_start_browser = self.toolbar.add_button( + self.st.lan().START_BROWSER, 'majsoul.png', self._on_btn_start_browser_clicked) # buttons on toolbar self.toolbar.add_sep() self.toolbar.add_button(self.st.lan().SETTINGS, 'settings.png', self._on_btn_settings_clicked) - self.toolbar.add_button(self.st.lan().OPEN_LOG_FILE, 'log.png', self._on_btn_log_clicked) - self.btn_help = self.toolbar.add_button(self.st.lan().HELP, 'help.png', self._on_btn_help_clicked) + if not self.is_mac: + self.toolbar.add_button(self.st.lan().OPEN_LOG_FILE, 'log.png', self._on_btn_log_clicked) + self.btn_help = self.toolbar.add_button(self.st.lan().HELP, 'help.png', self._on_btn_help_clicked) self.toolbar.add_sep() self.toolbar.add_button(self.st.lan().EXIT, 'exit.png', self._on_exit) - - # === 2nd toolbar === - cur_row += 1 - self.tb2 = ToolBar(self.grid_frame, tb_ht) - self.tb2.grid(row=cur_row, **grid_args) - sw_ft_sz = 10 - self.tb2.add_sep() - # Switches - self.switch_overlay = ToggleSwitch( - self.tb2, self.st.lan().WEB_OVERLAY, tb_ht, font_size=sw_ft_sz, command=self._on_switch_hud_clicked) - self.switch_overlay.pack(**pack_args) - self.tb2.add_sep() - self.switch_autoplay = ToggleSwitch( - self.tb2, self.st.lan().AUTOPLAY, tb_ht, font_size=sw_ft_sz, command=self._on_switch_autoplay_clicked) - self.switch_autoplay.pack(**pack_args) - # auto join - self.tb2.add_sep() - self.switch_autojoin = ToggleSwitch( - self.tb2, self.st.lan().AUTO_JOIN_GAME, tb_ht, font_size=sw_ft_sz, command=self._on_switch_autojoin_clicked) - self.switch_autojoin.pack(**pack_args) - # 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) - # timer + + # === 2nd toolbar === + cur_row += 1 + self.tb2 = ToolBar(self.grid_frame, tb_ht) + self.tb2.grid(row=cur_row, **grid_args) + sw_ft_sz = 10 + self.tb2.add_sep() + # Switches + self.switch_overlay = ToggleSwitch( + self.tb2, self.st.lan().WEB_OVERLAY, tb_ht, font_size=sw_ft_sz, command=self._on_switch_hud_clicked) + self.switch_overlay.pack(**pack_args) + self.tb2.add_sep() + self.switch_autoplay = ToggleSwitch( + self.tb2, self.st.lan().AUTOPLAY, tb_ht, font_size=sw_ft_sz, command=self._on_switch_autoplay_clicked) + self.switch_autoplay.pack(**pack_args) + # auto join + self.tb2.add_sep() + self.switch_autojoin = ToggleSwitch( + self.tb2, self.st.lan().AUTO_JOIN_GAME, tb_ht, font_size=sw_ft_sz, command=self._on_switch_autojoin_clicked) + self.switch_autojoin.pack(**pack_args) + # combo boxrd for auto join level and mode + _frame = tk.Frame(self.tb2, bg=GUI_STYLE.bg_color) + _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) + # 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 self.timer.pack(**pack_args) - self.tb2.add_sep() + self.switch_real_mouse = None + if sys.platform == "win32": + self.tb2.add_sep() + # real mouse switch + self.switch_real_mouse = ToggleSwitch( + self.tb2, self.st.lan().REAL_MOUSE, tb_ht, font_size=sw_ft_sz, command=self._on_switch_real_mouse_clicked) + self.switch_real_mouse.pack(**pack_args) + self.tb2.add_sep() - # === AI guidance === - cur_row += 1 - _label = ttk.Label(self.grid_frame, text=self.st.lan().AI_OUTPUT) - _label.grid(row=cur_row, **grid_args) - self.grid_frame.grid_rowconfigure(cur_row, weight=0) - - cur_row += 1 - self.ai_guide_var = tk.StringVar() - self.text_ai_guide = tk.Label( - self.grid_frame, - textvariable=self.ai_guide_var, - font=GUI_STYLE.font_normal("Segoe UI Emoji",22), - height=5, anchor=tk.NW, justify=tk.LEFT, - relief=tk.SUNKEN, padx=5,pady=5, - ) - self.text_ai_guide.grid(row=cur_row, **grid_args) - self.grid_frame.grid_rowconfigure(cur_row, weight=1) - - # === game info === - cur_row += 1 - _label = ttk.Label(self.grid_frame, text=self.st.lan().GAME_INFO) - _label.grid(row=cur_row, **grid_args) - self.grid_frame.grid_rowconfigure(cur_row, weight=0) - cur_row += 1 - self.gameinfo_var = tk.StringVar() - self.text_gameinfo = tk.Label( - self.grid_frame, - textvariable=self.gameinfo_var, - height=2, anchor=tk.W, justify=tk.LEFT, - font=GUI_STYLE.font_normal("Segoe UI Emoji",22), - relief=tk.SUNKEN, padx=5,pady=5, - ) - self.text_gameinfo.grid(row=cur_row, **grid_args) - self.grid_frame.grid_rowconfigure(cur_row, weight=1) - - # === Model info === - cur_row += 1 - self.model_bar = StatusBar(self.grid_frame, 2) - self.model_bar.grid(row=cur_row, column=0, sticky='ew', padx=1, pady=1) - self.grid_frame.grid_rowconfigure(cur_row, weight=0) - - # === status bar === - cur_row += 1 - self.status_bar = StatusBar(self.grid_frame, 3) - self.status_bar.grid(row=cur_row, column=0, sticky='ew', padx=1, pady=1) - self.grid_frame.grid_rowconfigure(cur_row, weight=0) - - def report_callback_exception(self, exc, val, tb): - """ override exception handling: write to log""" - LOGGER.error("GUI uncaught exception: %s", exc, exc_info=True) - # super().report_callback_exception(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) - - - 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] - self.st.auto_join_mode = new_mode - - - def _on_btn_start_browser_clicked(self): - self.btn_start_browser.config(state=tk.DISABLED) - self.bot_manager.start_browser() - - - def _on_switch_hud_clicked(self): - self.switch_overlay.switch_mid() - if not self.st.enable_overlay: - self.bot_manager.enable_overlay() - else: - self.bot_manager.disable_overlay() - - - def _on_switch_autoplay_clicked(self): - self.switch_autoplay.switch_mid() - if self.st.enable_automation: - self.bot_manager.disable_automation() - else: - self.bot_manager.enable_automation() - - - def _on_switch_autojoin_clicked(self): - self.switch_autojoin.switch_mid() - if self.st.auto_join_game: - self.bot_manager.disable_autojoin() - else: - self.bot_manager.enable_autojoin() - - + # === AI guidance === + cur_row += 1 + _label = ttk.Label(self.grid_frame, text=self.st.lan().AI_OUTPUT) + _label.grid(row=cur_row, **grid_args) + self.grid_frame.grid_rowconfigure(cur_row, weight=0) + + cur_row += 1 + self.ai_guide_var = tk.StringVar() + self.text_ai_guide = tk.Label( + self.grid_frame, + textvariable=self.ai_guide_var, + font=GUI_STYLE.font_normal("Segoe UI Emoji",22), + height=5, anchor=tk.NW, justify=tk.LEFT, + relief=tk.FLAT, padx=8, pady=8, + bg=GUI_STYLE.card_bg, fg=GUI_STYLE.fg_color, + highlightbackground=GUI_STYLE.card_border, highlightthickness=1, + ) + self.text_ai_guide.grid(row=cur_row, **grid_args) + self.grid_frame.grid_rowconfigure(cur_row, weight=1) + + # === game info === + cur_row += 1 + _label = ttk.Label(self.grid_frame, text=self.st.lan().GAME_INFO) + _label.grid(row=cur_row, **grid_args) + self.grid_frame.grid_rowconfigure(cur_row, weight=0) + cur_row += 1 + self.gameinfo_var = tk.StringVar() + self.text_gameinfo = tk.Label( + self.grid_frame, + textvariable=self.gameinfo_var, + height=2, anchor=tk.W, justify=tk.LEFT, + font=GUI_STYLE.font_normal("Segoe UI Emoji",22), + relief=tk.FLAT, padx=8, pady=8, + bg=GUI_STYLE.card_bg, fg=GUI_STYLE.fg_color, + highlightbackground=GUI_STYLE.card_border, highlightthickness=1, + ) + self.text_gameinfo.grid(row=cur_row, **grid_args) + self.grid_frame.grid_rowconfigure(cur_row, weight=1) + + # === Model info === + cur_row += 1 + self.model_bar = StatusBar(self.grid_frame, 2) + self.model_bar.grid(row=cur_row, column=0, sticky='ew', padx=1, pady=1) + self.grid_frame.grid_rowconfigure(cur_row, weight=0) + + # === status bar === + cur_row += 1 + self.status_bar = StatusBar(self.grid_frame, 3) + self.status_bar.grid(row=cur_row, column=0, sticky='ew', padx=1, pady=1) + self.grid_frame.grid_rowconfigure(cur_row, weight=0) + + def report_callback_exception(self, exc, val, tb): + """ override exception handling: write to log""" + LOGGER.error("GUI uncaught exception: %s", exc, exc_info=True) + # super().report_callback_exception(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) + + + 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] + self.st.auto_join_mode = new_mode + + + def _on_btn_start_browser_clicked(self): + self.btn_start_browser.config(state=tk.DISABLED) + self.bot_manager.start_browser() + + + def _on_switch_hud_clicked(self): + self.switch_overlay.switch_mid() + if not self.st.enable_overlay: + self.bot_manager.enable_overlay() + else: + self.bot_manager.disable_overlay() + + + def _on_switch_autoplay_clicked(self): + self.switch_autoplay.switch_mid() + if self.st.enable_automation: + self.bot_manager.disable_automation() + else: + self.bot_manager.enable_automation() + + + def _on_switch_autojoin_clicked(self): + self.switch_autojoin.switch_mid() + if self.st.auto_join_game: + self.bot_manager.disable_autojoin() + else: + self.bot_manager.enable_autojoin() + + def _on_switch_real_mouse_clicked(self): + if self.switch_real_mouse is None: + return + self.st.use_real_mouse = not self.st.use_real_mouse + self.st.save_json() + + def _on_btn_log_clicked(self): - # LOGGER.debug('Open log') - os.startfile(LogHelper.log_file_name) - - - def _on_btn_settings_clicked(self): - # open settings dialog (modal/blocking) - settings_window = SettingsWindow(self, self.st) - settings_window.transient(self) - settings_window.grab_set() - self.wait_window(settings_window) - - if settings_window.exit_save: - if settings_window.model_updated: - self.bot_manager.set_bot_update() - if settings_window.gui_need_reload: - self.reload_gui() - # mitm port occupy issue. Need to restart program for now - # if settings_window.mitm_proxinject_updated: - # message box to tell user to restart - - # self.bot_manager.set_mitm_proxinject_update() - - + if self.is_mac: + return + try: + if sys.platform == "win32": + os.startfile(LogHelper.log_file_name) + elif sys.platform == "darwin": + subprocess.Popen(["open", LogHelper.log_file_name]) + else: + subprocess.Popen(["xdg-open", LogHelper.log_file_name]) + except Exception as e: # pylint:disable=broad-except + LOGGER.error("Failed to open log file: %s", e, exc_info=True) + + + def _on_btn_settings_clicked(self): + # open settings dialog (modal/blocking) + settings_window = SettingsWindow(self, self.st) + settings_window.transient(self) + settings_window.grab_set() + self.wait_window(settings_window) + + if settings_window.exit_save: + if settings_window.model_updated: + self.bot_manager.set_bot_update() + if settings_window.gui_need_reload: + self.reload_gui() + # mitm port occupy issue. Need to restart program for now + # if settings_window.mitm_proxinject_updated: + # message box to tell user to restart + + # self.bot_manager.set_mitm_proxinject_update() + + def _on_btn_help_clicked(self): + if self.is_mac or self.updater is None: + return # open help dialog help_win = HelpWindow(self, self.st, self.updater) help_win.transient(self) help_win.grab_set() - - - def _on_exit(self): - # Exit the app - # pop up that confirm if the user really wants to quit - if messagebox.askokcancel(self.st.lan().EXIT, self.st.lan().EIXT_CONFIRM, parent=self): - try: - LOGGER.info("Exiting GUI and program") - self.status_bar.update_column(2, self.st.lan().EXIT + "ing...", self.icon_yellow) - self.update_idletasks() - self.st.save_json() - self.bot_manager.stop(True) - except: #pylint:disable=bare-except - pass - self.quit() - - - def reload_gui(self): - """ Clear UI compontes and rebuid widgets""" - for widget in self.winfo_children(): - widget.destroy() - self._create_widgets() - - - def _update_gui_info(self): - """ Update GUI widgets status with latest info from bot manager""" - try: - self._update_gui_info_inner() - except Exception as e: - LOGGER.error("Error updating GUI: %s", e, exc_info=True) - self.after(self.gui_update_delay, self._update_gui_info) - - def _update_gui_info_inner(self): - """ Update GUI widgets status with latest info from bot manager""" - # start browser button state - if not self.bot_manager.browser.is_running(): - if self.bot_manager.get_game_client_type() == GameClientType.PROXY: - self.btn_start_browser.config(state=tk.DISABLED) # disable when proxy client running + + + def _on_exit(self): + # Exit the app + # pop up that confirm if the user really wants to quit + if messagebox.askokcancel(self.st.lan().EXIT, self.st.lan().EIXT_CONFIRM, parent=self): + try: + LOGGER.info("Exiting GUI and program") + self.status_bar.update_column(2, self.st.lan().EXIT + "ing...", self.icon_yellow) + self.update_idletasks() + self.st.save_json() + self.bot_manager.stop(True) + except: #pylint:disable=bare-except + pass + self.quit() + + + def reload_gui(self): + """ Clear UI compontes and rebuid widgets""" + for widget in self.winfo_children(): + widget.destroy() + self._create_widgets() + + + def _update_gui_info(self): + """ Update GUI widgets status with latest info from bot manager""" + try: + self._update_gui_info_inner() + except Exception as e: + LOGGER.error("Error updating GUI: %s", e, exc_info=True) + self.after(self.gui_update_delay, self._update_gui_info) + + def _update_gui_info_inner(self): + """ Update GUI widgets status with latest info from bot manager""" + # start browser button state + if not self.bot_manager.browser.is_running(): + if self.bot_manager.get_game_client_type() == GameClientType.PROXY: + self.btn_start_browser.config(state=tk.DISABLED) # disable when proxy client running + else: + self.btn_start_browser.config(state=tk.NORMAL) + else: + self.btn_start_browser.config(state=tk.DISABLED) + + # help button (not shown on mac build) + if self.btn_help is not None and self.updater is not None: + if self.updater.update_status in ( + UpdateStatus.NEW_VERSION, + UpdateStatus.DOWNLOADING, + UpdateStatus.UNZIPPING, + UpdateStatus.PREPARED + ): + self.toolbar.set_img(self.btn_help, 'help_update.png') else: - self.btn_start_browser.config(state=tk.NORMAL) - else: - self.btn_start_browser.config(state=tk.DISABLED) - - # help button - if self.updater.update_status in ( - UpdateStatus.NEW_VERSION, - UpdateStatus.DOWNLOADING, - UpdateStatus.UNZIPPING, - UpdateStatus.PREPARED - ): - self.toolbar.set_img(self.btn_help, 'help_update.png') - else: - self.toolbar.set_img(self.btn_help, 'help.png') - - # update switch states + self.toolbar.set_img(self.btn_help, 'help.png') + + # update switch states sw_list = [ (self.switch_overlay, lambda: self.st.enable_overlay), (self.switch_autoplay, lambda: self.st.enable_automation), - (self.switch_autojoin, lambda: self.st.auto_join_game) + (self.switch_autojoin, lambda: self.st.auto_join_game), ] + if self.switch_real_mouse is not None: + sw_list.append((self.switch_real_mouse, lambda: self.st.use_real_mouse)) for sw, func in sw_list: - if func(): - sw.switch_on() - else: - sw.switch_off() - - # Update AI guide from Reaction - pending_reaction = self.bot_manager.get_pending_reaction() - if pending_reaction: - ai_guide_str, options = mjai_reaction_2_guide(pending_reaction, 3, self.st.lan()) - ai_guide_str += '\n' - for tile_str, weight in options: - ai_guide_str += f" {tile_str:8} {weight*100:4.0f}%\n" - self.ai_guide_var.set(ai_guide_str) - else: - self.ai_guide_var.set("") - - # update game info: display tehai + tsumohai - gi:GameInfo = self.bot_manager.get_game_info() - if gi and gi.my_tehai: - tehai = gi.my_tehai - tsumohai = gi.my_tsumohai - hand_str = ''.join(MJAI_TILE_2_UNICODE[t] for t in tehai) - if tsumohai: - hand_str += f" + {MJAI_TILE_2_UNICODE[tsumohai]}" - self.gameinfo_var.set(hand_str) - else: - self.gameinfo_var.set("") - - # bot/model info - if self.bot_manager.is_bot_created(): - mode_strs = [] - for m in GameMode: - if m in self.bot_manager.bot.supported_modes: - mode_strs.append('✔' + m.value) - else: - mode_strs.append('✖' + m.value) - mode_str = ' | '.join(mode_strs) - text = f"{self.st.lan().MODEL}: {self.st.model_type} ({mode_str})" - self.model_bar.update_column(0, text, self.icon_green) - if self.bot_manager.is_game_syncing(): - self.model_bar.update_column(1, '⌛ ' + self.st.lan().SYNCING) - elif self.bot_manager.is_bot_calculating(): - self.model_bar.update_column(1, '⌛ ' + self.st.lan().CALCULATING) - else: - self.model_bar.update_column(1, 'ℹ️' + self.bot_manager.bot.info_str) - else: # bot is not ready - if self.bot_manager.is_loading_bot: - text = self.st.lan().MODEL_LOADING - icon = self.icon_yellow - else: - text = self.st.lan().MODEL_NOT_LOADED - icon = self.icon_red - self.model_bar.update_column(0, text, icon) - self.model_bar.update_column(1, '') - - # Status bar - # main thread - fps_disp = min([999, self.bot_manager.fps_counter.fps]) - fps_str = f"({fps_disp:3.0f})" - if self.bot_manager.is_running(): # main thread - self.status_bar.update_column(0, self.st.lan().MAIN_THREAD + fps_str, self.icon_green) - else: - self.status_bar.update_column(0, self.st.lan().MAIN_THREAD + fps_str, self.icon_red) - - # client/browser - client_type = self.bot_manager.get_game_client_type() - if client_type == GameClientType.PLAYWRIGHT: - fps_disp = min(999, self.bot_manager.browser.fps_counter.fps) - fps_str = f"({fps_disp:3.0f})" - status_str = self.st.lan().BROWSER+fps_str - if self.bot_manager.browser.is_running(): - icon = self.icon_green - else: - icon = self.icon_gray - elif client_type == GameClientType.PROXY: - status_str = self.st.lan().PROXY_CLIENT - icon = self.icon_green - else: - status_str = self.st.lan().GAME_NOT_RUNNING - icon = self.icon_ready - self.status_bar.update_column(1, status_str, icon) - - # status (last col) - status_str, icon = self._get_status_text_icon(gi) - self.status_bar.update_column(2, status_str, icon) - - ### update overlay - self.bot_manager.update_overlay() - - def _get_status_text_icon(self, gi:GameInfo) -> tuple[str, str]: - # Get text and icon for status bar last column, based on bot running info - # show info as : thread error > game error > game status - bot_exception = self.bot_manager.main_thread_exception - if bot_exception: - return error_to_str(bot_exception, self.st.lan()), self.icon_red - else: # no exception in bot manager - pass - - game_error:Exception = self.bot_manager.get_game_error() - if game_error: - return error_to_str(game_error, self.st.lan()), self.icon_red - if self.bot_manager.is_browser_zoom_off(): - return self.st.lan().BROWSER_ZOOM_OFF, self.icon_red - - if self.bot_manager.is_in_game(): - info_str = self.st.lan().GAME_RUNNING - if self.bot_manager.is_game_syncing(): - info_str += " - " + self.st.lan().SYNCING - return info_str, self.icon_green - else: # game in progress - if gi and gi.bakaze: - info_str += ' '.join([ - "", "-", - f"{self.st.lan().mjai2str(gi.bakaze)}", - f"{gi.kyoku} {self.st.lan().KYOKU}", - f"{gi.honba} {self.st.lan().HONBA}", - ]) - else: - info_str += " - " + self.st.lan().GAME_STARTING - return info_str, self.icon_green - else: - state_dict = { - UiState.MAIN_MENU: self.st.lan().MAIN_MENU, - UiState.GAME_ENDING: self.st.lan().GAME_ENDING, - UiState.NOT_RUNNING: self.st.lan().GAME_NOT_RUNNING, - } - info_str = self.st.lan().READY_FOR_GAME + " - " + state_dict.get(self.bot_manager.automation.ui_state, "") - return info_str, self.icon_ready + if func(): + sw.switch_on() + else: + sw.switch_off() + + # Update AI guide from Reaction + pending_reaction = self.bot_manager.get_pending_reaction() + if pending_reaction: + ai_guide_str, options = mjai_reaction_2_guide(pending_reaction, 3, self.st.lan()) + ai_guide_str += '\n' + for tile_str, weight in options: + ai_guide_str += f" {tile_str:8} {weight*100:4.0f}%\n" + self.ai_guide_var.set(ai_guide_str) + else: + self.ai_guide_var.set("") + + # update game info: display tehai + tsumohai + gi:GameInfo = self.bot_manager.get_game_info() + if gi and gi.my_tehai: + tehai = gi.my_tehai + tsumohai = gi.my_tsumohai + hand_str = ''.join(MJAI_TILE_2_UNICODE[t] for t in tehai) + if tsumohai: + hand_str += f" + {MJAI_TILE_2_UNICODE[tsumohai]}" + self.gameinfo_var.set(hand_str) + else: + self.gameinfo_var.set("") + + # bot/model info + if self.bot_manager.is_bot_created(): + mode_strs = [] + for m in GameMode: + if m in self.bot_manager.bot.supported_modes: + mode_strs.append('✔' + m.value) + else: + mode_strs.append('✖' + m.value) + mode_str = ' | '.join(mode_strs) + text = f"{self.st.lan().MODEL}: {self.st.model_type} ({mode_str})" + self.model_bar.update_column(0, text, self.icon_green) + if self.bot_manager.is_game_syncing(): + self.model_bar.update_column(1, '⌛ ' + self.st.lan().SYNCING) + elif self.bot_manager.is_bot_calculating(): + self.model_bar.update_column(1, '⌛ ' + self.st.lan().CALCULATING) + else: + self.model_bar.update_column(1, 'ℹ️' + self.bot_manager.bot.info_str) + else: # bot is not ready + if self.bot_manager.is_loading_bot: + text = self.st.lan().MODEL_LOADING + icon = self.icon_yellow + else: + text = self.st.lan().MODEL_NOT_LOADED + icon = self.icon_red + self.model_bar.update_column(0, text, icon) + self.model_bar.update_column(1, '') + + # Status bar + # main thread + fps_disp = min([999, self.bot_manager.fps_counter.fps]) + fps_str = f"({fps_disp:3.0f})" + if self.bot_manager.is_running(): # main thread + self.status_bar.update_column(0, self.st.lan().MAIN_THREAD + fps_str, self.icon_green) + else: + self.status_bar.update_column(0, self.st.lan().MAIN_THREAD + fps_str, self.icon_red) + + # client/browser + client_type = self.bot_manager.get_game_client_type() + if client_type == GameClientType.PLAYWRIGHT: + fps_disp = min(999, self.bot_manager.browser.fps_counter.fps) + fps_str = f"({fps_disp:3.0f})" + status_str = self.st.lan().BROWSER+fps_str + if self.bot_manager.browser.is_running(): + icon = self.icon_green + else: + icon = self.icon_gray + elif client_type == GameClientType.PROXY: + status_str = self.st.lan().PROXY_CLIENT + icon = self.icon_green + else: + status_str = self.st.lan().GAME_NOT_RUNNING + icon = self.icon_ready + self.status_bar.update_column(1, status_str, icon) + + # status (last col) + status_str, icon = self._get_status_text_icon(gi) + self.status_bar.update_column(2, status_str, icon) + + ### update overlay + self.bot_manager.update_overlay() + + def _get_status_text_icon(self, gi:GameInfo) -> tuple[str, str]: + # Get text and icon for status bar last column, based on bot running info + # show info as : thread error > game error > game status + bot_exception = self.bot_manager.main_thread_exception + if bot_exception: + return error_to_str(bot_exception, self.st.lan()), self.icon_red + else: # no exception in bot manager + pass + + game_error:Exception = self.bot_manager.get_game_error() + if game_error: + return error_to_str(game_error, self.st.lan()), self.icon_red + if self.bot_manager.is_browser_zoom_off(): + return self.st.lan().BROWSER_ZOOM_OFF, self.icon_red + + if self.bot_manager.is_in_game(): + info_str = self.st.lan().GAME_RUNNING + if self.bot_manager.is_game_syncing(): + info_str += " - " + self.st.lan().SYNCING + return info_str, self.icon_green + else: # game in progress + if gi and gi.bakaze: + info_str += ' '.join([ + "", "-", + f"{self.st.lan().mjai2str(gi.bakaze)}", + f"{gi.kyoku} {self.st.lan().KYOKU}", + f"{gi.honba} {self.st.lan().HONBA}", + ]) + else: + info_str += " - " + self.st.lan().GAME_STARTING + return info_str, self.icon_green + else: + state_dict = { + UiState.MAIN_MENU: self.st.lan().MAIN_MENU, + UiState.GAME_ENDING: self.st.lan().GAME_ENDING, + UiState.NOT_RUNNING: self.st.lan().GAME_NOT_RUNNING, + } + info_str = self.st.lan().READY_FOR_GAME + " - " + state_dict.get(self.bot_manager.automation.ui_state, "") + return info_str, self.icon_ready diff --git a/gui/settings_window.py b/gui/settings_window.py index d63a81a..f6af1d4 100644 --- a/gui/settings_window.py +++ b/gui/settings_window.py @@ -1,363 +1,613 @@ """ GUI Settings Window """ +import sys import tkinter as tk from tkinter import ttk, messagebox - -from common.utils import Folder -from common.utils import list_children -from common.log_helper import LOGGER -from common.settings import Settings -from common.lan_str import LAN_OPTIONS -from bot import MODEL_TYPE_STRINGS -from .utils import GUI_STYLE, add_hover_text - + +from common.utils import Folder +from common.utils import list_children +from common.log_helper import LOGGER +from common.settings import Settings +from common.lan_str import LAN_OPTIONS +from bot import MODEL_TYPE_STRINGS +from .utils import GUI_STYLE, add_hover_text + class SettingsWindow(tk.Toplevel): - """ Settings dialog window""" - def __init__(self, parent:tk.Frame, setting:Settings): - super().__init__(parent) + """ Settings dialog window""" + def __init__(self, parent:tk.Frame, setting:Settings): + super().__init__(parent) self.st = setting - - self.geometry('700x675') - self.minsize(700,675) - # self.resizable(False, False) - # set position: within main window - parent_x = parent.winfo_x() - parent_y = parent.winfo_y() - self.geometry(f'+{parent_x+10}+{parent_y+10}') - - # flags - self.exit_save:bool = False # save btn clicked - self.gui_need_reload:bool = False # - self.model_updated:bool = False # model settings updated - self.mitm_proxinject_updated:bool = False # mitm settings updated - - style = ttk.Style(self) - GUI_STYLE.set_style_normal(style) - self.create_widgets() - - + self.is_mac = sys.platform == "darwin" + + self.geometry('700x730') + self.minsize(700,730) + self.configure(bg=GUI_STYLE.bg_color) + # set position: within main window + parent_x = parent.winfo_x() + parent_y = parent.winfo_y() + self.geometry(f'+{parent_x+10}+{parent_y+10}') + + # flags + self.exit_save:bool = False # save btn clicked + self.gui_need_reload:bool = False # + self.model_updated:bool = False # model settings updated + self.mitm_proxinject_updated:bool = False # mitm settings updated + + style = ttk.Style(self) + GUI_STYLE.set_style_normal(style) + self.create_widgets() + + def create_widgets(self): """ Create widgets for settings dialog""" self.title(self.st.lan().SETTINGS) + if self.is_mac: + self._create_widgets_mac() + return # Main frame main_frame = ttk.Frame(self, padding="20") - main_frame.pack(expand=True, fill="both") + main_frame.pack(expand=True, fill="both") + + pad_args = {"padx":(3, 3), "pady":(3, 2)} + args_label = {"sticky":"e", **pad_args} + args_entry = {"sticky":"w", **pad_args} + std_wid = 15 + # auto launch browser + cur_row = 0 + _label = ttk.Label(main_frame, text=self.st.lan().BROWSER) + _label.grid(row=cur_row, column=0, **args_label) + self.auto_launch_var = tk.BooleanVar(value=self.st.auto_launch_browser) + auto_launch_entry = ttk.Checkbutton( + main_frame, variable=self.auto_launch_var, + text=self.st.lan().AUTO_LAUNCH_BROWSER, width=std_wid) + auto_launch_entry.grid(row=cur_row, column=1, **args_entry) + + # Select client size + _label = ttk.Label(main_frame, text=self.st.lan().CLIENT_SIZE) + _label.grid(row=cur_row, column=2, **args_label) + options = ["960 x 540", "1280 x 720", "1600 x 900", "1920 x 1080", "2560 x 1440", "3840 x 2160"] + setting_size = f"{self.st.browser_width} x {self.st.browser_height}" + self.client_size_var = tk.StringVar(value=setting_size) + select_menu = ttk.Combobox(main_frame, textvariable=self.client_size_var, values=options, state="readonly", width=std_wid) + select_menu.grid(row=cur_row, column=3, **args_entry) + + # majsoul url + cur_row += 1 + _label = ttk.Label(main_frame, text=self.st.lan().MAJSOUL_URL) + _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) + # extensions + self.enable_extension_var = tk.BooleanVar(value=self.st.enable_chrome_ext) + auto_launch_entry = ttk.Checkbutton( + main_frame, variable=self.enable_extension_var, + text=self.st.lan().ENABLE_CHROME_EXT, width=std_wid+1) + auto_launch_entry.grid(row=cur_row, column=3, **args_entry) + + # mitm port + cur_row += 1 + _label = ttk.Label(main_frame, text=self.st.lan().MITM_PORT) + _label.grid(row=cur_row, column=0, **args_label) + # add_hover_text(_label, "Need restart") + self.mitm_port_var = tk.StringVar(value=self.st.mitm_port) + number_entry = ttk.Entry(main_frame, textvariable=self.mitm_port_var, width=std_wid) + number_entry.grid(row=cur_row, column=1, **args_entry) + # upstream proxy + _frame = tk.Frame(main_frame, bg=GUI_STYLE.bg_color) + _frame.grid(row=cur_row, column=2, columnspan=2) + _label = ttk.Label(_frame, text=self.st.lan().UPSTREAM_PROXY) + _label.pack(side=tk.LEFT, **pad_args) + self.upstream_proxy_var = tk.StringVar(value=self.st.upstream_proxy) + _entry = ttk.Entry(_frame, textvariable=self.upstream_proxy_var, width=std_wid*2) + _entry.pack(side=tk.LEFT, **pad_args) + + # Select language + cur_row += 1 + _label = ttk.Label(main_frame, text=self.st.lan().LANGUAGE) + _label.grid(row=cur_row, column=0, **args_label) + options = [v.LANGUAGE_NAME for v in LAN_OPTIONS.values()] + self.language_var = tk.StringVar(value=LAN_OPTIONS[self.st.language].LANGUAGE_NAME) + select_menu = ttk.Combobox(main_frame, textvariable=self.language_var, values=options, state="readonly", width=std_wid) + select_menu.grid(row=cur_row, column=1, **args_entry) + + # proxy inject + self.proxy_inject_var = tk.BooleanVar(value=self.st.enable_proxinject) + check_proxy_inject = ttk.Checkbutton( + main_frame, variable=self.proxy_inject_var, text=self.st.lan().CLIENT_INJECT_PROXY, width=std_wid*2) + check_proxy_inject.grid(row=cur_row, column=2, columnspan=2, **args_entry) + + # sep + cur_row += 1 + sep = ttk.Separator(main_frame, orient=tk.HORIZONTAL) + sep.grid(row=cur_row, column=0, columnspan=4, sticky="ew", pady=5) + # Select Model Type + cur_row += 1 + _label = ttk.Label(main_frame, text=self.st.lan().MODEL_TYPE) + _label.grid(row=cur_row, column=0, **args_label) + self.model_type_var = tk.StringVar(value=self.st.model_type) + select_menu = ttk.Combobox(main_frame, textvariable=self.model_type_var, values=MODEL_TYPE_STRINGS, state="readonly", width=std_wid) + select_menu.grid(row=cur_row, column=1, **args_entry) + + # Select Model File + model_files = [""] + list_children(Folder.MODEL) + cur_row += 1 + _label = ttk.Label(main_frame, text=self.st.lan().AI_MODEL_FILE) + _label.grid(row=cur_row, column=0, **args_label) + self.model_file_var = tk.StringVar(value=self.st.model_file) + select_menu = ttk.Combobox(main_frame, textvariable=self.model_file_var, values=model_files, state="readonly", width=std_wid*3) + select_menu.grid(row=cur_row, column=1, columnspan=3, **args_entry) + # model file 3p + cur_row += 1 + _label = ttk.Label(main_frame, text=self.st.lan().AI_MODEL_FILE_3P) + _label.grid(row=cur_row, column=0, **args_label) + self.model_file_3p_var = tk.StringVar(value=self.st.model_file_3p) + select_menu2 = ttk.Combobox(main_frame, textvariable=self.model_file_3p_var, values=model_files, state="readonly", width=std_wid*3) + select_menu2.grid(row=cur_row, column=1, columnspan=3, **args_entry) + # Akagi OT + cur_row += 1 + _label = ttk.Label(main_frame, text=self.st.lan().AKAGI_OT_URL) + _label.grid(row=cur_row, column=0, **args_label) + self.akagiot_url_var = tk.StringVar(value=self.st.akagi_ot_url) + string_entry = ttk.Entry(main_frame, textvariable=self.akagiot_url_var, width=std_wid*4) + string_entry.grid(row=cur_row, column=1,columnspan=3, **args_entry) + # Akagi OT API Key + cur_row += 1 + _label = ttk.Label(main_frame, text=self.st.lan().AKAGI_OT_APIKEY) + _label.grid(row=cur_row, column=0, **args_label) + self.akagiot_apikey_var = tk.StringVar(value=self.st.akagi_ot_apikey) + string_entry = ttk.Entry(main_frame, textvariable=self.akagiot_apikey_var, width=std_wid*4) + string_entry.grid(row=cur_row, column=1,columnspan=3, **args_entry) + + # MJAPI url + cur_row += 1 + _label = ttk.Label(main_frame, text=self.st.lan().MJAPI_URL) + _label.grid(row=cur_row, column=0, **args_label) + self.mjapi_url_var = tk.StringVar(value=self.st.mjapi_url) + string_entry = ttk.Entry(main_frame, textvariable=self.mjapi_url_var, width=std_wid*4) + string_entry.grid(row=cur_row, column=1,columnspan=3, **args_entry) + + # MJAPI user + cur_row += 1 + _label = ttk.Label(main_frame, text=self.st.lan().MJAPI_USER) + _label.grid(row=cur_row, column=0, **args_label) + self.mjapi_user_var = tk.StringVar(value=self.st.mjapi_user) + string_entry = ttk.Entry(main_frame, textvariable=self.mjapi_user_var, width=std_wid) + string_entry.grid(row=cur_row, column=1, **args_entry) + + # MJAPI secret + cur_row += 1 + _label = ttk.Label(main_frame, text=self.st.lan().MJAPI_SECRET) + _label.grid(row=cur_row, column=0, **args_label) + self.mjapi_secret_var = tk.StringVar(value=self.st.mjapi_secret) + string_entry = ttk.Entry(main_frame, textvariable=self.mjapi_secret_var,width=std_wid*4) + string_entry.grid(row=cur_row, column=1,columnspan=3, **args_entry) + + # MJAPI model + cur_row += 1 + _label = ttk.Label(main_frame, text=self.st.lan().MJAPI_MODEL_SELECT) + _label.grid(row=cur_row, column=0, **args_label) + self.mjapi_model_select_var = tk.StringVar(value=self.st.mjapi_model_select) + options = self.st.mjapi_models + sel_model = ttk.Combobox(main_frame, textvariable=self.mjapi_model_select_var, values=options, state="readonly", width=std_wid) + sel_model.grid(row=cur_row, column=1, **args_entry) + + _label = ttk.Label(main_frame, text=self.st.lan().LOGIN_TO_REFRESH) + _label.grid(row=cur_row, column=2, **args_entry) + + # sep + cur_row += 1 + sep = ttk.Separator(main_frame, orient=tk.HORIZONTAL) + sep.grid(row=cur_row, column=0, columnspan=4, sticky="ew", pady=5) + ### Auto play settings + cur_row += 1 + _label = ttk.Label(main_frame, text=self.st.lan().AUTO_PLAY_SETTINGS) + _label.grid(row=cur_row, column=0, **args_label) + # random move + self.random_move_var = tk.BooleanVar(value=self.st.auto_random_move) + ran_moves_entry = ttk.Checkbutton( + main_frame, variable=self.random_move_var, text=self.st.lan().MOUSE_RANDOM_MOVE, width=std_wid) + ran_moves_entry.grid(row=cur_row, column=1, **args_entry) + # idle move + self.auto_idle_move_var = tk.BooleanVar(value=self.st.auto_idle_move) + idle_move_entry = ttk.Checkbutton(main_frame, variable=self.auto_idle_move_var, text=self.st.lan().AUTO_IDLE_MOVE, width=std_wid) + idle_move_entry.grid(row=cur_row, column=2, **args_entry) + # drag dahai + self.auto_drag_dahai_var = tk.BooleanVar(value=self.st.auto_dahai_drag) + _entry = ttk.Checkbutton(main_frame, variable=self.auto_drag_dahai_var, text=self.st.lan().DRAG_DAHAI, width=std_wid) + _entry.grid(row=cur_row, column=3, **args_entry) + + # randomize choice + cur_row += 1 + _label = ttk.Label(main_frame, text=self.st.lan().RANDOM_CHOICE) + _label.grid(row=cur_row, column=0, **args_label) + self.randomized_choice_var = tk.StringVar(value=self.st.ai_randomize_choice) + options = ['0 (Off)',1,2,3,4,5] + random_choice_entry = ttk.Combobox( + main_frame, textvariable=self.randomized_choice_var, values=options, state="readonly", width=std_wid) + random_choice_entry.grid(row=cur_row, column=1, **args_entry) + # reply emoji chance + _label = ttk.Label(main_frame, text=self.st.lan().REPLY_EMOJI_CHANCE) + _label.grid(row=cur_row, column=2, **args_label) + options = [f"{i*10}%" for i in range(11)] + options[0] = '0% (off)' + self.reply_emoji_var = tk.StringVar(value=f"{int(self.st.auto_reply_emoji_rate*100)}%") + _combo = ttk.Combobox( + main_frame, textvariable=self.reply_emoji_var, values=options, state="readonly", width=std_wid) + _combo.grid(row=cur_row, column=3, **args_entry) + + # random delay lower/upper + cur_row += 1 + _label = ttk.Label(main_frame, text=self.st.lan().RANDOM_DELAY_RANGE) + _label.grid(row=cur_row, column=0, **args_label) + self.delay_random_lower_var = tk.DoubleVar(value=self.st.delay_random_lower) + delay_lower_entry = tk.Entry(main_frame, textvariable= self.delay_random_lower_var, width=std_wid, + bg=GUI_STYLE.card_bg, fg=GUI_STYLE.fg_color, insertbackground=GUI_STYLE.fg_color) + delay_lower_entry.grid(row=cur_row, column=1, **args_entry) + self.delay_random_upper_var = tk.DoubleVar(value=self.st.delay_random_upper) + delay_upper_entry = tk.Entry(main_frame, textvariable= self.delay_random_upper_var, width=std_wid, + bg=GUI_STYLE.card_bg, fg=GUI_STYLE.fg_color, insertbackground=GUI_STYLE.fg_color) + delay_upper_entry.grid(row=cur_row, column=2, **args_entry) + + # real mouse parameters + cur_row += 1 + _label = ttk.Label(main_frame, text=self.st.lan().REAL_MOUSE_SPEED) + _label.grid(row=cur_row, column=0, **args_label) + self.real_mouse_speed_var = tk.DoubleVar(value=self.st.real_mouse_speed_pps) + _entry = tk.Entry(main_frame, textvariable=self.real_mouse_speed_var, width=std_wid, + bg=GUI_STYLE.card_bg, fg=GUI_STYLE.fg_color, insertbackground=GUI_STYLE.fg_color) + _entry.grid(row=cur_row, column=1, **args_entry) + _label = ttk.Label(main_frame, text=self.st.lan().REAL_MOUSE_JITTER) + _label.grid(row=cur_row, column=2, **args_label) + self.real_mouse_jitter_var = tk.DoubleVar(value=self.st.real_mouse_jitter_px) + _entry = tk.Entry(main_frame, textvariable=self.real_mouse_jitter_var, width=std_wid, + bg=GUI_STYLE.card_bg, fg=GUI_STYLE.fg_color, insertbackground=GUI_STYLE.fg_color) + _entry.grid(row=cur_row, column=3, **args_entry) + + cur_row += 1 + _label = ttk.Label(main_frame, text=self.st.lan().REAL_MOUSE_CLICK_OFFSET) + _label.grid(row=cur_row, column=0, **args_label) + self.real_mouse_click_offset_var = tk.DoubleVar(value=self.st.real_mouse_click_offset_px) + _entry = tk.Entry(main_frame, textvariable=self.real_mouse_click_offset_var, width=std_wid, + bg=GUI_STYLE.card_bg, fg=GUI_STYLE.fg_color, insertbackground=GUI_STYLE.fg_color) + _entry.grid(row=cur_row, column=1, **args_entry) + + # tips :Settings + cur_row += 1 + label_settings = ttk.Label(main_frame, text=self.st.lan().SETTINGS_TIPS, width=std_wid*4) + label_settings.grid(row=cur_row, column=0, columnspan=4, **args_entry) + + # Buttons frame + button_frame = ttk.Frame(self) + button_frame.pack(side=tk.BOTTOM, fill=tk.X) + cancel_button = ttk.Button(button_frame, text=self.st.lan().CANCEL, command=self._on_cancel) + cancel_button.pack(side=tk.LEFT, padx=20, pady=20) + save_button = ttk.Button(button_frame, text=self.st.lan().SAVE, command=self._on_save) + save_button.pack(side=tk.RIGHT, padx=20, pady=20) - pad_args = {"padx":(3, 3), "pady":(3, 2)} - args_label = {"sticky":"e", **pad_args} - args_entry = {"sticky":"w", **pad_args} + def _create_widgets_mac(self): + """Create minimal settings UI for macOS release.""" + main_frame = ttk.Frame(self, padding="20") + main_frame.pack(expand=True, fill="both") + + pad_args = {"padx": (3, 3), "pady": (3, 2)} + args_label = {"sticky": "e", **pad_args} + args_entry = {"sticky": "w", **pad_args} std_wid = 15 - # auto launch browser cur_row = 0 - _label = ttk.Label(main_frame, text=self.st.lan().BROWSER) - _label.grid(row=cur_row, column=0, **args_label) - self.auto_launch_var = tk.BooleanVar(value=self.st.auto_launch_browser) - auto_launch_entry = ttk.Checkbutton( - main_frame, variable=self.auto_launch_var, - text=self.st.lan().AUTO_LAUNCH_BROWSER, width=std_wid) - auto_launch_entry.grid(row=cur_row, column=1, **args_entry) - # Select client size + # Client size _label = ttk.Label(main_frame, text=self.st.lan().CLIENT_SIZE) - _label.grid(row=cur_row, column=2, **args_label) - options = ["960 x 540", "1280 x 720", "1600 x 900", "1920 x 1080", "2560 x 1440", "3840 x 2160"] + _label.grid(row=cur_row, column=0, **args_label) + size_options = [ + "960 x 540", "1280 x 720", "1600 x 900", + "1920 x 1080", "2560 x 1440", "3840 x 2160" + ] setting_size = f"{self.st.browser_width} x {self.st.browser_height}" self.client_size_var = tk.StringVar(value=setting_size) - select_menu = ttk.Combobox(main_frame, textvariable=self.client_size_var, values=options, state="readonly", width=std_wid) - select_menu.grid(row=cur_row, column=3, **args_entry) - - # majsoul url + _combo = ttk.Combobox( + main_frame, textvariable=self.client_size_var, values=size_options, + state="readonly", width=std_wid + ) + _combo.grid(row=cur_row, column=1, **args_entry) + self.browser_fullscreen_var = tk.BooleanVar(value=self.st.browser_fullscreen) + _entry = ttk.Checkbutton( + main_frame, variable=self.browser_fullscreen_var, text=self.st.lan().BROWSER_FULLSCREEN, width=std_wid + ) + _entry.grid(row=cur_row, column=2, **args_entry) + self.browser_high_quality_var = tk.BooleanVar(value=self.st.browser_high_quality) + _entry = ttk.Checkbutton( + main_frame, variable=self.browser_high_quality_var, text=self.st.lan().BROWSER_HIGH_QUALITY, width=std_wid + ) + _entry.grid(row=cur_row, column=3, **args_entry) + + # Majsoul URL cur_row += 1 _label = ttk.Label(main_frame, text=self.st.lan().MAJSOUL_URL) _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) - # extensions - self.enable_extension_var = tk.BooleanVar(value=self.st.enable_chrome_ext) - auto_launch_entry = ttk.Checkbutton( - main_frame, variable=self.enable_extension_var, - text=self.st.lan().ENABLE_CHROME_EXT, width=std_wid+1) - auto_launch_entry.grid(row=cur_row, column=3, **args_entry) - - # mitm port - cur_row += 1 - _label = ttk.Label(main_frame, text=self.st.lan().MITM_PORT) - _label.grid(row=cur_row, column=0, **args_label) - # add_hover_text(_label, "Need restart") - self.mitm_port_var = tk.StringVar(value=self.st.mitm_port) - number_entry = ttk.Entry(main_frame, textvariable=self.mitm_port_var, width=std_wid) - number_entry.grid(row=cur_row, column=1, **args_entry) - # upstream proxy - _frame = tk.Frame(main_frame) - _frame.grid(row=cur_row, column=2, columnspan=2) - _label = ttk.Label(_frame, text=self.st.lan().UPSTREAM_PROXY) - _label.pack(side=tk.LEFT, **pad_args) - self.upstream_proxy_var = tk.StringVar(value=self.st.upstream_proxy) - _entry = ttk.Entry(_frame, textvariable=self.upstream_proxy_var, width=std_wid*2) - _entry.pack(side=tk.LEFT, **pad_args) - - # Select language - cur_row += 1 - _label = ttk.Label(main_frame, text=self.st.lan().LANGUAGE) - _label.grid(row=cur_row, column=0, **args_label) - options = [v.LANGUAGE_NAME for v in LAN_OPTIONS.values()] - self.language_var = tk.StringVar(value=LAN_OPTIONS[self.st.language].LANGUAGE_NAME) - select_menu = ttk.Combobox(main_frame, textvariable=self.language_var, values=options, state="readonly", width=std_wid) - select_menu.grid(row=cur_row, column=1, **args_entry) - - # proxy inject - self.proxy_inject_var = tk.BooleanVar(value=self.st.enable_proxinject) - check_proxy_inject = ttk.Checkbutton( - main_frame, variable=self.proxy_inject_var, text=self.st.lan().CLIENT_INJECT_PROXY, width=std_wid*2) - check_proxy_inject.grid(row=cur_row, column=2, columnspan=2, **args_entry) + _entry = ttk.Entry(main_frame, textvariable=self.ms_url_var, width=std_wid * 4) + _entry.grid(row=cur_row, column=1, columnspan=3, **args_entry) - # sep + # Model files (Local only) cur_row += 1 sep = ttk.Separator(main_frame, orient=tk.HORIZONTAL) sep.grid(row=cur_row, column=0, columnspan=4, sticky="ew", pady=5) - # Select Model Type - cur_row += 1 - _label = ttk.Label(main_frame, text=self.st.lan().MODEL_TYPE) - _label.grid(row=cur_row, column=0, **args_label) - self.model_type_var = tk.StringVar(value=self.st.model_type) - select_menu = ttk.Combobox(main_frame, textvariable=self.model_type_var, values=MODEL_TYPE_STRINGS, state="readonly", width=std_wid) - select_menu.grid(row=cur_row, column=1, **args_entry) - - # Select Model File + model_files = [""] + list_children(Folder.MODEL) cur_row += 1 _label = ttk.Label(main_frame, text=self.st.lan().AI_MODEL_FILE) - _label.grid(row=cur_row, column=0, **args_label) + _label.grid(row=cur_row, column=0, **args_label) self.model_file_var = tk.StringVar(value=self.st.model_file) - select_menu = ttk.Combobox(main_frame, textvariable=self.model_file_var, values=model_files, state="readonly", width=std_wid*3) - select_menu.grid(row=cur_row, column=1, columnspan=3, **args_entry) - # model file 3p + _combo = ttk.Combobox( + main_frame, textvariable=self.model_file_var, values=model_files, + state="readonly", width=std_wid * 3 + ) + _combo.grid(row=cur_row, column=1, columnspan=3, **args_entry) + cur_row += 1 _label = ttk.Label(main_frame, text=self.st.lan().AI_MODEL_FILE_3P) _label.grid(row=cur_row, column=0, **args_label) self.model_file_3p_var = tk.StringVar(value=self.st.model_file_3p) - select_menu2 = ttk.Combobox(main_frame, textvariable=self.model_file_3p_var, values=model_files, state="readonly", width=std_wid*3) - select_menu2.grid(row=cur_row, column=1, columnspan=3, **args_entry) - # Akagi OT - cur_row += 1 - _label = ttk.Label(main_frame, text=self.st.lan().AKAGI_OT_URL) - _label.grid(row=cur_row, column=0, **args_label) - self.akagiot_url_var = tk.StringVar(value=self.st.akagi_ot_url) - string_entry = ttk.Entry(main_frame, textvariable=self.akagiot_url_var, width=std_wid*4) - string_entry.grid(row=cur_row, column=1,columnspan=3, **args_entry) - # Akagi OT API Key - cur_row += 1 - _label = ttk.Label(main_frame, text=self.st.lan().AKAGI_OT_APIKEY) - _label.grid(row=cur_row, column=0, **args_label) - self.akagiot_apikey_var = tk.StringVar(value=self.st.akagi_ot_apikey) - string_entry = ttk.Entry(main_frame, textvariable=self.akagiot_apikey_var, width=std_wid*4) - string_entry.grid(row=cur_row, column=1,columnspan=3, **args_entry) - - # MJAPI url - cur_row += 1 - _label = ttk.Label(main_frame, text=self.st.lan().MJAPI_URL) - _label.grid(row=cur_row, column=0, **args_label) - self.mjapi_url_var = tk.StringVar(value=self.st.mjapi_url) - string_entry = ttk.Entry(main_frame, textvariable=self.mjapi_url_var, width=std_wid*4) - string_entry.grid(row=cur_row, column=1,columnspan=3, **args_entry) - - # MJAPI user - cur_row += 1 - _label = ttk.Label(main_frame, text=self.st.lan().MJAPI_USER) - _label.grid(row=cur_row, column=0, **args_label) - self.mjapi_user_var = tk.StringVar(value=self.st.mjapi_user) - string_entry = ttk.Entry(main_frame, textvariable=self.mjapi_user_var, width=std_wid) - string_entry.grid(row=cur_row, column=1, **args_entry) - - # MJAPI secret - cur_row += 1 - _label = ttk.Label(main_frame, text=self.st.lan().MJAPI_SECRET) - _label.grid(row=cur_row, column=0, **args_label) - self.mjapi_secret_var = tk.StringVar(value=self.st.mjapi_secret) - string_entry = ttk.Entry(main_frame, textvariable=self.mjapi_secret_var,width=std_wid*4) - string_entry.grid(row=cur_row, column=1,columnspan=3, **args_entry) - - # MJAPI model - cur_row += 1 - _label = ttk.Label(main_frame, text=self.st.lan().MJAPI_MODEL_SELECT) - _label.grid(row=cur_row, column=0, **args_label) - self.mjapi_model_select_var = tk.StringVar(value=self.st.mjapi_model_select) - options = self.st.mjapi_models - sel_model = ttk.Combobox(main_frame, textvariable=self.mjapi_model_select_var, values=options, state="readonly", width=std_wid) - sel_model.grid(row=cur_row, column=1, **args_entry) - - _label = ttk.Label(main_frame, text=self.st.lan().LOGIN_TO_REFRESH) - _label.grid(row=cur_row, column=2, **args_entry) - - # sep + _combo = ttk.Combobox( + main_frame, textvariable=self.model_file_3p_var, values=model_files, + state="readonly", width=std_wid * 3 + ) + _combo.grid(row=cur_row, column=1, columnspan=3, **args_entry) + + # Autoplay settings cur_row += 1 sep = ttk.Separator(main_frame, orient=tk.HORIZONTAL) sep.grid(row=cur_row, column=0, columnspan=4, sticky="ew", pady=5) - ### Auto play settings + cur_row += 1 _label = ttk.Label(main_frame, text=self.st.lan().AUTO_PLAY_SETTINGS) - _label.grid(row=cur_row, column=0, **args_label) - # random move + _label.grid(row=cur_row, column=0, **args_label) + self.random_move_var = tk.BooleanVar(value=self.st.auto_random_move) - ran_moves_entry = ttk.Checkbutton( - main_frame, variable=self.random_move_var, text=self.st.lan().MOUSE_RANDOM_MOVE, width=std_wid) - ran_moves_entry.grid(row=cur_row, column=1, **args_entry) - # idle move + _entry = ttk.Checkbutton( + main_frame, variable=self.random_move_var, text=self.st.lan().MOUSE_RANDOM_MOVE, width=std_wid + ) + _entry.grid(row=cur_row, column=1, **args_entry) + self.auto_idle_move_var = tk.BooleanVar(value=self.st.auto_idle_move) - idle_move_entry = ttk.Checkbutton(main_frame, variable=self.auto_idle_move_var, text=self.st.lan().AUTO_IDLE_MOVE, width=std_wid) - idle_move_entry.grid(row=cur_row, column=2, **args_entry) - # drag dahai + _entry = ttk.Checkbutton( + main_frame, variable=self.auto_idle_move_var, text=self.st.lan().AUTO_IDLE_MOVE, width=std_wid + ) + _entry.grid(row=cur_row, column=2, **args_entry) + self.auto_drag_dahai_var = tk.BooleanVar(value=self.st.auto_dahai_drag) - _entry = ttk.Checkbutton(main_frame, variable=self.auto_drag_dahai_var, text=self.st.lan().DRAG_DAHAI, width=std_wid) + _entry = ttk.Checkbutton( + main_frame, variable=self.auto_drag_dahai_var, text=self.st.lan().DRAG_DAHAI, width=std_wid + ) _entry.grid(row=cur_row, column=3, **args_entry) - - # randomize choice - cur_row += 1 + + cur_row += 1 _label = ttk.Label(main_frame, text=self.st.lan().RANDOM_CHOICE) _label.grid(row=cur_row, column=0, **args_label) self.randomized_choice_var = tk.StringVar(value=self.st.ai_randomize_choice) - options = ['0 (Off)',1,2,3,4,5] - random_choice_entry = ttk.Combobox( - main_frame, textvariable=self.randomized_choice_var, values=options, state="readonly", width=std_wid) - random_choice_entry.grid(row=cur_row, column=1, **args_entry) - # reply emoji chance + _combo = ttk.Combobox( + main_frame, + textvariable=self.randomized_choice_var, + values=['0 (Off)', 1, 2, 3, 4, 5], + state="readonly", + width=std_wid + ) + _combo.grid(row=cur_row, column=1, **args_entry) + _label = ttk.Label(main_frame, text=self.st.lan().REPLY_EMOJI_CHANCE) _label.grid(row=cur_row, column=2, **args_label) - options = [f"{i*10}%" for i in range(11)] - options[0] = '0% (off)' + emoji_options = [f"{i*10}%" for i in range(11)] + emoji_options[0] = '0% (off)' self.reply_emoji_var = tk.StringVar(value=f"{int(self.st.auto_reply_emoji_rate*100)}%") _combo = ttk.Combobox( - main_frame, textvariable=self.reply_emoji_var, values=options, state="readonly", width=std_wid) - _combo.grid(row=cur_row, column=3, **args_entry) - - # random delay lower/upper + main_frame, textvariable=self.reply_emoji_var, values=emoji_options, state="readonly", width=std_wid + ) + _combo.grid(row=cur_row, column=3, **args_entry) + cur_row += 1 _label = ttk.Label(main_frame, text=self.st.lan().RANDOM_DELAY_RANGE) _label.grid(row=cur_row, column=0, **args_label) self.delay_random_lower_var = tk.DoubleVar(value=self.st.delay_random_lower) - delay_lower_entry = tk.Entry(main_frame, textvariable= self.delay_random_lower_var,width=std_wid) - delay_lower_entry.grid(row=cur_row, column=1, **args_entry) + _entry = tk.Entry( + main_frame, textvariable=self.delay_random_lower_var, width=std_wid, + bg=GUI_STYLE.card_bg, fg=GUI_STYLE.fg_color, insertbackground=GUI_STYLE.fg_color + ) + _entry.grid(row=cur_row, column=1, **args_entry) self.delay_random_upper_var = tk.DoubleVar(value=self.st.delay_random_upper) - delay_upper_entry = tk.Entry(main_frame, textvariable= self.delay_random_upper_var,width=std_wid) - delay_upper_entry.grid(row=cur_row, column=2, **args_entry) - - # tips :Settings + _entry = tk.Entry( + main_frame, textvariable=self.delay_random_upper_var, width=std_wid, + bg=GUI_STYLE.card_bg, fg=GUI_STYLE.fg_color, insertbackground=GUI_STYLE.fg_color + ) + _entry.grid(row=cur_row, column=2, **args_entry) + cur_row += 1 - label_settings = ttk.Label(main_frame, text=self.st.lan().SETTINGS_TIPS, width=std_wid*4) - label_settings.grid(row=cur_row, column=0, columnspan=4, **args_entry) - - # Buttons frame + _tips = ttk.Label( + main_frame, + text="mac 版仅保留本地模型、窗口大小、网址与出牌设置。", + width=std_wid * 4 + ) + _tips.grid(row=cur_row, column=0, columnspan=4, **args_entry) + + # Buttons button_frame = ttk.Frame(self) button_frame.pack(side=tk.BOTTOM, fill=tk.X) cancel_button = ttk.Button(button_frame, text=self.st.lan().CANCEL, command=self._on_cancel) cancel_button.pack(side=tk.LEFT, padx=20, pady=20) save_button = ttk.Button(button_frame, text=self.st.lan().SAVE, command=self._on_save) save_button.pack(side=tk.RIGHT, padx=20, pady=20) - - - def _on_save(self): - # Get values from entry fields, validate, and save them - # === Process and validate new values === + + def _on_save_mac(self): + """Save handler for macOS minimal settings UI.""" 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() - - # mitm & proxy inject - mitm_port_new = int(self.mitm_port_var.get()) - if not self.st.valid_mitm_port(mitm_port_new): - messagebox.showerror("⚠", self.st.lan().MITM_PORT_ERROR_PROMPT) - return - upstream_proxy_new = self.upstream_proxy_var.get() - proxy_inject_new = self.proxy_inject_var.get() - if upstream_proxy_new != self.st.upstream_proxy or mitm_port_new != self.st.mitm_port or proxy_inject_new != self.st.enable_proxinject: - self.mitm_proxinject_updated = True - - # language - language_name = self.language_var.get() - language_new = None - for code, lan in LAN_OPTIONS.items(): - if language_name == lan.LANGUAGE_NAME: - language_new = code - break - if self.st.language != language_new: - self.gui_need_reload = True - else: - self.gui_need_reload = False - - # models - model_type_new = self.model_type_var.get() + model_file_new = self.model_file_var.get() mode_file_3p_new = self.model_file_3p_var.get() - akagi_url_new = self.akagiot_url_var.get() - akagi_apikey_new = self.akagiot_apikey_var.get() - mjapi_url_new = self.mjapi_url_var.get() - mjapi_user_new = self.mjapi_user_var.get() - mjapi_secret_new = self.mjapi_secret_var.get() - mjapi_model_select_new = self.mjapi_model_select_var.get() - if ( - self.st.model_type != model_type_new or - self.st.model_file != model_file_new or - self.st.model_file_3p != mode_file_3p_new or - self.st.akagi_ot_url != akagi_url_new or - self.st.akagi_ot_apikey != akagi_apikey_new or - self.st.mjapi_url != mjapi_url_new or - self.st.mjapi_user != mjapi_user_new or - self.st.mjapi_secret != mjapi_secret_new or - self.st.mjapi_model_select != mjapi_model_select_new - ): + if self.st.model_file != model_file_new or self.st.model_file_3p != mode_file_3p_new: self.model_updated = True - - # auto play settings - randomized_choice_new:int = int(self.randomized_choice_var.get().split(' ')[0]) - reply_emoji_new:float = int(self.reply_emoji_var.get().split('%')[0])/100 + + randomized_choice_new = int(self.randomized_choice_var.get().split(' ')[0]) + reply_emoji_new = int(self.reply_emoji_var.get().split('%')[0]) / 100 try: delay_lower_new = self.delay_random_lower_var.get() delay_upper_new = self.delay_random_upper_var.get() - except Exception as _e: + except Exception: messagebox.showerror("⚠", self.st.lan().RANDOM_DELAY_RANGE) return - delay_lower_new = max(0,delay_lower_new) + + delay_lower_new = max(0, delay_lower_new) delay_upper_new = max(delay_lower_new, delay_upper_new) - - # === save new values to setting === - self.st.auto_launch_browser = self.auto_launch_var.get() + self.st.browser_width = width_new self.st.browser_height = height_new + self.st.browser_fullscreen = self.browser_fullscreen_var.get() + self.st.browser_high_quality = self.browser_high_quality_var.get() self.st.ms_url = 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 - self.st.language = language_new - self.st.enable_proxinject = proxy_inject_new - - self.st.model_type = model_type_new + + # macOS minimal app behavior + self.st.language = "ZHS" + self.st.model_type = "Local" + self.st.enable_chrome_ext = False + self.st.auto_launch_browser = False + self.st.upstream_proxy = "" + self.st.enable_proxinject = False + self.st.use_real_mouse = False + self.st.model_file = model_file_new self.st.model_file_3p = mode_file_3p_new - self.st.akagi_ot_url = akagi_url_new - self.st.akagi_ot_apikey = akagi_apikey_new - self.st.mjapi_url = mjapi_url_new - self.st.mjapi_user = mjapi_user_new - self.st.mjapi_secret = mjapi_secret_new - self.st.mjapi_model_select = mjapi_model_select_new - + self.st.auto_idle_move = self.auto_idle_move_var.get() self.st.auto_dahai_drag = self.auto_drag_dahai_var.get() self.st.auto_random_move = self.random_move_var.get() self.st.ai_randomize_choice = randomized_choice_new - self.st.auto_reply_emoji_rate = reply_emoji_new + self.st.auto_reply_emoji_rate = reply_emoji_new self.st.delay_random_lower = delay_lower_new self.st.delay_random_upper = delay_upper_new - + + self.gui_need_reload = False + self.mitm_proxinject_updated = False self.st.save_json() self.exit_save = True - if self.mitm_proxinject_updated: - messagebox.showinfo(self.st.lan().SETTINGS, self.st.lan().SETTINGS_TIPS, parent=self, icon='info', type='ok') self.destroy() - - def _on_cancel(self): - LOGGER.info("Closing settings window without saving") - self.exit_save = False - self.destroy() + + def _on_save(self): + if self.is_mac: + self._on_save_mac() + return + # Get values from entry fields, validate, and save them + # === Process and validate new values === + 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() + + # mitm & proxy inject + mitm_port_new = int(self.mitm_port_var.get()) + if not self.st.valid_mitm_port(mitm_port_new): + messagebox.showerror("⚠", self.st.lan().MITM_PORT_ERROR_PROMPT) + return + upstream_proxy_new = self.upstream_proxy_var.get() + proxy_inject_new = self.proxy_inject_var.get() + if upstream_proxy_new != self.st.upstream_proxy or mitm_port_new != self.st.mitm_port or proxy_inject_new != self.st.enable_proxinject: + self.mitm_proxinject_updated = True + + # language + language_name = self.language_var.get() + language_new = None + for code, lan in LAN_OPTIONS.items(): + if language_name == lan.LANGUAGE_NAME: + language_new = code + break + if self.st.language != language_new: + self.gui_need_reload = True + else: + self.gui_need_reload = False + + # models + model_type_new = self.model_type_var.get() + model_file_new = self.model_file_var.get() + mode_file_3p_new = self.model_file_3p_var.get() + akagi_url_new = self.akagiot_url_var.get() + akagi_apikey_new = self.akagiot_apikey_var.get() + mjapi_url_new = self.mjapi_url_var.get() + mjapi_user_new = self.mjapi_user_var.get() + mjapi_secret_new = self.mjapi_secret_var.get() + mjapi_model_select_new = self.mjapi_model_select_var.get() + if ( + self.st.model_type != model_type_new or + self.st.model_file != model_file_new or + self.st.model_file_3p != mode_file_3p_new or + self.st.akagi_ot_url != akagi_url_new or + self.st.akagi_ot_apikey != akagi_apikey_new or + self.st.mjapi_url != mjapi_url_new or + self.st.mjapi_user != mjapi_user_new or + self.st.mjapi_secret != mjapi_secret_new or + self.st.mjapi_model_select != mjapi_model_select_new + ): + self.model_updated = True + + # auto play settings + randomized_choice_new:int = int(self.randomized_choice_var.get().split(' ')[0]) + reply_emoji_new:float = int(self.reply_emoji_var.get().split('%')[0])/100 + try: + delay_lower_new = self.delay_random_lower_var.get() + delay_upper_new = self.delay_random_upper_var.get() + real_mouse_speed_new = self.real_mouse_speed_var.get() + real_mouse_jitter_new = self.real_mouse_jitter_var.get() + real_mouse_click_offset_new = self.real_mouse_click_offset_var.get() + except Exception as _e: + messagebox.showerror("⚠", self.st.lan().RANDOM_DELAY_RANGE) + return + delay_lower_new = max(0,delay_lower_new) + delay_upper_new = max(delay_lower_new, delay_upper_new) + real_mouse_speed_new = max(300.0, min(20000.0, real_mouse_speed_new)) + real_mouse_jitter_new = max(0.0, min(20.0, real_mouse_jitter_new)) + real_mouse_click_offset_new = max(0.0, min(30.0, real_mouse_click_offset_new)) + + # === save new values to setting === + 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.enable_chrome_ext = self.enable_extension_var.get() + self.st.mitm_port = mitm_port_new + self.st.upstream_proxy = upstream_proxy_new + self.st.language = language_new + self.st.enable_proxinject = proxy_inject_new + + self.st.model_type = model_type_new + self.st.model_file = model_file_new + self.st.model_file_3p = mode_file_3p_new + self.st.akagi_ot_url = akagi_url_new + self.st.akagi_ot_apikey = akagi_apikey_new + self.st.mjapi_url = mjapi_url_new + self.st.mjapi_user = mjapi_user_new + self.st.mjapi_secret = mjapi_secret_new + self.st.mjapi_model_select = mjapi_model_select_new + + self.st.auto_idle_move = self.auto_idle_move_var.get() + self.st.auto_dahai_drag = self.auto_drag_dahai_var.get() + self.st.auto_random_move = self.random_move_var.get() + self.st.ai_randomize_choice = randomized_choice_new + self.st.auto_reply_emoji_rate = reply_emoji_new + self.st.delay_random_lower = delay_lower_new + self.st.delay_random_upper = delay_upper_new + self.st.real_mouse_speed_pps = real_mouse_speed_new + self.st.real_mouse_jitter_px = real_mouse_jitter_new + self.st.real_mouse_click_offset_px = real_mouse_click_offset_new + + self.st.save_json() + self.exit_save = True + if self.mitm_proxinject_updated: + messagebox.showinfo(self.st.lan().SETTINGS, self.st.lan().SETTINGS_TIPS, parent=self, icon='info', type='ok') + self.destroy() + + + def _on_cancel(self): + LOGGER.info("Closing settings window without saving") + self.exit_save = False + self.destroy() diff --git a/mortal_mac_v0.2.spec b/mortal_mac_v0.2.spec new file mode 100644 index 0000000..3a27a18 --- /dev/null +++ b/mortal_mac_v0.2.spec @@ -0,0 +1,51 @@ +# -*- mode: python ; coding: utf-8 -*- + + +a = Analysis( + ['main.py'], + pathex=[], + binaries=[], + datas=[], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name='mortal_mac_v0.2', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=['/Users/peite/Documents/MahjongCopilot/resources/icon.icns'], +) +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name='mortal_mac_v0.2', +) +app = BUNDLE( + coll, + name='mortal_mac_v0.2.app', + icon='/Users/peite/Documents/MahjongCopilot/resources/icon.icns', + bundle_identifier=None, +) diff --git a/readme.md b/readme.md index ecf6828..a706416 100644 --- a/readme.md +++ b/readme.md @@ -1,112 +1,153 @@ -# 麻将 Copilot / Mahjong Copilot - -麻将 AI 助手,基于 mjai (Mortal模型) 实现的机器人。会对游戏对局的每一步进行指导。现支持雀魂三人、四人麻将。 - -加入QQ群:1031865144 -
-Click to Join Discord - -Mahjong AI Assistant for Majsoul, based on mjai (Mortal model) bot impelementaion. When you are in a Majsoul game, AI will give you step-by-step guidance. Now supports Majsoul 3-person and 4-person game modes. - -下载、帮助和更多信息请访问网站 Please see website for download, help, and more information -帮助信息 Help Info | https://mjcopilot.com - ---- - -![](assets/shot3_lower.png) - -特性: - -- 对局每一步 AI 指导,可在游戏中覆盖显示 -- 自动打牌,自动加入游戏 -- 多语言支持 -- 支持本地 Mortal 模型和在线模型,支持三麻和四麻 - -Features: - -- Step-by-step AI guidance for the game, with optional in-game overlay. -- Auto play & auto joining next game -- Multi-language support -- Supports Mortal local models and online models, 3p and 4p mahjong modes. - - - -## 使用方法 / Instructions - -### 开发 - -1. 克隆 repo -2. 安装 Python 虚拟环境。Python 版本推荐 3.11. -3. 安装 requirements.txt 中的依赖。 -4. 安装 Playwright + Chromium -5. 主程序入口: main.py - -### To Develope - -1. Clone the repo -2. Install Python virtual environment. Python version 3.11 recommended. -3. Install dependencies from requirements.txt -4. Install Playwright + Chromium -5. Main entry: main.py - +# 麻将 Copilot / Mahjong Copilot + +麻将 AI 助手,基于 mjai (Mortal模型) 实现的机器人。会对游戏对局的每一步进行指导。现支持雀魂三人、四人麻将。 + +加入QQ群:1031865144 +
+Click to Join Discord + +Mahjong AI Assistant for Majsoul, based on mjai (Mortal model) bot impelementaion. When you are in a Majsoul game, AI will give you step-by-step guidance. Now supports Majsoul 3-person and 4-person game modes. + +下载、帮助和更多信息请访问网站 Please see website for download, help, and more information +帮助信息 Help Info | https://mjcopilot.com + +--- + +![](assets/shot3_lower.png) + +特性: + +- 对局每一步 AI 指导,可在游戏中覆盖显示 +- 自动打牌,自动加入游戏 +- 多语言支持 +- 支持本地 Mortal 模型和在线模型,支持三麻和四麻 + +Features: + +- Step-by-step AI guidance for the game, with optional in-game overlay. +- Auto play & auto joining next game +- Multi-language support +- Supports Mortal local models and online models, 3p and 4p mahjong modes. + + + +## 使用方法 / Instructions + +### 开发 + +1. 克隆 repo +2. 安装 Python 虚拟环境。Python 版本推荐 3.11. +3. 安装 requirements.txt 中的依赖。 +4. 安装 Playwright + Chromium +5. 主程序入口: main.py + +### To Develope + +1. Clone the repo +2. Install Python virtual environment. Python version 3.11 recommended. +3. Install dependencies from requirements.txt +4. Install Playwright + Chromium +5. Main entry: main.py + ### 示例脚本 Sample script: ```batch git clone https://github.com/latorc/MahjongCopilot.git -cd MahjongCopilot -python -m venv venv -CALL venv\Scripts\activate.bat -pip install -r requirements.txt -set PLAYWRIGHT_BROWSERS_PATH=0 +cd MahjongCopilot +python -m venv venv +CALL venv\Scripts\activate.bat +pip install -r requirements.txt +set PLAYWRIGHT_BROWSERS_PATH=0 playwright install chromium python main.py ``` -### 配置模型 -本程序支持几种模型来源。其中,本地模型(Local)是基于 Akagi 兼容的 Mortal 模型。要获取 Akagi 的模型,请参见 Akagi Github 的说明。 -### Model Configuration -This program supports different types of AI models. The 'Local' Model type uses Mortal models compatible with Akagi. To acquire Akagi's models, please refer to Akagi Github . +### macOS arm64 极简发布(`mortal_mac_v0.2`) + +仅适用于 Apple Silicon (M1/M2/M3...),并使用极简配置: -## 截图 / Screenshots +- 固定简体中文 +- 仅本地模型(不提供 AkagiOT/MJAPI 配置入口) +- 仅 Playwright 自动化(禁用真实鼠标) +- 隐藏代理相关设置 +- 浏览器支持全屏模式(可配置) +- 浏览器支持高画质模式(默认开启) -界面 / GUI +构建命令: -![](assets/shot1.png) -![](assets/settings.png) +```bash +bash scripts/generate_mac_app.sh +bash scripts/make_dmg_mac.sh +``` -游戏中覆盖显示 (HUD)/ In-game Overlay (HUD) +产物: -![](assets/shot2.png) +- App: `dist/mortal_mac_v0.2.app` +- DMG: `dist/mortal_mac_v0.2.dmg` -![](assets/shot3.png) +更新策略(mac): -## 设计 / Design +- Help 窗口仅检查版本,不执行一键更新 +- 发布新版本后,下载新 DMG 覆盖安装 -![](assets/design_struct.png) +浏览器体验设置(mac): - -目录说明 Description for folders: -* gui: tkinter GUI 相关类 / tkinter GUI related classes -* game: 雀魂游戏相关类 / classes related to Majsoul game -* bot: AI 模型和机器人实现 / implementations for AI models and bots -* common: 共同使用的支持代码 commonly used supporting code -* libriichi & libriichi3p: 编译完成的 libriichi 库文件 / For compiled libriichi libraries +- 全屏模式:设置页可开关,启动浏览器时生效 +- 高画质模式:设置页可开关,默认开启 +- 高画质模式会使用持久化浏览器 profile 缓存,减少资源重复加载 -## 鸣谢 / Credit +若仍出现画面模糊或资源未完整加载,可按以下顺序排查: -- 基于 Mortal 模型和 MJAI 协议 - Based on Mortal Model an MJAI protocol - - Mortal: https://github.com/Equim-chan/Mortal -- 设计和功能实现基于 Akagi - Design and implementation based on Akagi - - Akagi: https://github.com/shinkuan/Akagi -- 参考 Reference - Mahjong Soul API: https://github.com/MahjongRepository/mahjong_soul_api -- MJAI协议参考 / MJAI Protocol Reference - - MJAI: https://mjai.app +1. 确认网络连接与 MITM 证书安装状态正常 +2. 关闭程序后删除 `~/Library/Application Support/mortal_mac_v0.2/browser_data` +3. 重新启动应用并等待页面资源首次完整缓存 -## 许可 / License -本项目使用 GNU GPL v3 许可协议。 -协议全文请见 [LICENSE](LICENSE) +### 配置模型 +本程序支持几种模型来源。其中,本地模型(Local)是基于 Akagi 兼容的 Mortal 模型。要获取 Akagi 的模型,请参见 Akagi Github 的说明。 +### Model Configuration +This program supports different types of AI models. The 'Local' Model type uses Mortal models compatible with Akagi. To acquire Akagi's models, please refer to Akagi Github . + + +## 截图 / Screenshots + +界面 / GUI + +![](assets/shot1.png) +![](assets/settings.png) + +游戏中覆盖显示 (HUD)/ In-game Overlay (HUD) + +![](assets/shot2.png) + +![](assets/shot3.png) + +## 设计 / Design + +![](assets/design_struct.png) + + +目录说明 Description for folders: +* gui: tkinter GUI 相关类 / tkinter GUI related classes +* game: 雀魂游戏相关类 / classes related to Majsoul game +* bot: AI 模型和机器人实现 / implementations for AI models and bots +* common: 共同使用的支持代码 commonly used supporting code +* libriichi & libriichi3p: 编译完成的 libriichi 库文件 / For compiled libriichi libraries + +## 鸣谢 / Credit + +- 基于 Mortal 模型和 MJAI 协议 + Based on Mortal Model an MJAI protocol + + Mortal: https://github.com/Equim-chan/Mortal +- 设计和功能实现基于 Akagi + Design and implementation based on Akagi + + Akagi: https://github.com/shinkuan/Akagi +- 参考 Reference + Mahjong Soul API: https://github.com/MahjongRepository/mahjong_soul_api +- MJAI协议参考 / MJAI Protocol Reference + + MJAI: https://mjai.app + +## 许可 / License +本项目使用 GNU GPL v3 许可协议。 +协议全文请见 [LICENSE](LICENSE) diff --git a/scripts/generate_mac_app.sh b/scripts/generate_mac_app.sh new file mode 100644 index 0000000..5505323 --- /dev/null +++ b/scripts/generate_mac_app.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +set -euo pipefail + +APP_NAME="mortal_mac_v0.2" +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +VENV_DIR="$ROOT/.venv-mac" +PYTHON_BIN="${PYTHON_BIN:-python3.11}" +REQUIRED_PY_MINOR="3.11" +BROWSERS_CACHE_DIR="$ROOT/.playwright-browsers" + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "This script must run on macOS." >&2 + exit 1 +fi + +cd "$ROOT" + +if ! command -v "$PYTHON_BIN" >/dev/null 2>&1; then + echo "Missing required interpreter: $PYTHON_BIN" >&2 + echo "Install Python $REQUIRED_PY_MINOR first, then rerun." + exit 1 +fi + +if ! "$PYTHON_BIN" - <<'PY' >/dev/null 2>&1 +import tkinter +PY +then + if command -v brew >/dev/null 2>&1; then + echo "Python is missing tkinter, installing python-tk@${REQUIRED_PY_MINOR}..." + brew install "python-tk@${REQUIRED_PY_MINOR}" + else + echo "Python is missing tkinter and Homebrew is unavailable." >&2 + echo "Install a tkinter-enabled Python ${REQUIRED_PY_MINOR} first, then rerun." + exit 1 + fi +fi + +RECREATE_VENV=0 +if [[ -x "$VENV_DIR/bin/python" ]]; then + CUR_VER="$("$VENV_DIR/bin/python" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" + if [[ "$CUR_VER" != "$REQUIRED_PY_MINOR" ]]; then + echo "Existing .venv-mac uses Python $CUR_VER, recreating with $REQUIRED_PY_MINOR..." + RECREATE_VENV=1 + fi +fi + +if [[ ! -d "$VENV_DIR" || "$RECREATE_VENV" -eq 1 ]]; then + rm -rf "$VENV_DIR" + "$PYTHON_BIN" -m venv "$VENV_DIR" +fi + +if ! "$VENV_DIR/bin/python" - <<'PY' >/dev/null 2>&1 +import tkinter +PY +then + echo ".venv-mac Python is missing tkinter (_tkinter)." >&2 + echo "Please ensure python-tk@${REQUIRED_PY_MINOR} is installed, then rerun." >&2 + exit 1 +fi + +"$VENV_DIR/bin/python" -m pip install --upgrade pip +"$VENV_DIR/bin/pip" install -r requirements.txt + +bash "$ROOT/scripts/make_icns_mac.sh" || true + +# Keep Playwright browsers out of site-packages; we copy them into app bundle later. +export PLAYWRIGHT_BROWSERS_PATH="$BROWSERS_CACHE_DIR" +"$VENV_DIR/bin/python" -m playwright install chromium + +# Ensure PyInstaller does not pick previously bundled browsers from the package path. +PKG_BROWSERS_DIR="$VENV_DIR/lib/python${REQUIRED_PY_MINOR}/site-packages/playwright/driver/package/.local-browsers" +if [[ -d "$PKG_BROWSERS_DIR" ]]; then + rm -rf "$PKG_BROWSERS_DIR" +fi + +rm -rf dist build + +ICON_PATH="$ROOT/resources/icon.icns" +if [[ ! -f "$ICON_PATH" ]]; then + ICON_PATH="$ROOT/resources/icon.png" +fi + +"$VENV_DIR/bin/python" -m PyInstaller \ + --windowed \ + --noconfirm \ + --name="$APP_NAME" \ + --icon="$ICON_PATH" \ + main.py + +APP_DIR="$ROOT/dist/${APP_NAME}.app" +MACOS_DIR="$APP_DIR/Contents/MacOS" +INTERNAL_DIR="$MACOS_DIR/_internal" + +cp -R "$ROOT/resources" "$MACOS_DIR/" +cp -R "$ROOT/liqi_proto" "$MACOS_DIR/" +cp "$ROOT/version" "$MACOS_DIR/version" + +mkdir -p "$MACOS_DIR/models" +mkdir -p "$MACOS_DIR/chrome_ext" + +MODELS_DST="$MACOS_DIR/models" +copy_models_from_dir() { + local src_dir="$1" + if [[ ! -d "$src_dir" ]]; then + return + fi + local copied=0 + shopt -s nullglob + for src_model in "$src_dir"/*.pth; do + cp -f "$src_model" "$MODELS_DST/" + copied=1 + done + shopt -u nullglob + if [[ "$copied" -eq 1 ]]; then + echo "Bundled models from: $src_dir" + fi +} + +APP_SUPPORT_MODELS="$HOME/Library/Application Support/$APP_NAME/models" +HAS_APP_SUPPORT_MODELS=0 +if [[ -d "$APP_SUPPORT_MODELS" ]]; then + shopt -s nullglob + APP_SUPPORT_PTH=("$APP_SUPPORT_MODELS"/*.pth) + shopt -u nullglob + if [[ ${#APP_SUPPORT_PTH[@]} -gt 0 ]]; then + HAS_APP_SUPPORT_MODELS=1 + fi +fi + +if [[ "$HAS_APP_SUPPORT_MODELS" -eq 1 ]]; then + rm -f "$MODELS_DST"/*.pth 2>/dev/null || true + copy_models_from_dir "$APP_SUPPORT_MODELS" +else + copy_models_from_dir "$ROOT/models" +fi + +BROWSERS_SRC="$BROWSERS_CACHE_DIR" +BROWSERS_DST="$INTERNAL_DIR/playwright/driver/package/ms-playwright" +if [[ -d "$BROWSERS_SRC" ]]; then + mkdir -p "$BROWSERS_DST" + rsync -a --delete "$BROWSERS_SRC"/ "$BROWSERS_DST"/ + # Playwright cache metadata folder is not needed at runtime and breaks codesign --deep. + find "$BROWSERS_DST" -type d -name ".links" -prune -exec rm -rf {} + 2>/dev/null || true +else + echo "WARNING: Playwright browsers not found under $BROWSERS_SRC" +fi + +if command -v codesign >/dev/null 2>&1; then + xattr -cr "$APP_DIR" || true + codesign --force --deep --sign - --timestamp=none "$APP_DIR" + codesign --verify --deep --strict --verbose=2 "$APP_DIR" +fi + +echo "Built app: $APP_DIR" +echo "Run scripts/make_dmg_mac.sh to package DMG." diff --git a/scripts/make_dmg_mac.sh b/scripts/make_dmg_mac.sh new file mode 100644 index 0000000..842e9e7 --- /dev/null +++ b/scripts/make_dmg_mac.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +APP_NAME="mortal_mac_v0.2" +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +APP_PATH="$ROOT/dist/${APP_NAME}.app" +DMG_PATH="$ROOT/dist/${APP_NAME}.dmg" +STAGE_DIR="$ROOT/dist/.dmg-stage-${APP_NAME}" + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "This script must run on macOS." >&2 + exit 1 +fi + +if [[ ! -d "$APP_PATH" ]]; then + echo "App bundle not found: $APP_PATH" >&2 + echo "Run scripts/generate_mac_app.sh first." + exit 1 +fi + +rm -rf "$STAGE_DIR" "$DMG_PATH" +mkdir -p "$STAGE_DIR" + +cp -R "$APP_PATH" "$STAGE_DIR/" +ln -s /Applications "$STAGE_DIR/Applications" + +hdiutil create \ + -volname "$APP_NAME" \ + -srcfolder "$STAGE_DIR" \ + -ov \ + -format UDZO \ + "$DMG_PATH" + +rm -rf "$STAGE_DIR" +echo "Built DMG: $DMG_PATH" diff --git a/scripts/make_icns_mac.sh b/scripts/make_icns_mac.sh new file mode 100644 index 0000000..60602af --- /dev/null +++ b/scripts/make_icns_mac.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SRC_PNG="$ROOT/resources/icon.png" +OUT_ICNS="$ROOT/resources/icon.icns" +ICONSET_DIR="$ROOT/build/icon.iconset" + +if [[ ! -f "$SRC_PNG" ]]; then + echo "Missing source icon: $SRC_PNG" >&2 + exit 1 +fi + +if ! command -v sips >/dev/null 2>&1 || ! command -v iconutil >/dev/null 2>&1; then + echo "sips/iconutil not found. Skipping icon.icns generation." + exit 0 +fi + +rm -rf "$ICONSET_DIR" +mkdir -p "$ICONSET_DIR" + +sips -z 16 16 "$SRC_PNG" --out "$ICONSET_DIR/icon_16x16.png" >/dev/null +sips -z 32 32 "$SRC_PNG" --out "$ICONSET_DIR/icon_16x16@2x.png" >/dev/null +sips -z 32 32 "$SRC_PNG" --out "$ICONSET_DIR/icon_32x32.png" >/dev/null +sips -z 64 64 "$SRC_PNG" --out "$ICONSET_DIR/icon_32x32@2x.png" >/dev/null +sips -z 128 128 "$SRC_PNG" --out "$ICONSET_DIR/icon_128x128.png" >/dev/null +sips -z 256 256 "$SRC_PNG" --out "$ICONSET_DIR/icon_128x128@2x.png" >/dev/null +sips -z 256 256 "$SRC_PNG" --out "$ICONSET_DIR/icon_256x256.png" >/dev/null +sips -z 512 512 "$SRC_PNG" --out "$ICONSET_DIR/icon_256x256@2x.png" >/dev/null +sips -z 512 512 "$SRC_PNG" --out "$ICONSET_DIR/icon_512x512.png" >/dev/null +sips -z 1024 1024 "$SRC_PNG" --out "$ICONSET_DIR/icon_512x512@2x.png" >/dev/null + +iconutil -c icns "$ICONSET_DIR" -o "$OUT_ICNS" +echo "Generated icon: $OUT_ICNS" From fba1f66717806d7cfffb672a645e3cd3fc6365b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A3=B4=E7=89=B9?= Date: Mon, 23 Feb 2026 22:06:13 +0000 Subject: [PATCH 2/2] docs: comment legacy README and add mac/windows change summary --- readme.md | 240 +++++++++++++++++++++++++++++++----------------------- 1 file changed, 137 insertions(+), 103 deletions(-) diff --git a/readme.md b/readme.md index a706416..32d13ea 100644 --- a/readme.md +++ b/readme.md @@ -1,62 +1,63 @@ -# 麻将 Copilot / Mahjong Copilot - -麻将 AI 助手,基于 mjai (Mortal模型) 实现的机器人。会对游戏对局的每一步进行指导。现支持雀魂三人、四人麻将。 - -加入QQ群:1031865144 -
-Click to Join Discord - -Mahjong AI Assistant for Majsoul, based on mjai (Mortal model) bot impelementaion. When you are in a Majsoul game, AI will give you step-by-step guidance. Now supports Majsoul 3-person and 4-person game modes. - -下载、帮助和更多信息请访问网站 Please see website for download, help, and more information -帮助信息 Help Info | https://mjcopilot.com - ---- - -![](assets/shot3_lower.png) - -特性: - -- 对局每一步 AI 指导,可在游戏中覆盖显示 -- 自动打牌,自动加入游戏 -- 多语言支持 -- 支持本地 Mortal 模型和在线模型,支持三麻和四麻 - -Features: - -- Step-by-step AI guidance for the game, with optional in-game overlay. -- Auto play & auto joining next game -- Multi-language support -- Supports Mortal local models and online models, 3p and 4p mahjong modes. - - - -## 使用方法 / Instructions - -### 开发 - -1. 克隆 repo -2. 安装 Python 虚拟环境。Python 版本推荐 3.11. -3. 安装 requirements.txt 中的依赖。 -4. 安装 Playwright + Chromium -5. 主程序入口: main.py - -### To Develope - -1. Clone the repo -2. Install Python virtual environment. Python version 3.11 recommended. -3. Install dependencies from requirements.txt -4. Install Playwright + Chromium -5. Main entry: main.py - + + +# MahjongCopilot 开发改动总结(本次) + +## 1. mac 版本主线(`mortal_mac_v0.2`) + +- 完成 mac 极简版方向:设置页只保留本地模型、窗口尺寸、Majsoul URL、自动打牌参数。 +- mac 固定策略:简体中文、仅 Local 模型、禁用真实鼠标、隐藏代理/远程模型等复杂入口。 +- 应用命名统一为 `mortal_mac_v0.2`(窗口标题、`.app`、`.dmg`)。 +- 打包链路补齐:`scripts/generate_mac_app.sh` + `scripts/make_dmg_mac.sh`,产出 `dist/mortal_mac_v0.2.app` 与 `dist/mortal_mac_v0.2.dmg`。 +- 处理了 mac 启动与分发问题:资源拷贝后重新签名(ad-hoc)避免 Finder 拦截。 +- 模型分发改造:把常用模型放进 `.app`,并加首次启动自动拷贝到可写目录的逻辑。 +- 浏览器体验增强(mac): + - 新增全屏模式开关(启动生效)。 + - 新增高画质模式开关(默认开启)。 + - 高画质使用持久 profile 缓存,减少重复加载。 +- 修复全屏相关稳定性问题: + - 避免 `new_page()` 打断全屏窗口状态。 + - 全屏模式使用运行时窗口尺寸,不再和固定 viewport 冲突。 + - 导航阶段临时异常(如 `Execution context was destroyed`)按非致命处理,避免误退出。 + - 全屏下增加坐标映射层,自动打牌点击按 16:9 游戏区自适配,减少分辨率偏移。 +- mac 版本中移除 Help/打开日志入口,界面进一步简化。 + +## 2. Windows 功能补充(模拟鼠标) + +- Windows 分支保留并增强“模拟/真实鼠标”相关能力。 +- 增加真实鼠标执行参数与控制项(如移动速度、抖动、点击偏移等)用于降低行为机械感。 +- 自动化执行链路按平台分流:Windows 可走鼠标模拟路径,mac 固定 Playwright 路径。 + +## 3. 当前交付形态 + +- 主要交付目标是 mac 版可分发开发(`mortal_mac_v0.2`)与运行稳定性。 +- 同时保留 Windows 的模拟鼠标能力作为差异化功能,不影响 mac 极简策略。