From 0ea9c2f2ccf7d8350a334262941b5863d1b199e8 Mon Sep 17 00:00:00 2001 From: AthenaHibou <13084046220@163.com> Date: Tue, 1 Sep 2026 15:36:21 +0800 Subject: [PATCH 01/11] =?UTF-8?q?feat(ok-nte):=20MAS=20=E7=BB=8F=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E5=99=A8=E6=8B=89=E8=B5=B7=E5=BC=82=E7=8E=AF=E6=B8=B8?= =?UTF-8?q?=E6=88=8F=E5=B9=B6=E6=94=AF=E6=8C=81=E5=90=AF=E5=8A=A8=E5=89=8D?= =?UTF-8?q?=E5=BC=BA=E5=88=B6=E5=88=87=E6=8D=A2=E7=99=BB=E5=BD=95=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 游戏启动改由 MAS 拉起 NTELauncher 启动器并 OCR 点击「开始游戏」(替代直启 HTGame.exe 避免卡界面),兼容启动器「更新」流程、旧客户端路径与三区服启动器; 游戏结束改为按 HTGame.exe 进程名清理 - 新增「运行前强制切换账号」(依赖游戏配置启用),游戏启动后按用户手机号后 4 位匹配登录面板掩码账号完成切换,交互与失败诊断留痕对齐 OK-WW 强制切号 - 修复 OK-NTE AutoProxy 缺失 Notify 导入导致任务异常通知静默失效的问题 --- app/models/config.py | 6 + app/models/schema.py | 7 +- app/task/OkNte/AutoProxy.py | 180 +++++- app/task/OkNte/tools/account_switch.py | 611 ++++++++++++++++++ app/task/OkNte/tools/launcher_start.py | 377 +++++++++++ frontend/src/api/models/OkNteConfig_Game.ts | 6 +- frontend/src/i18n/locales/en-US.ts | 4 +- frontend/src/i18n/locales/ja-JP.ts | 4 +- frontend/src/i18n/locales/zh-CN.ts | 3 +- .../views/EditView/Script/OkNteScriptEdit.vue | 89 ++- res/version.json | 3 + 11 files changed, 1251 insertions(+), 39 deletions(-) create mode 100644 app/task/OkNte/tools/account_switch.py create mode 100644 app/task/OkNte/tools/launcher_start.py diff --git a/app/models/config.py b/app/models/config.py index 07fbce719..8c1ea8fe0 100644 --- a/app/models/config.py +++ b/app/models/config.py @@ -3689,6 +3689,8 @@ def __init__(self) -> None: self.Game_Type = ConfigItem( "Game", "Type", "Client", OptionsValidator(["Client", "URL"]) ) + # 异环直启 HTGame.exe 会卡界面,此路径为启动器 exe(NTELauncher/NTEGame.exe), + # 旧值为 HTGame.exe 时运行时自动反推同安装根下的启动器 self.Game_Path = ConfigItem("Game", "Path", "", FileValidator()) self.Game_URL = ConfigItem("Game", "URL", "") self.Game_ProcessName = ConfigItem("Game", "ProcessName", "") @@ -3700,6 +3702,10 @@ def __init__(self) -> None: self.Game_CloseOnFinish = ConfigItem( "Game", "CloseOnFinish", True, BoolValidator() ) + ## 运行前强制切换账号(依赖游戏配置启用;用户未填手机号时不切换) + self.Game_AccountSwitch = ConfigItem( + "Game", "AccountSwitch", False, BoolValidator() + ) ## Run ------------------------------------------------------------- self.Run_ProxyTimesLimit = ConfigItem( diff --git a/app/models/schema.py b/app/models/schema.py index d57049c49..6d0a727f3 100644 --- a/app/models/schema.py +++ b/app/models/schema.py @@ -1072,7 +1072,9 @@ class OkNteConfig_Game(BaseModel): Type: Optional[Literal["Client", "URL"]] = Field( default=None, description="类型: PC端, URL协议" ) - Path: Optional[str] = Field(default=None, description="游戏程序路径") + Path: Optional[str] = Field( + default=None, description="游戏启动器路径(NTELauncher/NTEGame.exe,直启 HTGame.exe 会卡界面)" + ) URL: Optional[str] = Field(default=None, description="自定义协议URL") ProcessName: Optional[str] = Field(default=None, description="游戏进程名称") Arguments: Optional[str] = Field(default=None, description="游戏启动参数") @@ -1086,6 +1088,9 @@ class OkNteConfig_Game(BaseModel): CloseOnFinish: Optional[bool] = Field( default=None, description="任务结束后是否关闭游戏" ) + AccountSwitch: Optional[bool] = Field( + default=None, description="运行前强制切换账号(需启用游戏配置;用户未填手机号时不切换)" + ) class OkNteConfig_Run(GeneralConfig_Run): diff --git a/app/task/OkNte/AutoProxy.py b/app/task/OkNte/AutoProxy.py index 92fa24410..972705fc5 100644 --- a/app/task/OkNte/AutoProxy.py +++ b/app/task/OkNte/AutoProxy.py @@ -28,6 +28,8 @@ from datetime import datetime from pathlib import Path +import psutil + from app.core import Config from app.core.ws import Publisher, protocol from app.log_box import LogType, log_box @@ -35,7 +37,7 @@ from app.models.ConfigBase import MultipleConfig from app.models.schema import WSTaskNoticeData from app.models.task import LogRecord, ScriptItem, TaskExecuteBase, UserItem -from app.services import System +from app.services import Notify, System from app.task.general.tools import execute_script_task from app.utils import ( ProcessInfo, @@ -59,12 +61,18 @@ ) from .push_log import OKNTE_PUSH_RULES, oknte_resolve from .tools import push_notification +from .tools.account_switch import async_switch_account +from .tools.launcher_start import async_start_game_via_launcher logger = get_logger("OK-NTE 自动代理") # 异环 PC 客户端进程名固定,MAS 接管启动前据此避免重复拉起 _NTE_CLIENT_PROCESS = "HTGame.exe" -_NTE_LAUNCHER_RELATIVE_PATH = Path("Neverness To Everness/NTELauncher/NTEGame.exe") +# 异环必须经启动器拉起(直启 HTGame.exe 会卡界面):启动器相对目录与候选 exe +# (国服/国际/台服,对齐 ok-nte 上游) +_NTE_LAUNCHER_DIR = Path("Neverness To Everness/NTELauncher") +_NTE_LAUNCHER_EXES = ("NTEGame.exe", "NTEGlobalGame.exe", "NTETWGame.exe") +_NTE_LAUNCHER_EXES_CASEFOLD = {exe.casefold() for exe in _NTE_LAUNCHER_EXES} # 多用户切换时等待旧游戏完全退出的上限(秒): # 异环客户端「自退」不是瞬时的(ok-nte `-e` 退出约 70 秒),若不等待完全退出, @@ -81,13 +89,16 @@ def _load_nte_launcher_path(config_path: Path) -> Path | None: return None launcher_path = Path(str(config.get("Launcher Path") or "").strip()) - expected_parts = tuple( - part.casefold() for part in _NTE_LAUNCHER_RELATIVE_PATH.parts - ) - actual_parts = tuple( - part.casefold() for part in launcher_path.parts[-len(expected_parts) :] + if not launcher_path.is_absolute(): + return None + expected_dirs = tuple(part.casefold() for part in _NTE_LAUNCHER_DIR.parts) + actual_dirs = tuple( + part.casefold() for part in launcher_path.parts[-len(expected_dirs) - 1 : -1] ) - if not launcher_path.is_absolute() or actual_parts != expected_parts: + if ( + actual_dirs != expected_dirs + or launcher_path.name.casefold() not in _NTE_LAUNCHER_EXES_CASEFOLD + ): return None return launcher_path @@ -270,7 +281,7 @@ async def check(self) -> str: and self.script_config.get("Game", "Type") == "Client" and not Path(self.script_config.get("Game", "Path")).is_file() ): - return "请设置异环游戏路径" + return "请设置异环启动器路径" if ( self.script_config.get("Game", "Enabled") and self.script_config.get("Game", "Type") == "URL" @@ -382,8 +393,7 @@ async def prepare(self): self.oknte_args.append("-e") self.oknte_args.extend(extra_args) - # 游戏配置(对齐通用脚本逻辑) - self.game_path = Path(self.script_config.get("Game", "Path")) + # 游戏配置(对齐通用脚本逻辑);启动器路径在启动时经 _resolve_launcher_path 解析 self.game_url = self.script_config.get("Game", "URL") self.game_process_name = self.script_config.get("Game", "ProcessName") self.script_config_path = Path(self.script_config.get("Script", "ConfigPath")) @@ -511,6 +521,26 @@ async def _log_game_config_summary(self) -> None: self.script_info.log = "\n".join(self._game_config_summary_lines()) await asyncio.sleep(0) + def _resolve_launcher_path(self) -> Path | None: + """解析异环启动器路径(异环直启 HTGame.exe 会卡界面,必须经启动器)。 + + Game.Path 优先:新语义直接选启动器 exe;旧值是 HTGame.exe 时按 + <安装根>\\Client\\... 反推 <安装根>\\NTELauncher\\<启动器>。都没有时 + 回退 ok-nte 自己的 LauncherTask.json(其注册表回退由 ok-nte 维护)。 + """ + game_path = Path(self.script_config.get("Game", "Path")) + if game_path.is_file(): + if game_path.name.casefold() in _NTE_LAUNCHER_EXES_CASEFOLD: + return game_path + for ancestor in game_path.parents: + if ancestor.name.casefold() == "client": + for exe in _NTE_LAUNCHER_EXES: + candidate = ancestor.parent / "NTELauncher" / exe + if candidate.is_file(): + return candidate + break + return _load_nte_launcher_path(self.script_config_path) + async def _mas_launch_game_before_task(self) -> None: """MAS 接管启动游戏,并将各步骤写入调度台日志。""" @@ -526,13 +556,29 @@ async def _mas_launch_game_before_task(self) -> None: await self._push_dispatch_log("检测到客户端已在运行,跳过启动") return - await self._push_dispatch_log("未检测到运行中的客户端,正在拉起游戏...") - await self.game_manager.open_process( - self.game_path, - *_split_args(self.script_config.get("Game", "Arguments")), + await self._push_dispatch_log("未检测到运行中的客户端,正在拉起启动器...") + launcher_path = self._resolve_launcher_path() + if launcher_path is None: + raise RuntimeError( + "未找到异环启动器路径,请重新选择游戏目录以定位 NTELauncher 启动器" + ) + await self.game_manager.open_process(launcher_path) + await self._push_dispatch_log("启动器已拉起,正在等待点击「开始游戏」...") + # 启动器交互在后台线程内同步执行,on_log 契约是同步回调; + # _push_dispatch_log 是 async 方法,须经 run_coroutine_threadsafe + # 调度回事件循环(与账号切换的 _push_switch_log 同理)。 + launch_loop = asyncio.get_running_loop() + + def _push_launch_log(line: str) -> None: + asyncio.run_coroutine_threadsafe( + self._push_dispatch_log(line), launch_loop + ) + + await async_start_game_via_launcher( + launcher_path, on_log=_push_launch_log ) wait_time = int(self.script_config.get("Game", "WaitTime")) - await self._push_dispatch_log(f"正在等待游戏完成启动({wait_time}s)...") + await self._push_dispatch_log(f"游戏窗口已出现,正在等待游戏完成启动({wait_time}s)...") await asyncio.sleep(wait_time) await self._push_dispatch_log("游戏启动完成") return @@ -559,6 +605,46 @@ async def _mas_launch_game_before_task(self) -> None: await self._push_dispatch_log("游戏启动指令已发送") return + async def handle_pre_oknte_error( + self, error_message: str, e: Exception | None = None + ) -> None: + """游戏启动 / 账号切换等前置步骤失败的统一处理(对齐 OK-WW)。""" + + if e is None: + logger.warning(f"用户: {self.cur_user_uid} - {error_message}") + await Publisher.send( + id=self.task_info.task_id, + type=protocol.TASK_NOTICE, + data=WSTaskNoticeData(level="error", message=error_message), + ) + else: + logger.opt(exception=True).warning( + f"用户: {self.cur_user_uid} - {error_message}: {e}" + ) + await Publisher.send( + id=self.task_info.task_id, + type=protocol.TASK_NOTICE, + data=WSTaskNoticeData( + level="error", message=f"{error_message}: {e}" + ), + ) + self.cur_user_log.content = [f"{error_message}, 无日志记录"] + self.cur_user_log.status = error_message + + await self.kill_managed_process( + kill_game=self._mas_should_close_game_on_retry() + ) + + try: + await Notify.push_plyer( + "OK-NTE 自动代理出现异常!", + f"用户 {self.cur_user_item.name} 自动代理时{error_message}", + f"{self.cur_user_item.name}的自动代理出现异常", + 3, + ) + except Exception: + pass + async def main_task(self): await self.prepare() await self._reset_daily_proxy_count() @@ -625,6 +711,51 @@ async def main_task(self): self.cur_user_item.status = "异常" continue + # 游戏启动成功后、下发脚本配置前,按「运行前强制切换账号」开关切号。 + # 开关在游戏配置区,依赖 Game.Enabled(未启用游戏配置则不生效); + # 用户未填写手机号时跳过不切换。 + if ( + self.script_config.get("Game", "AccountSwitch") + and self.script_config.get("Game", "Enabled") + and self.game_manager is not None + ): + account_id = (self.cur_user_config.get("Info", "Id") or "").strip() + if not account_id: + await self._push_dispatch_log( + "未配置账号,跳过账号切换" + ) + else: + try: + await self._push_dispatch_log( + "正在强制切换异环登录账号..." + ) + # 账号切换在后台线程内同步执行,on_log 契约是同步回调; + # _push_dispatch_log 是 async 方法,须经 run_coroutine_threadsafe + # 调度回事件循环,否则进度不会推送到调度台且产生未等待协程告警。 + switch_loop = asyncio.get_running_loop() + + def _push_switch_log(line: str) -> None: + asyncio.run_coroutine_threadsafe( + self._push_dispatch_log(line), switch_loop + ) + + await async_switch_account( + account_id, on_log=_push_switch_log + ) + await self._push_dispatch_log( + f"异环账号切换成功:****{account_id[-4:]}" + ) + except Exception as e: + await self.handle_pre_oknte_error("异环账号切换失败", e) + if i + 1 < run_limit: + await self._push_dispatch_log( + f"异环账号切换失败,将在稍后重试 ({i + 1}/{run_limit})" + ) + await asyncio.sleep(10) + else: + self.cur_user_item.status = "异常" + continue + await self.set_oknte() await self._push_dispatch_log( f"启动 OK-NTE: -t {self.task_index}" @@ -966,9 +1097,20 @@ async def _kill_game_process(self) -> None: if isinstance(self.game_manager, ProcessManager): await self.game_manager.kill() if game_type == "Client": - gp = self.game_path - if gp.is_file(): - await System.kill_process(gp) + # Game.Path 是启动器,游戏本体按进程名结束;进程管理器只跟踪 + # 启动器,HTGame.exe 由启动器拉起、可能不在其进程树内 + for process in psutil.process_iter(["name"]): + try: + if process.info["name"] != _NTE_CLIENT_PROCESS: + continue + except psutil.Error: + continue + try: + await System.kill_process_by_pid(process.pid) + except Exception as e: + logger.opt(exception=True).warning( + f"结束异环游戏进程失败 PID: {process.pid}, {e}" + ) except Exception as e: logger.opt(exception=True).warning(f"关闭游戏进程失败: {e}") diff --git a/app/task/OkNte/tools/account_switch.py b/app/task/OkNte/tools/account_switch.py new file mode 100644 index 000000000..e22b0ef03 --- /dev/null +++ b/app/task/OkNte/tools/account_switch.py @@ -0,0 +1,611 @@ +# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software +# Copyright © 2025-2026 AUTO-MAS Team +# +# This file is part of AUTO-MAS. +# +# AUTO-MAS is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of +# the License, or (at your option) any later version. +# +# AUTO-MAS is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty +# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See +# the GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with AUTO-MAS. If not, see . + +"""OK-NTE(异环)强制账号切换。 + +参照 OK-WW 强制切号骨架重写(前台 pyautogui + DPI 适配 + 1080p 帧坐标空间, +OCR 复用通用工具集 `app.tools.ocr`)。异环部分界面元素没有文本可识别, +无文本元素按 1080p 帧相对坐标点击。 + +流程:: + + 标题界面点右上角退出图标位置(相对坐标,双入口)→ 已登录:确认弹窗 + 点「确认」;未登录:直接打开登录面板 → HOTTA STUDIO 登录面板 + → 点账号卡片展开列表 → 按手机号后 4 位选择目标账号(超出可见范围时 + 滚轮翻页)→ 点「登 录」→ 等待登录面板消失(登录成功) + +退出图标位置的点击是双入口:已登录时弹出「是否退出当前账号」确认弹窗, +未登录时该点击直接打开登录面板,按点击后弹出的界面分流。 +""" + +import asyncio +import ctypes +import inspect +import re +import time +from collections.abc import Callable +from contextlib import contextmanager +from datetime import datetime +from functools import lru_cache +from pathlib import Path + +import cv2 +import numpy as np +import psutil +from PIL import Image + +from app.tools.ocr import Box, OCRItem, ocr_image +from app.utils import get_logger +from app.utils.platform import IS_WINDOWS + +if IS_WINDOWS: + # pyautogui 与 pywin32 仅 Windows 可用(无图形会话导入即失败),随入口的 + # IS_WINDOWS 检查一并惰性导入,避免非 Windows 环境在未启用切号时导入崩溃 + import pyautogui + import win32api + import win32con + import win32gui + import win32process + +logger = get_logger("OK-NTE 账号切换") + +# 诊断文件(debug/oknte-account-switch/switch-detail-*.log):记录切换过程各步骤 +# OCR 文本,供用户反馈登录失败时对照定位识别漂移;切换串行独占前台,单例写入。 +_DIAGNOSTIC_PATH: Path | None = None + + +def _write_diagnostic(text: str) -> None: + """向诊断文件追加一行(旁路,失败时静默忽略)。""" + if _DIAGNOSTIC_PATH is not None: + try: + with _DIAGNOSTIC_PATH.open("a", encoding="utf-8") as handle: + handle.write(text) + except OSError: + pass + +# ── 异环客户端窗口识别(与 OkNte/AutoProxy 的 _NTE_CLIENT_PROCESS 一致)── +_NTE_CLIENT_PROCESS = "HTGame.exe" + +# 截图基准分辨率(16:9),OCR 与点击均在此坐标空间计算后再映射回真实窗口 +_FRAME_WIDTH = 1920 +_FRAME_HEIGHT = 1080 + +# 标题界面右上角退出图标(无文本可 OCR,按 2048x1152 参考截图换算 1080p 坐标) +_LOGOUT_ICON_POINT = (1865, 342) +# 账号列表滚动中心(HOTTA STUDIO 面板中部),目标账号超出可见范围时滚轮翻页 +_LIST_SCROLL_POINT = (960, 600) + +# 仅标题界面出现的文本(底部居中按钮),用于判定「处于标题界面」 +_TITLE_TEXTS = ("进入游戏",) +# 仅登录面板出现的文本,用于判定「登录面板已打开 / 登录成功后面板消失」 +_PANEL_TEXTS = ("使用其他方式登录",) + +# 掩码账号形如 130*****6220 +_MASKED_ACCOUNT = re.compile(r"\d{3}\*+\d{4}") +_MASKED_SUFFIX = re.compile(r"\d+\*+(\d{4})") + + +@lru_cache(maxsize=1) +def _user32_dpi_api(): + user32 = ctypes.windll.user32 + user32.SetThreadDpiAwarenessContext.argtypes = [ctypes.c_void_p] + user32.SetThreadDpiAwarenessContext.restype = ctypes.c_void_p + return user32 + + +@contextmanager +def _per_monitor_dpi(): + """切换到 per-monitor DPI 感知,保证窗口坐标换算在跨 DPI 显示器下正确。""" + user32 = _user32_dpi_api() + previous = user32.SetThreadDpiAwarenessContext(ctypes.c_void_p(-4)) + try: + yield + finally: + if previous: + user32.SetThreadDpiAwarenessContext(previous) + + +# ── 窗口定位 ──────────────────────────────────────────────────────────── + + +def _process_name(pid: int) -> str | None: + """按 pid 读取进程名;提权进程可能被拒,返回 None 而非抛错。""" + try: + return psutil.Process(pid).name() + except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): + return None + + +def _window_area(hwnd: int) -> int: + try: + left, top, right, bottom = win32gui.GetWindowRect(hwnd) + return (right - left) * (bottom - top) + except Exception: + return 0 + + +def _find_game_hwnd() -> int: + """按所属进程名定位异环主窗口。 + + 异环窗口类未实测稳定,故只用 ``EnumWindows + psutil.Process(pid).name()`` + 过滤 HTGame.exe 的可见窗口(规避提权进程 name 读取被拒导致的漏判), + 同进程存在多个窗口时取面积最大者。 + """ + candidates: list[int] = [] + + def _enum(hwnd: int, _lparam: int) -> bool: + try: + if not win32gui.IsWindowVisible(hwnd): + return True + except Exception: + return True + _, pid = win32process.GetWindowThreadProcessId(hwnd) + if pid and _process_name(pid) == _NTE_CLIENT_PROCESS: + candidates.append(hwnd) + return True + + win32gui.EnumWindows(_enum, 0) + if not candidates: + raise RuntimeError( + f"未找到异环游戏窗口(进程 {_NTE_CLIENT_PROCESS})" + ) + return max(candidates, key=_window_area) + + +# ── 截图 / 交互(前台 pyautogui + DPI 适配)───────────────────────────── + + +def _activate_window(hwnd: int) -> None: + if not win32gui.IsWindow(hwnd): + raise RuntimeError("异环游戏窗口已失效") + show_command = ( + win32con.SW_RESTORE + if win32gui.IsIconic(hwnd) + else win32con.SW_SHOW if not win32gui.IsWindowVisible(hwnd) else None + ) + if show_command is not None: + win32gui.ShowWindow(hwnd, show_command) + time.sleep(0.15) + try: + if win32gui.GetForegroundWindow() != hwnd: + # Windows 前台锁:后台进程不能直接抢占前台。先附着当前前台窗口线程 + # 的输入队列,再置前,绕过系统限制(与 OK-WW 切号同理)。 + foreground = win32gui.GetForegroundWindow() + fg_thread = win32process.GetWindowThreadProcessId(foreground)[0] + win32process.AttachThreadInput( + win32api.GetCurrentThreadId(), fg_thread, True + ) + try: + win32gui.BringWindowToTop(hwnd) + win32gui.SetForegroundWindow(hwnd) + finally: + win32process.AttachThreadInput( + win32api.GetCurrentThreadId(), fg_thread, False + ) + else: + win32gui.BringWindowToTop(hwnd) + win32gui.SetForegroundWindow(hwnd) + except win32gui.error: + logger.debug("异环游戏窗口焦点请求被系统忽略,继续按前置窗口处理") + time.sleep(0.1) + + +def _client_size(hwnd: int) -> tuple[int, int]: + _, _, width, height = win32gui.GetClientRect(hwnd) + if width <= 0 or height <= 0: + raise RuntimeError("异环游戏窗口尺寸异常") + if abs(width / height - 16 / 9) > 0.02: + logger.warning( + f"异环窗口非 16:9({width}x{height}),账号切换坐标可能偏移" + ) + return width, height + + +def _capture_window_image(hwnd: int, *, activate: bool = True) -> Image.Image: + with _per_monitor_dpi(): + if activate: + _activate_window(hwnd) + width, height = _client_size(hwnd) + left, top = win32gui.ClientToScreen(hwnd, (0, 0)) + virtual_left = win32api.GetSystemMetrics(win32con.SM_XVIRTUALSCREEN) + virtual_top = win32api.GetSystemMetrics(win32con.SM_YVIRTUALSCREEN) + return pyautogui.screenshot(allScreens=True).crop( + ( + left - virtual_left, + top - virtual_top, + left - virtual_left + width, + top - virtual_top + height, + ) + ) + + +def _capture_window(hwnd: int, *, activate: bool = True) -> np.ndarray: + screenshot = _capture_window_image(hwnd, activate=activate) + screenshot = screenshot.resize( + (_FRAME_WIDTH, _FRAME_HEIGHT), Image.Resampling.LANCZOS + ) + return cv2.cvtColor(np.asarray(screenshot), cv2.COLOR_RGB2BGR) + + +def _dump_ocr_items(items: list[OCRItem]) -> None: + """诊断旁路:把一次 OCR 的全部识别文本写入诊断文件(标注调用函数)。""" + if _DIAGNOSTIC_PATH is None: + return + caller = "" + frame = inspect.currentframe() + if frame is not None and frame.f_back is not None: + caller = frame.f_back.f_code.co_name + _write_diagnostic(f"\n[{datetime.now():%H:%M:%S}] OCR[{caller}] {len(items)} 条:\n") + for text, box in items: + x, y, w, h = box + _write_diagnostic(f" ({x:4},{y:4} {w:3}x{h:3}) {text}\n") + + +def _read_texts(hwnd: int, roi: Box | None = None) -> list[OCRItem]: + frame = _capture_window(hwnd, activate=False) + items = ocr_image(frame, roi) + _dump_ocr_items(items) + return items + + +def _click_box( + hwnd: int, box: Box, *, activate: bool = False, after_sleep: float = 0.3 +) -> None: + """点击 1080p 坐标空间中的一个文字框中心。""" + with _per_monitor_dpi(): + if activate: + _activate_window(hwnd) + width, height = _client_size(hwnd) + x, y, box_width, box_height = box + client_x = round((x + box_width / 2) * width / _FRAME_WIDTH) + client_y = round((y + box_height / 2) * height / _FRAME_HEIGHT) + screen_x, screen_y = win32gui.ClientToScreen(hwnd, (client_x, client_y)) + + original_position = pyautogui.position() + try: + pyautogui.moveTo(screen_x, screen_y) + time.sleep(0.3) + pyautogui.click() + time.sleep(after_sleep) + finally: + pyautogui.moveTo(*original_position) + + +def _click_point( + hwnd: int, px: int, py: int, *, after_sleep: float = 0.3 +) -> None: + _click_box(hwnd, (px, py, 1, 1), after_sleep=after_sleep) + + +# ── OCR 文本判定辅助 ───────────────────────────────────────────────────── + + +def _find_text(items: list[OCRItem], keywords: tuple[str, ...]) -> Box | None: + for text, box in items: + if any(keyword in text for keyword in keywords): + return box + return None + + +def _wait_ocr_text( + hwnd: int, + keywords: tuple[str, ...], + *, + roi: Box | None = None, + timeout: int, +) -> Box | None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + box = _find_text(_read_texts(hwnd, roi), keywords) + if box is not None: + return box + time.sleep(1) + return None + + +def _on_login_panel(hwnd: int) -> bool: + """登录面板是否已打开(面板内独有文本「使用其他方式登录」)。""" + return _find_text(_read_texts(hwnd), _PANEL_TEXTS) is not None + + +def _find_confirm_box(items: list[OCRItem]) -> Box | None: + """在 OCR 条目中定位退出确认弹窗的「确认」按钮。 + + 弹窗说明文本「是否确认退出当前账号?」含「确认」子串,必须排除; + 按钮文本恰为「确认」,与左侧「取消」区分。 + """ + candidates = [(text, box) for text, box in items if "确认" in text] + if not candidates: + return None + # 精确等于按钮文本 + for text, box in candidates: + if text == "确认": + logger.info("OK-NTE 确认按钮精确命中「确认」") + return box + # 排除说明文本(含问号 / 「退出」)后取含「确认」的候选,多个命中取最右 + # (确认按钮在弹窗右侧) + filtered = [ + (t, b) + for t, b in candidates + if "?" not in t and "?" not in t and "退出" not in t and "取消" not in t + ] + if filtered: + text, box = max(filtered, key=lambda item: item[1][0] + item[1][2]) + logger.info(f"OK-NTE 确认按钮命中候选文本: {text}") + return box + logger.info(f"确认候选均为说明文本: {[t for t, _ in candidates]}") + return None + + +def _find_login_button(items: list[OCRItem]) -> Box | None: + """在 OCR 条目中定位登录面板的「登 录」按钮。 + + OCR 文本已去空白,按钮文本归一化后恰为「登录」;面板下方还有 + 「使用其他方式登录」,须排除。 + """ + candidates = [(text, box) for text, box in items if "登录" in text] + if not candidates: + return None + for text, box in candidates: + if text == "登录": + logger.info("OK-NTE 登录按钮精确命中「登录」") + return box + filtered = [(t, b) for t, b in candidates if "其他方式" not in t] + if filtered: + text, box = max(filtered, key=lambda item: item[1][0] + item[1][2]) + logger.info(f"OK-NTE 登录按钮命中候选文本: {text}") + return box + logger.info(f"登录候选均为干扰文本: {[t for t, _ in candidates]}") + return None + + +def _open_account_panel(hwnd: int, on_log: Callable[[str], None]) -> None: + """标题界面 → 点右上角退出图标位置 → 按弹出的界面分流。 + + 该位置点击是双入口:已登录时此点击弹出「是否退出当前账号」确认弹窗, + 点「确认」后登录面板打开;未登录时此点击直接打开登录面板。 + """ + on_log("正在点击标题界面右上角退出图标位置") + _click_point(hwnd, *_LOGOUT_ICON_POINT, after_sleep=1.5) + + # 已登录:等待退出确认弹窗;未登录:登录面板直接打开 + confirm: Box | None = None + deadline = time.monotonic() + 15 + while time.monotonic() < deadline: + items = _read_texts(hwnd) + confirm = _find_confirm_box(items) + if confirm is not None: + break + if _find_text(items, _PANEL_TEXTS) is not None: + on_log("登录面板已打开(当前未登录)") + return + time.sleep(1) + + if confirm is None: + raise RuntimeError( + "点击退出图标位置后未出现退出确认或登录面板,请人工确认当前处于标题界面" + ) + on_log("检测到已登录,点击「确认」退出当前账号") + _click_box(hwnd, confirm, after_sleep=2) + + if _wait_ocr_text(hwnd, _PANEL_TEXTS, timeout=30) is None: + raise RuntimeError("退出账号后登录面板未打开(30s 未识别到登录面板)") + on_log("登录面板已打开") + + +def _detect_current_account(hwnd: int) -> str | None: + """从登录面板 OCR 掩码账号(如 130*****6220)识别当前账号后 4 位。""" + for text, _ in _read_texts(hwnd): + match = _MASKED_SUFFIX.search(text) + if match: + return match.group(1) + return None + + +def _expand_account_list(hwnd: int) -> None: + """点击当前账号卡片直至账号列表展开(出现多个掩码账号)。 + + 若始终识别不到任何掩码账号,说明无法确认账号列表已打开(OCR 失败或 + 面板布局变化);继续登录可能落在错误账号上,按失败抛出而非静默返回。 + """ + found_masked = False + for _ in range(3): + items = _read_texts(hwnd) + masked = [box for text, box in items if _MASKED_ACCOUNT.search(text)] + if len(masked) >= 2: + return + if not masked: + # 可能为 OCR 瞬时失败,等待后重试 + time.sleep(1) + continue + found_masked = True + _click_box(hwnd, masked[0], after_sleep=1) + if not found_masked: + raise RuntimeError( + "未识别到任何掩码账号,无法确认账号列表已打开,请人工检查登录面板" + ) + + +def _scroll_list(hwnd: int) -> None: + """在账号列表区域滚轮下翻一页(目标账号不在可见范围时使用)。""" + with _per_monitor_dpi(): + width, height = _client_size(hwnd) + px, py = _LIST_SCROLL_POINT + client_x = round(px * width / _FRAME_WIDTH) + client_y = round(py * height / _FRAME_HEIGHT) + screen_x, screen_y = win32gui.ClientToScreen(hwnd, (client_x, client_y)) + pyautogui.moveTo(screen_x, screen_y) + time.sleep(0.2) + pyautogui.scroll(-3) + time.sleep(0.5) + + +def _click_masked_account( + hwnd: int, pattern: re.Pattern[str], on_log: Callable[[str], None] +) -> bool: + """在账号列表中点击目标掩码账号;不在可见范围时滚轮翻页(上限 5 页)。""" + for page in range(6): + items = _read_texts(hwnd) + for text, box in items: + if pattern.search(text): + _click_box(hwnd, box, after_sleep=1) + return True + if page < 5: + on_log(f"目标账号不在当前可见列表,滚轮翻页({page + 1}/5)") + _scroll_list(hwnd) + return False + + +def _wait_login_success(hwnd: int, *, timeout: int = 120) -> None: + """点击登录后等待登录面板消失(登录成功进入加载/游戏)。""" + deadline = time.monotonic() + timeout + absent_count = 0 + while time.monotonic() < deadline: + if not _on_login_panel(hwnd): + absent_count += 1 + if absent_count >= 2: + return + else: + absent_count = 0 + time.sleep(1) + raise RuntimeError("等待登录完成超时(登录面板未消失)") + + +def _select_and_login( + hwnd: int, suffix: str, on_log: Callable[[str], None] +) -> None: + pattern = re.compile(rf"\d+\*+{re.escape(suffix)}") + max_retries = 3 + for attempt in range(1, max_retries + 1): + _activate_window(hwnd) + time.sleep(1) + _expand_account_list(hwnd) + time.sleep(0.5) + if _click_masked_account(hwnd, pattern, on_log): + time.sleep(1) + detected = _detect_current_account(hwnd) + on_log( + f"已选择账号 ****{suffix},当前显示 " + f"****{detected if detected else '未知'}" + ) + if detected == suffix: + break + if attempt < max_retries: + on_log(f"账号显示不匹配,重试({attempt}/{max_retries})") + time.sleep(1) + else: + on_log(f"账号选择失败,已重试 {max_retries} 次") + raise RuntimeError(f"账号选择失败,已重试 {max_retries} 次") + time.sleep(2) + login_box = _find_login_button(_read_texts(hwnd)) + if login_box is not None: + _click_box(hwnd, login_box, after_sleep=3) + else: + # OCR 失败兜底:登录按钮在面板中线、账号卡片下方约一卡处 + on_log("未识别到「登录」按钮文本,按面板相对位置兜底点击") + _click_point(hwnd, 960, 660, after_sleep=3) + _wait_login_success(hwnd) + on_log(f"登录成功:****{suffix}") + + +def _save_error_screenshot(hwnd: int) -> None: + """保存切换失败时的原始窗口截图,便于排查 OCR 文本漂移。""" + try: + screenshot_dir = Path.cwd() / "debug" / "oknte-account-switch" + screenshot_dir.mkdir(parents=True, exist_ok=True) + screenshot_path = screenshot_dir / ( + f"switch-error-{datetime.now().strftime('%Y%m%d-%H%M%S-%f')}.png" + ) + _capture_window_image(hwnd, activate=False).save( + screenshot_path, format="PNG" + ) + logger.warning(f"账号切换错误截图已保存: {screenshot_path}") + except Exception as error: + # 截图是诊断旁路,失败时不能覆盖原始切换异常 + logger.warning(f"账号切换错误截图保存失败: {error}") + + +# ── 对外入口 ───────────────────────────────────────────────────────────── + + +def account_switch( + account_id: str, *, on_log: Callable[[str], None] | None = None +) -> bool: + """强制切换异环登录账号到 ``account_id``(按手机号后 4 位匹配)。 + + Args: + account_id: 目标账号(手机号),取后 4 位匹配登录面板掩码账号。 + on_log: 流程进度回调(供 MAS 推送调度台日志),默认仅写日志。 + + Returns: + 切换成功返回 True;失败抛出带原因描述的 RuntimeError。 + + Raises: + RuntimeError: 未找到游戏窗口 / 画面不在标题界面或登录面板 / 流程失败 / 超时。 + """ + on_log = on_log or (lambda msg: logger.info(msg)) + if not IS_WINDOWS: + raise RuntimeError("OK-NTE 账号切换仅支持 Windows 平台") + account_id = str(account_id or "").strip() + if len(account_id) < 4: + raise RuntimeError("账号不足四位,无法按手机号后 4 位匹配登录账号") + suffix = account_id[-4:] + + # 开启诊断记录:进度日志与各步骤 OCR 文本写入 debug/oknte-account-switch/, + # 供登录失败时用户反馈定位(文件随时间戳命名,一次切换一个文件) + global _DIAGNOSTIC_PATH + diagnostic_dir = Path.cwd() / "debug" / "oknte-account-switch" + diagnostic_dir.mkdir(parents=True, exist_ok=True) + _DIAGNOSTIC_PATH = diagnostic_dir / ( + f"switch-detail-{datetime.now().strftime('%Y%m%d-%H%M%S-%f')}.log" + ) + + def _on_log(msg: str) -> None: + _write_diagnostic(f"[{datetime.now():%H:%M:%S}] {msg}\n") + on_log(msg) + + _on_log(f"开始切换异环账号:****{suffix}") + try: + hwnd = _find_game_hwnd() + _activate_window(hwnd) + if _on_login_panel(hwnd): + on_log("登录面板已打开,直接选择账号") + elif _find_text(_read_texts(hwnd), _TITLE_TEXTS) is not None: + _open_account_panel(hwnd, _on_log) + else: + raise RuntimeError( + "未识别到异环标题界面或登录面板,请让游戏停在标题界面后重试" + ) + _select_and_login(hwnd, suffix, _on_log) + except Exception: + try: + _save_error_screenshot(_find_game_hwnd()) + except Exception: + pass + raise + finally: + _DIAGNOSTIC_PATH = None + logger.success(f"异环账号切换成功:****{suffix}") + return True + + +async def async_switch_account( + account_id: str, *, on_log: Callable[[str], None] | None = None +) -> bool: + """async 版本:在后台线程执行完整切换流程,避免阻塞事件循环。""" + return await asyncio.to_thread(account_switch, account_id, on_log=on_log) diff --git a/app/task/OkNte/tools/launcher_start.py b/app/task/OkNte/tools/launcher_start.py new file mode 100644 index 000000000..62945e753 --- /dev/null +++ b/app/task/OkNte/tools/launcher_start.py @@ -0,0 +1,377 @@ +# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software +# Copyright © 2025-2026 AUTO-MAS Team +# +# This file is part of AUTO-MAS. +# +# AUTO-MAS is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of +# the License, or (at your option) any later version. +# +# AUTO-MAS is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty +# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See +# the GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with AUTO-MAS. If not, see . + +"""OK-NTE(异环)通过启动器拉起游戏。 + +异环客户端直接运行 HTGame.exe 会卡界面,必须经启动器(NTELauncher 下的 +NTEGame.exe / NTEGlobalGame.exe / NTETWGame.exe)启动。本模块对齐 ok-nte +上游 LauncherTask 的启动流程,交互与截图采用与账号切换一致的前台 +pyautogui + DPI 适配模式,OCR 复用通用工具集 `app.tools.ocr`。 + +流程:: + + 拉起启动器 → 等启动器窗口 → OCR 找「开始游戏」/「更新」按钮并点击 + (点「更新」后只点一次,等更新完成按钮变回「开始游戏」再点) + → 等 HTGame.exe 进程 + 可见窗口出现(游戏就绪,停在标题界面) +""" + +import asyncio +import ctypes +import time +from collections.abc import Callable +from contextlib import contextmanager +from datetime import datetime +from functools import lru_cache +from pathlib import Path + +import cv2 +import numpy as np +import psutil +from PIL import Image + +from app.tools.ocr import Box, OCRItem, ocr_image +from app.utils import get_logger +from app.utils.platform import IS_WINDOWS + +if IS_WINDOWS: + # pyautogui 与 pywin32 仅 Windows 可用(无图形会话导入即失败),随入口的 + # IS_WINDOWS 检查一并惰性导入 + import pyautogui + import win32api + import win32con + import win32gui + import win32process + +logger = get_logger("OK-NTE 启动器启动") + +# 游戏客户端与启动器进程名(对齐 ok-nte 上游 src/__init__.py) +_GAME_PROCESS = "HTGame.exe" +LAUNCHER_EXES = ("NTEGame.exe", "NTEGlobalGame.exe", "NTETWGame.exe") + +# 截图基准分辨率(16:9),OCR 与点击均在此坐标空间计算后再映射回真实窗口 +_FRAME_WIDTH = 1920 +_FRAME_HEIGHT = 1080 + +# 首次找到按钮的等待;点击「开始游戏」后等游戏起窗;点「更新」后等更新完成 +_FIND_BUTTON_TIMEOUT = 120 +_START_GAME_TIMEOUT = 300 +_UPDATE_TIMEOUT = 1800 +# 启动器拉起后等其窗口创建的时限(对齐 ok-nte 上游 _wait_for_process 默认值) +_LAUNCHER_WINDOW_TIMEOUT = 120 + + +@lru_cache(maxsize=1) +def _user32_dpi_api(): + user32 = ctypes.windll.user32 + user32.SetThreadDpiAwarenessContext.argtypes = [ctypes.c_void_p] + user32.SetThreadDpiAwarenessContext.restype = ctypes.c_void_p + return user32 + + +@contextmanager +def _per_monitor_dpi(): + """切换到 per-monitor DPI 感知,保证窗口坐标换算在跨 DPI 显示器下正确。""" + user32 = _user32_dpi_api() + previous = user32.SetThreadDpiAwarenessContext(ctypes.c_void_p(-4)) + try: + yield + finally: + if previous: + user32.SetThreadDpiAwarenessContext(previous) + + +# ── 窗口 / 进程定位 ───────────────────────────────────────────────────── + + +def _process_name(pid: int) -> str | None: + """按 pid 读取进程名;提权进程可能被拒,返回 None 而非抛错。""" + try: + return psutil.Process(pid).name() + except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): + return None + + +def _window_area(hwnd: int) -> int: + try: + left, top, right, bottom = win32gui.GetWindowRect(hwnd) + return (right - left) * (bottom - top) + except Exception: + return 0 + + +def _find_process_hwnd(process_name: str) -> int | None: + """按所属进程名找最大可见窗口;不存在返回 None(区别于切号的必存抛错)。""" + candidates: list[int] = [] + + def _enum(hwnd: int, _lparam: int) -> bool: + try: + if not win32gui.IsWindowVisible(hwnd): + return True + except Exception: + return True + _, pid = win32process.GetWindowThreadProcessId(hwnd) + if pid and _process_name(pid) == process_name: + candidates.append(hwnd) + return True + + win32gui.EnumWindows(_enum, 0) + return max(candidates, key=_window_area) if candidates else None + + +def _find_game_hwnd() -> int | None: + return _find_process_hwnd(_GAME_PROCESS) + + +def _wait_launcher_hwnd( + launcher_path: Path, *, timeout: int | None = None +) -> int | None: + """轮询等待启动器窗口创建。 + + 启动器进程被拉起后窗口创建需要时间(单次查找会瞬时误判失败);窗口 + 也可能由其它区服启动器进程名承载,按候选名单兜底。 + """ + deadline = time.monotonic() + (timeout or _LAUNCHER_WINDOW_TIMEOUT) + while True: + hwnd = _find_process_hwnd(launcher_path.name) + if hwnd is None: + for exe in LAUNCHER_EXES: + hwnd = _find_process_hwnd(exe) + if hwnd is not None: + break + if hwnd is not None or time.monotonic() >= deadline: + return hwnd + time.sleep(2) + + +# ── 截图 / 交互(前台 pyautogui + DPI 适配)───────────────────────────── + + +def _activate_window(hwnd: int) -> None: + if not win32gui.IsWindow(hwnd): + raise RuntimeError("异环启动器窗口已失效") + show_command = ( + win32con.SW_RESTORE + if win32gui.IsIconic(hwnd) + else win32con.SW_SHOW if not win32gui.IsWindowVisible(hwnd) else None + ) + if show_command is not None: + win32gui.ShowWindow(hwnd, show_command) + time.sleep(0.15) + try: + if win32gui.GetForegroundWindow() != hwnd: + # Windows 前台锁:后台进程不能直接抢占前台。先附着当前前台窗口线程 + # 的输入队列,再置前,绕过系统限制(与 OK-NTE 切号同理)。 + foreground = win32gui.GetForegroundWindow() + fg_thread = win32process.GetWindowThreadProcessId(foreground)[0] + win32process.AttachThreadInput( + win32api.GetCurrentThreadId(), fg_thread, True + ) + try: + win32gui.BringWindowToTop(hwnd) + win32gui.SetForegroundWindow(hwnd) + finally: + win32process.AttachThreadInput( + win32api.GetCurrentThreadId(), fg_thread, False + ) + else: + win32gui.BringWindowToTop(hwnd) + win32gui.SetForegroundWindow(hwnd) + except win32gui.error: + logger.debug("异环启动器窗口焦点请求被系统忽略,继续按前置窗口处理") + time.sleep(0.1) + + +def _client_size(hwnd: int) -> tuple[int, int]: + _, _, width, height = win32gui.GetClientRect(hwnd) + if width <= 0 or height <= 0: + raise RuntimeError("异环启动器窗口尺寸异常") + return width, height + + +def _capture_window_image(hwnd: int, *, activate: bool = True) -> Image.Image: + with _per_monitor_dpi(): + if activate: + _activate_window(hwnd) + width, height = _client_size(hwnd) + left, top = win32gui.ClientToScreen(hwnd, (0, 0)) + virtual_left = win32api.GetSystemMetrics(win32con.SM_XVIRTUALSCREEN) + virtual_top = win32api.GetSystemMetrics(win32con.SM_YVIRTUALSCREEN) + return pyautogui.screenshot(allScreens=True).crop( + ( + left - virtual_left, + top - virtual_top, + left - virtual_left + width, + top - virtual_top + height, + ) + ) + + +def _capture_window(hwnd: int, *, activate: bool = True) -> np.ndarray: + screenshot = _capture_window_image(hwnd, activate=activate) + screenshot = screenshot.resize( + (_FRAME_WIDTH, _FRAME_HEIGHT), Image.Resampling.LANCZOS + ) + return cv2.cvtColor(np.asarray(screenshot), cv2.COLOR_RGB2BGR) + + +def _read_texts(hwnd: int) -> list[OCRItem]: + return ocr_image(_capture_window(hwnd, activate=False)) + + +def _click_box( + hwnd: int, box: Box, *, activate: bool = False, after_sleep: float = 0.3 +) -> None: + """点击 1080p 坐标空间中的一个文字框中心。""" + with _per_monitor_dpi(): + if activate: + _activate_window(hwnd) + width, height = _client_size(hwnd) + x, y, box_width, box_height = box + client_x = round((x + box_width / 2) * width / _FRAME_WIDTH) + client_y = round((y + box_height / 2) * height / _FRAME_HEIGHT) + screen_x, screen_y = win32gui.ClientToScreen(hwnd, (client_x, client_y)) + + original_position = pyautogui.position() + try: + pyautogui.moveTo(screen_x, screen_y) + time.sleep(0.3) + pyautogui.click() + time.sleep(after_sleep) + finally: + pyautogui.moveTo(*original_position) + + +# ── OCR 文本判定辅助 ───────────────────────────────────────────────────── + + +def _find_text(items: list[OCRItem], keywords: tuple[str, ...]) -> Box | None: + for text, box in items: + if any(keyword in text for keyword in keywords): + return box + return None + + +def _save_error_screenshot(launcher_hwnd: int | None) -> None: + """保存启动失败时的原始窗口截图,便于排查 OCR 文本漂移。""" + try: + screenshot_dir = Path.cwd() / "debug" / "oknte-launcher-start" + screenshot_dir.mkdir(parents=True, exist_ok=True) + screenshot_path = screenshot_dir / ( + f"launcher-error-{datetime.now().strftime('%Y%m%d-%H%M%S-%f')}.png" + ) + target = launcher_hwnd if launcher_hwnd is not None else _find_game_hwnd() + if target is None: + return + _capture_window_image(target, activate=False).save( + screenshot_path, format="PNG" + ) + logger.warning(f"启动器启动错误截图已保存: {screenshot_path}") + except Exception as error: + # 截图是诊断旁路,失败时不能覆盖原始启动异常 + logger.warning(f"启动器启动错误截图保存失败: {error}") + + +# ── 对外入口 ───────────────────────────────────────────────────────────── + + +def start_game_via_launcher( + launcher_path: Path, *, on_log: Callable[[str], None] | None = None +) -> bool: + """通过启动器拉起异环游戏,直到 HTGame.exe 窗口出现(停在标题界面)。 + + 启动器按钮有概率是「更新」而非「开始游戏」(游戏有新版本时):点「更新」 + 后不再重复点击,等更新完成、按钮变回「开始游戏」后再点。 + + Args: + launcher_path: 启动器 exe 路径(NTELauncher 下的启动器程序)。 + on_log: 流程进度回调(供 MAS 推送调度台日志),默认仅写日志。 + + Returns: + 游戏窗口就绪返回 True;失败抛出带原因描述的 RuntimeError。 + + Raises: + RuntimeError: 未找到启动器窗口 / 按钮点击失败 / 等待游戏窗口超时。 + """ + on_log = on_log or (lambda msg: logger.info(msg)) + if not IS_WINDOWS: + raise RuntimeError("OK-NTE 启动器启动仅支持 Windows 平台") + + try: + hwnd = _wait_launcher_hwnd(launcher_path) + if hwnd is None: + raise RuntimeError( + f"等待启动器窗口超时({_LAUNCHER_WINDOW_TIMEOUT}s 未找到进程 " + f"{launcher_path.name} 的窗口,若启动器弹出 UAC 请先确认)" + ) + _activate_window(hwnd) + + start_clicks = 0 + update_clicked = False + deadline = time.monotonic() + _FIND_BUTTON_TIMEOUT + while time.monotonic() < deadline: + if _find_game_hwnd() is not None: + on_log("已检测到异环游戏窗口") + return True + + try: + items = _read_texts(hwnd) + except RuntimeError: + # 启动器点击「开始游戏」后最小化或退出,窗口失效:只等游戏起窗 + if start_clicks > 0: + time.sleep(2) + continue + raise + + start_box = _find_text(items, ("开始游戏",)) + update_box = None if start_box else _find_text(items, ("更新",)) + now = time.monotonic() + if start_box is not None and start_clicks < 3: + on_log("点击启动器「开始游戏」") + _click_box(hwnd, start_box, after_sleep=3) + start_clicks += 1 + deadline = max(deadline, now + _START_GAME_TIMEOUT) + elif update_box is not None and not update_clicked: + on_log("检测到启动器「更新」按钮,正在更新游戏,等待时间将延长...") + _click_box(hwnd, update_box, after_sleep=3) + update_clicked = True + deadline = max(deadline, now + _UPDATE_TIMEOUT) + time.sleep(2) + + if _find_game_hwnd() is not None: + on_log("已检测到异环游戏窗口") + return True + raise RuntimeError( + "等待异环游戏窗口超时" + + ("(游戏更新可能未完成,请人工确认启动器状态)" if update_clicked else "") + ) + except Exception: + # 失败留启动器/游戏原图,供排查 OCR 文本漂移 + try: + _save_error_screenshot(_wait_launcher_hwnd(launcher_path, timeout=5)) + except Exception: + pass + raise + + +async def async_start_game_via_launcher( + launcher_path: Path, *, on_log: Callable[[str], None] | None = None +) -> bool: + """async 版本:在后台线程执行启动器交互,避免阻塞事件循环。""" + return await asyncio.to_thread( + start_game_via_launcher, launcher_path, on_log=on_log + ) diff --git a/frontend/src/api/models/OkNteConfig_Game.ts b/frontend/src/api/models/OkNteConfig_Game.ts index 0e154e47a..bb1847dd8 100644 --- a/frontend/src/api/models/OkNteConfig_Game.ts +++ b/frontend/src/api/models/OkNteConfig_Game.ts @@ -15,7 +15,7 @@ export type OkNteConfig_Game = { */ Type?: ('Client' | 'URL' | null); /** - * 游戏程序路径 + * 游戏启动器路径(NTELauncher/NTEGame.exe,直启 HTGame.exe 会卡界面) */ Path?: (string | null); /** @@ -46,5 +46,9 @@ export type OkNteConfig_Game = { * 任务结束后是否关闭游戏 */ CloseOnFinish?: (boolean | null); + /** + * 运行前强制切换账号(需启用游戏配置;用户未填手机号时不切换) + */ + AccountSwitch?: (boolean | null); }; diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts index e1bd8af74..98597a8a1 100644 --- a/frontend/src/i18n/locales/en-US.ts +++ b/frontend/src/i18n/locales/en-US.ts @@ -960,7 +960,7 @@ export default { nativeTaskConfigurationHas: 'The native task configuration has not been read yet', importedFromCurrentSra: 'Imported from the current SRA / March7th Assistant configuration', scriptLevelMaaendConfiguration: 'Script-level MaaEnd configuration started', - gamePathMatchedHtgame: 'Game path matched to HTGame.exe automatically', + gamePathMatchedHtgame: 'Game path matched to NTEGame.exe launcher automatically', applyPreset2: 'Apply the preset', turnThisOffWhen: 'Turn this off when the script controls the game directly and you use cloud gaming', @@ -1251,6 +1251,8 @@ export default { pickStarRailInstall: 'Pick the Star Rail install directory (contains StarRail.exe)', pickGameExecutable: 'Pick the game executable', pickGameRootDirectory: 'Pick the game root directory (HTGame.exe is matched automatically)', + pickGameLauncherDirectory: + 'Pick the game root directory (NTEGame.exe is matched automatically)', pickGameExecutable2: 'Pick the game executable', pickScriptSMain: "Pick the script's main program file", pickScriptRootDirectory2: 'Pick the script root directory', diff --git a/frontend/src/i18n/locales/ja-JP.ts b/frontend/src/i18n/locales/ja-JP.ts index ebc0d9bc3..7020c9242 100644 --- a/frontend/src/i18n/locales/ja-JP.ts +++ b/frontend/src/i18n/locales/ja-JP.ts @@ -960,7 +960,7 @@ export default { nativeTaskConfigurationHas: 'ネイティブのタスク設定はまだ読み込まれていません', importedFromCurrentSra: '現在の SRA / 三月なのかアシスタントの設定からインポートしました', scriptLevelMaaendConfiguration: 'スクリプト単位の MaaEnd 設定を開始しました', - gamePathMatchedHtgame: 'ゲームのパスを HTGame.exe に自動で合わせました', + gamePathMatchedHtgame: 'ゲームのパスを NTEGame.exe ランチャーに自動で合わせました', applyPreset2: 'プリセットを適用', turnThisOffWhen: 'スクリプト直接制御でクラウドゲームを使う場合は、このスイッチをオフにすることをおすすめします', @@ -1255,6 +1255,8 @@ export default { pickGameExecutable: 'ゲームの実行ファイルを選択してください', pickGameRootDirectory: 'ゲームのルートフォルダを選択してください(HTGame.exe は自動で照合されます)', + pickGameLauncherDirectory: + 'ゲームのルートフォルダを選択してください(NTEGame.exe は自動で照合されます)', pickGameExecutable2: 'ゲームの実行ファイルを選択してください', pickScriptSMain: 'スクリプトのメインプログラムを選択してください', pickScriptRootDirectory2: 'スクリプトのルートフォルダを選択してください', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 09e7854ad..8b027eb7b 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -915,7 +915,7 @@ export default { nativeTaskConfigurationHas: '尚未读取到原生任务配置', importedFromCurrentSra: '已从当前 SRA / 三月七助手源配置导入', scriptLevelMaaendConfiguration: '已启动脚本级 MaaEnd 配置', - gamePathMatchedHtgame: '已自动匹配游戏路径至 HTGame.exe', + gamePathMatchedHtgame: '已自动匹配游戏路径至 NTEGame.exe 启动器', applyPreset2: '应用预设', turnThisOffWhen: '建议在脚本直控且使用云游戏的情况下关闭此开关', on: '开启', @@ -1180,6 +1180,7 @@ export default { pickStarRailInstall: '请选择星穹铁道安装目录(含 StarRail.exe)', pickGameExecutable: '请选择游戏可执行文件', pickGameRootDirectory: '请选择游戏根目录(自动匹配到 HTGame.exe)', + pickGameLauncherDirectory: '请选择游戏根目录(自动匹配到 NTEGame.exe)', pickGameExecutable2: '请选择游戏的可执行文件', pickScriptSMain: '请选择脚本主程序文件', pickScriptRootDirectory2: '请选择脚本根目录', diff --git a/frontend/src/views/EditView/Script/OkNteScriptEdit.vue b/frontend/src/views/EditView/Script/OkNteScriptEdit.vue index 66300a40f..7ee47a9ba 100644 --- a/frontend/src/views/EditView/Script/OkNteScriptEdit.vue +++ b/frontend/src/views/EditView/Script/OkNteScriptEdit.vue @@ -90,7 +90,7 @@

{{ t('edit.gameConfiguration') }}

- +