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') }}
-
+
@@ -111,7 +111,7 @@
-
+
@@ -133,7 +133,10 @@
-
+
+
+
+
@@ -155,6 +158,28 @@
+
+
+
+
+
+ 运行前强制切换账号
+
+
+
+
+
+ 是
+ 否
+
+
+
@@ -162,17 +187,17 @@
- {{ t('edit.gameRootDirectory') }}
+ {{ t('edit.gameLauncher') }}
选择包含 Neverness To Everness 的任意目录,自动定位
- HTGame.exe
({
WaitTime: 60,
IfForceClose: true,
CloseOnFinish: true,
+ AccountSwitch: false,
},
Run: { ProxyTimesLimit: 0, RunTimesLimit: 1, RunTimeLimit: 120 },
})
@@ -400,9 +426,11 @@ const rules = {
path: [{ required: true, message: t('edit.pickOkNtePath'), trigger: 'blur' }],
}
-// 异环游戏路径预设锚点与相对路径
+// 异环游戏路径预设锚点与启动器相对路径(直启 HTGame.exe 会卡界面,必须经启动器)
const NTE_GAME_ANCHOR = 'Neverness To Everness'
-const NTE_EXE_RELATIVE = 'Client/WindowsNoEditor/HT/Binaries/Win64/HTGame.exe'
+const NTE_LAUNCHER_DIR = 'NTELauncher'
+const NTE_LAUNCHER_EXES = ['NTEGame.exe', 'NTEGlobalGame.exe', 'NTETWGame.exe']
+const NTE_CLIENT_EXE = 'htgame.exe'
const showPathRejectModal = (title: string, content: string) => {
Modal.error({ title, content, okText: t('edit.gotIt') })
@@ -509,6 +537,26 @@ const loadScript = async () => {
Object.assign(oknteConfig.Script, config.Script || {})
Object.assign(oknteConfig.Game, config.Game || {})
Object.assign(oknteConfig.Run, config.Run || {})
+
+ // 旧配置 Game.Path 存的是 HTGame.exe:展示层自动升级为同安装根下的启动器
+ // (运行时后端也会按同一规则反推,此处仅为回显正确)
+ const gamePath = (oknteConfig.Game.Path || '').replace(/\\/g, '/')
+ if (
+ oknteConfig.Game.Enabled &&
+ gamePath.toLowerCase().endsWith(`/${NTE_CLIENT_EXE}`)
+ ) {
+ const clientIdx = gamePath.toLowerCase().lastIndexOf('/client/')
+ if (clientIdx !== -1) {
+ const installRoot = gamePath.substring(0, clientIdx)
+ for (const exe of NTE_LAUNCHER_EXES) {
+ const candidate = `${installRoot}/${NTE_LAUNCHER_DIR}/${exe}`
+ if (await window.electronAPI.fileExists(candidate)) {
+ oknteConfig.Game.Path = candidate
+ break
+ }
+ }
+ }
+ }
} catch {
message.error(t('edit.couldNotLoadScript'))
} finally {
@@ -550,17 +598,28 @@ const selectGameRootPath = async () => {
return
}
- // 截断锚点之后的内容,拼接预设相对路径
+ // 截断锚点之后的内容,拼接启动器候选相对路径(国服/国际/台服)
const prefix = normalized.substring(0, idx)
- const candidateExe = prefix + NTE_GAME_ANCHOR + '/' + NTE_EXE_RELATIVE
-
- // 校验 exe 是否真实存在
- if (!(await window.electronAPI.fileExists(candidateExe))) {
- showPathRejectModal('所选目录无效', '检测到异环目录但未找到 HTGame.exe,请验证游戏完整性。')
+ const launcherCandidates = NTE_LAUNCHER_EXES.map(
+ (exe) => `${prefix}${NTE_GAME_ANCHOR}/${NTE_LAUNCHER_DIR}/${exe}`
+ )
+
+ let candidateLauncher: string | null = null
+ for (const candidate of launcherCandidates) {
+ if (await window.electronAPI.fileExists(candidate)) {
+ candidateLauncher = candidate
+ break
+ }
+ }
+ if (!candidateLauncher) {
+ showPathRejectModal(
+ '所选目录无效',
+ '检测到异环目录但未找到 NTELauncher 启动器(NTEGame.exe),请验证游戏完整性。'
+ )
return
}
- oknteConfig.Game.Path = candidateExe
+ oknteConfig.Game.Path = candidateLauncher
oknteConfig.Game.Type = 'Client'
isSaving.value = true
try {
diff --git a/res/version.json b/res/version.json
index 09e671e69..21cfb032e 100644
--- a/res/version.json
+++ b/res/version.json
@@ -2,6 +2,9 @@
"version": "v5.5.0-beta.3",
"version_info": {
"v5.5.0-beta.3": {
+ "新增功能": [
+ "OK-NTE专项 支持启动后按用户手机号后 4 位强制切换登录账号,并支持由 MAS 经启动器拉起异环游戏、自动点击开始游戏(替代直启客户端避免卡界面,兼容旧客户端路径与启动器更新流程),失败均留存诊断截图 by [@AthenaHibou](https://github.com/AthenaHibou)"
+ ],
"程序优化": [
"启动流程 超大幅度优化启动速度,前端加载纵享丝滑 by [@1w1w11w1](https://github.com/1w1w11w1)",
"启动与首页性能 修复活动数据刷新时创建 httpx 客户端同步全量加载系统证书库(truststore)导致的事件循环冻结(弱网或 VPN 下所有请求最长十几秒无响应):SSL 上下文改为进程内缓存并在启动时线程预热,版本检查不再在请求路径同步拉取远端 git;六张第三方活动卡(星穹铁道/原神/绝区零/鸣潮/异环/重返未来1999)改为前端直连公开接口并各自维护快照、超时重试与失败态,任一源异常只影响自身卡片;后端聚合接口同步瘦身为仅本地数据(方舟关卡与代理状态),MaaEnd 资源预加载移入后台减轻启动阻塞,安装包不再携带 sourcemap by [@1w1w11w1](https://github.com/1w1w11w1)",
From 26f27e494da5b05b68f3130a85cbba3a0312efac Mon Sep 17 00:00:00 2001
From: AthenaHibou <13084046220@163.com>
Date: Tue, 1 Sep 2026 16:52:04 +0800
Subject: [PATCH 02/11] =?UTF-8?q?fix(ok-nte):=20=E8=B4=A6=E5=8F=B7?=
=?UTF-8?q?=E5=88=87=E6=8D=A2=E7=AD=89=E5=BE=85=E6=B8=B8=E6=88=8F=E7=AA=97?=
=?UTF-8?q?=E5=8F=A3=E4=B8=8E=E6=A0=87=E9=A2=98/=E7=99=BB=E5=BD=95?=
=?UTF-8?q?=E6=80=81=E5=B0=B1=E7=BB=AA=EF=BC=8C=E5=85=BC=E5=AE=B9=E4=BD=8E?=
=?UTF-8?q?=E6=80=A7=E8=83=BD=E8=AE=BE=E5=A4=87=E5=90=AF=E5=8A=A8=E5=BB=B6?=
=?UTF-8?q?=E8=BF=9F?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/task/OkNte/tools/account_switch.py | 101 ++++++++++++++++++++-----
1 file changed, 80 insertions(+), 21 deletions(-)
diff --git a/app/task/OkNte/tools/account_switch.py b/app/task/OkNte/tools/account_switch.py
index e22b0ef03..3bb5b2b36 100644
--- a/app/task/OkNte/tools/account_switch.py
+++ b/app/task/OkNte/tools/account_switch.py
@@ -81,6 +81,17 @@ def _write_diagnostic(text: str) -> None:
# ── 异环客户端窗口识别(与 OkNte/AutoProxy 的 _NTE_CLIENT_PROCESS 一致)──
_NTE_CLIENT_PROCESS = "HTGame.exe"
+# 游戏窗口就绪宽限期:进程拉起到窗口可见通常存在启动延迟,且设备性能越差窗口
+# 创建/亮相越慢。账号切换紧随定长 WaitTime 之后立即执行,须在宽限期内轮询等待
+# 窗口就绪,否则慢设备会误报「未找到异环游戏窗口」。
+_GAME_WINDOW_WAIT_SECONDS = 60.0
+# 窗口轮询间隔。
+_WINDOW_POLL_INTERVAL = 1.0
+# 启动界面稳定等待:窗口已出现但尚未进入可执行态(仍停在 splash/加载过渡帧)时,
+# 在宽限期内轮询等待「标题界面」或「登录面板」出现,避免在过渡帧上立即误判失败;
+# 超时才按停在标题界面的预期抛错,请用户人工确认。
+_SWITCH_STABILIZE_SECONDS = 45.0
+
# 截图基准分辨率(16:9),OCR 与点击均在此坐标空间计算后再映射回真实窗口
_FRAME_WIDTH = 1920
_FRAME_HEIGHT = 1080
@@ -139,32 +150,48 @@ def _window_area(hwnd: int) -> int:
return 0
-def _find_game_hwnd() -> int:
+def _find_game_hwnd(*, wait: bool = True) -> int:
"""按所属进程名定位异环主窗口。
异环窗口类未实测稳定,故只用 ``EnumWindows + psutil.Process(pid).name()``
过滤 HTGame.exe 的可见窗口(规避提权进程 name 读取被拒导致的漏判),
同进程存在多个窗口时取面积最大者。
+
+ Args:
+ wait: 为 True 时在宽限期 ``_GAME_WINDOW_WAIT_SECONDS`` 内轮询等待窗口就绪,
+ 吸收进程拉起后窗口延迟亮相的启动阶段;为 False 时单次枚举立即返回。
+
+ Raises:
+ RuntimeError: 宽限期结束仍未定位到异环游戏窗口。
"""
- candidates: list[int] = []
+ deadline = time.monotonic() + _GAME_WINDOW_WAIT_SECONDS
+ while True:
+ candidates: list[int] = []
- def _enum(hwnd: int, _lparam: int) -> bool:
- try:
- if not win32gui.IsWindowVisible(hwnd):
+ def _enum(hwnd: int, _lparam: int) -> bool:
+ try:
+ if not win32gui.IsWindowVisible(hwnd):
+ return True
+ except Exception:
return True
- except Exception:
+ _, pid = win32process.GetWindowThreadProcessId(hwnd)
+ if pid and _process_name(pid) == _NTE_CLIENT_PROCESS:
+ candidates.append(hwnd)
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})"
+ win32gui.EnumWindows(_enum, 0)
+ if candidates:
+ return max(candidates, key=_window_area)
+ if not wait or time.monotonic() >= deadline:
+ break
+ logger.info(
+ "异环游戏进程已启动但窗口暂未就绪,"
+ f"{_WINDOW_POLL_INTERVAL:g} 秒后重试..."
)
- return max(candidates, key=_window_area)
+ time.sleep(_WINDOW_POLL_INTERVAL)
+ raise RuntimeError(
+ f"未找到异环游戏窗口(进程 {_NTE_CLIENT_PROCESS})"
+ )
# ── 截图 / 交互(前台 pyautogui + DPI 适配)─────────────────────────────
@@ -323,6 +350,38 @@ def _on_login_panel(hwnd: int) -> bool:
return _find_text(_read_texts(hwnd), _PANEL_TEXTS) is not None
+def _on_title_screen(hwnd: int) -> bool:
+ """是否处于标题界面(独有文本「进入游戏」)。"""
+ return _find_text(_read_texts(hwnd), _TITLE_TEXTS) is not None
+
+
+def _wait_for_actionable_state(
+ hwnd: int, on_log: Callable[[str], None]
+) -> None:
+ """等待进入可执行的切号态(标题界面或登录面板)。
+
+ 游戏窗口刚出现时可能仍停在启动过渡帧(splash/加载),此时标题界面与登录
+ 面板都不命中。若立即按标题界面退出图标分流,可能落在不匹配的画面上。故在
+ 宽限期 ``_SWITCH_STABILIZE_SECONDS`` 内轮询等待两态之一出现;超时仍未命中
+ 才抛错(可能未停在标题界面,需人工确认)。
+
+ 进度日志按约 5 条/轮询节流,避免每秒向调度台刷屏。
+ """
+ deadline = time.monotonic() + _SWITCH_STABILIZE_SECONDS
+ iter_count = 0
+ while time.monotonic() < deadline:
+ if _on_login_panel(hwnd) or _on_title_screen(hwnd):
+ return
+ if iter_count % 5 == 0:
+ on_log("窗口已出现但尚未进入标题界面或登录面板,等待启动过渡帧稳定...")
+ iter_count += 1
+ time.sleep(1)
+ raise RuntimeError(
+ "等待异环标题界面/登录面板稳定超时 "
+ f"({_SWITCH_STABILIZE_SECONDS:g}s),请人工确认游戏已停在标题界面"
+ )
+
+
def _find_confirm_box(items: list[OCRItem]) -> Box | None:
"""在 OCR 条目中定位退出确认弹窗的「确认」按钮。
@@ -583,18 +642,18 @@ def _on_log(msg: str) -> None:
try:
hwnd = _find_game_hwnd()
_activate_window(hwnd)
+ # 启动稳定化:等界面进入「标题界面」或「登录面板」之一,再按各自流程分流,
+ # 避免在窗口已现但仍在加载过渡帧时立即误判失败。
+ _wait_for_actionable_state(hwnd, _on_log)
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(
- "未识别到异环标题界面或登录面板,请让游戏停在标题界面后重试"
- )
+ _open_account_panel(hwnd, _on_log)
_select_and_login(hwnd, suffix, _on_log)
except Exception:
try:
- _save_error_screenshot(_find_game_hwnd())
+ # wait=False:主流程已等待过窗口,此处单次枚举即可,避免失败后再空等宽限期。
+ _save_error_screenshot(_find_game_hwnd(wait=False))
except Exception:
pass
raise
From e3fd53e64436f6442719b20ffc6b2be28b38d57d Mon Sep 17 00:00:00 2001
From: AthenaHibou <13084046220@163.com>
Date: Tue, 1 Sep 2026 17:08:24 +0800
Subject: [PATCH 03/11] =?UTF-8?q?fix(ok-nte):=20=E8=B4=A6=E5=8F=B7?=
=?UTF-8?q?=E5=88=87=E6=8D=A2=E4=BB=A5=E8=BF=9B=E5=B1=95=E7=BB=AD=E5=BB=B6?=
=?UTF-8?q?=E7=AD=89=E5=BE=85=E6=B8=B8=E6=88=8F=E5=86=85=E6=9B=B4=E6=96=B0?=
=?UTF-8?q?/=E5=8A=A0=E8=BD=BD=E5=B0=B1=E7=BB=AA?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/task/OkNte/tools/account_switch.py | 59 ++++++++++++++++++--------
1 file changed, 41 insertions(+), 18 deletions(-)
diff --git a/app/task/OkNte/tools/account_switch.py b/app/task/OkNte/tools/account_switch.py
index 3bb5b2b36..d4f498a21 100644
--- a/app/task/OkNte/tools/account_switch.py
+++ b/app/task/OkNte/tools/account_switch.py
@@ -87,10 +87,15 @@ def _write_diagnostic(text: str) -> None:
_GAME_WINDOW_WAIT_SECONDS = 60.0
# 窗口轮询间隔。
_WINDOW_POLL_INTERVAL = 1.0
-# 启动界面稳定等待:窗口已出现但尚未进入可执行态(仍停在 splash/加载过渡帧)时,
-# 在宽限期内轮询等待「标题界面」或「登录面板」出现,避免在过渡帧上立即误判失败;
-# 超时才按停在标题界面的预期抛错,请用户人工确认。
-_SWITCH_STABILIZE_SECONDS = 45.0
+# 启动界面稳定等待:窗口已出现但尚未进入可执行态(仍停在 splash/加载/游戏内更新
+# 过渡帧)时,在硬上限内轮询等待「标题界面」或「登录面板」出现。异环更新频繁,点
+# 「开始游戏」后游戏内更新可能耗时很长,故界面仍在变化(有进展)时持续顺延等待;
+# 只有持续 ``_IN_GAME_STALL_SECONDS`` 无任何进展(界面静止且仍非标题/登录)才判失败。
+_IN_GAME_UPDATE_TIMEOUT = 1800.0
+# 长时间无进展判定:界面静止达到该时长仍非标题/登录面板则视为卡死,提前失败。
+_IN_GAME_STALL_SECONDS = 60.0
+# 游戏内更新/加载等待的轮询间隔(比窗口等待更宽松,降低长等待期 OCR 负载)。
+_IN_GAME_POLL_INTERVAL = 3.0
# 截图基准分辨率(16:9),OCR 与点击均在此坐标空间计算后再映射回真实窗口
_FRAME_WIDTH = 1920
@@ -350,9 +355,10 @@ def _on_login_panel(hwnd: int) -> bool:
return _find_text(_read_texts(hwnd), _PANEL_TEXTS) is not None
-def _on_title_screen(hwnd: int) -> bool:
- """是否处于标题界面(独有文本「进入游戏」)。"""
- return _find_text(_read_texts(hwnd), _TITLE_TEXTS) is not None
+def _frame_signature(frame: np.ndarray) -> int:
+ """对下采样的帧做哈希,用于判断界面是否仍在变化(有更新/加载进展)。"""
+ small = frame[::40, ::40]
+ return hash(small.tobytes())
def _wait_for_actionable_state(
@@ -360,25 +366,42 @@ def _wait_for_actionable_state(
) -> None:
"""等待进入可执行的切号态(标题界面或登录面板)。
- 游戏窗口刚出现时可能仍停在启动过渡帧(splash/加载),此时标题界面与登录
- 面板都不命中。若立即按标题界面退出图标分流,可能落在不匹配的画面上。故在
- 宽限期 ``_SWITCH_STABILIZE_SECONDS`` 内轮询等待两态之一出现;超时仍未命中
- 才抛错(可能未停在标题界面,需人工确认)。
+ 游戏窗口刚出现时可能仍停在启动过渡帧(splash/加载),而异环更新频繁,点「开始
+ 游戏」后游戏内可能出现体积大、耗时长更新的界面(此时标题界面与登录面板都不命中)。
+ 若立即按标题界面退出图标分流,会落在不匹配的画面上。故采用「进展续延」语义等待:
- 进度日志按约 5 条/轮询节流,避免每秒向调度台刷屏。
+ - 命中标题界面/登录面板 → 返回,由调用方按对应态执行;
+ - 界面仍在变化(有更新/加载进展)→ 持续顺延等待,硬上限 ``_IN_GAME_UPDATE_TIMEOUT``;
+ - 界面持续 ``_IN_GAME_STALL_SECONDS`` 无任何进展且仍非标题/登录 → 判卡死提前失败。
+
+ 进度日志按约 5 条/轮询节流,避免向调度台频繁刷屏。
"""
- deadline = time.monotonic() + _SWITCH_STABILIZE_SECONDS
+ deadline = time.monotonic() + _IN_GAME_UPDATE_TIMEOUT
+ last_progress = time.monotonic()
+ last_sig: int | None = None
iter_count = 0
while time.monotonic() < deadline:
- if _on_login_panel(hwnd) or _on_title_screen(hwnd):
+ frame = _capture_window(hwnd, activate=False)
+ items = ocr_image(frame)
+ _dump_ocr_items(items)
+ if (
+ _find_text(items, _PANEL_TEXTS) is not None
+ or _find_text(items, _TITLE_TEXTS) is not None
+ ):
return
+ sig = _frame_signature(frame)
+ if sig != last_sig:
+ last_sig = sig
+ last_progress = time.monotonic()
+ if time.monotonic() - last_progress >= _IN_GAME_STALL_SECONDS:
+ break
if iter_count % 5 == 0:
- on_log("窗口已出现但尚未进入标题界面或登录面板,等待启动过渡帧稳定...")
+ on_log("异环仍在游戏内更新/加载过渡帧中,等待进入标题界面或登录面板...")
iter_count += 1
- time.sleep(1)
+ time.sleep(_IN_GAME_POLL_INTERVAL)
raise RuntimeError(
- "等待异环标题界面/登录面板稳定超时 "
- f"({_SWITCH_STABILIZE_SECONDS:g}s),请人工确认游戏已停在标题界面"
+ "等待异环标题界面/登录面板超时:长时间无进展或超过硬上限"
+ f"({_IN_GAME_UPDATE_TIMEOUT:g}s),请人工确认游戏已停在标题界面"
)
From 2694863ec883f1cbd5f9dfa759cf3eaf08538bff Mon Sep 17 00:00:00 2001
From: AthenaHibou <13084046220@163.com>
Date: Tue, 1 Sep 2026 18:55:38 +0800
Subject: [PATCH 04/11] =?UTF-8?q?fix(ok-nte):=20=E9=95=BF=E7=AD=89?=
=?UTF-8?q?=E5=BE=85=E6=9C=9F=E8=AF=8A=E6=96=AD=20OCR=20=E8=90=BD=E7=9B=98?=
=?UTF-8?q?=E8=8A=82=E6=B5=81=EF=BC=8C=E9=81=BF=E5=85=8D=E6=9B=B4=E6=96=B0?=
=?UTF-8?q?=E7=AD=89=E5=BE=85=E6=97=B6=E8=AF=8A=E6=96=AD=E6=96=87=E4=BB=B6?=
=?UTF-8?q?=E8=86=A8=E8=83=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/task/OkNte/tools/account_switch.py | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/app/task/OkNte/tools/account_switch.py b/app/task/OkNte/tools/account_switch.py
index d4f498a21..f5d57b5f9 100644
--- a/app/task/OkNte/tools/account_switch.py
+++ b/app/task/OkNte/tools/account_switch.py
@@ -96,6 +96,9 @@ def _write_diagnostic(text: str) -> None:
_IN_GAME_STALL_SECONDS = 60.0
# 游戏内更新/加载等待的轮询间隔(比窗口等待更宽松,降低长等待期 OCR 负载)。
_IN_GAME_POLL_INTERVAL = 3.0
+# 长等待期诊断 OCR 的落盘节流:更新可能耗时数十分钟,若每个轮询都全量写诊断文件,
+# 单次切换会累积数千行;改为每 N 次轮询(≈ N*3s)写一次,既能保留过渡帧采样又不膨胀。
+_DIAGNOSTIC_DUMP_EVERY_POLLS = 10
# 截图基准分辨率(16:9),OCR 与点击均在此坐标空间计算后再映射回真实窗口
_FRAME_WIDTH = 1920
@@ -383,7 +386,8 @@ def _wait_for_actionable_state(
while time.monotonic() < deadline:
frame = _capture_window(hwnd, activate=False)
items = ocr_image(frame)
- _dump_ocr_items(items)
+ if iter_count % _DIAGNOSTIC_DUMP_EVERY_POLLS == 0:
+ _dump_ocr_items(items)
if (
_find_text(items, _PANEL_TEXTS) is not None
or _find_text(items, _TITLE_TEXTS) is not None
From af853c5b2610c593e032869335e1a19a8a567ef6 Mon Sep 17 00:00:00 2001
From: AthenaHibou <13084046220@163.com>
Date: Tue, 1 Sep 2026 20:21:18 +0800
Subject: [PATCH 05/11] =?UTF-8?q?feat(ok-nte):=20=E6=9C=AA=E5=BC=80?=
=?UTF-8?q?=E5=90=AF=E3=80=8C=E4=BB=BB=E5=8A=A1=E5=89=8D=E5=90=AF=E5=8A=A8?=
=?UTF-8?q?=E6=B8=B8=E6=88=8F=E3=80=8D=E6=97=B6=E7=BD=AE=E7=81=B0=E5=BC=BA?=
=?UTF-8?q?=E5=88=B6=E5=88=87=E6=8D=A2=E8=B4=A6=E5=8F=B7=E5=BC=80=E5=85=B3?=
=?UTF-8?q?=E5=B9=B6=E6=8F=90=E7=A4=BA?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/src/views/EditView/Script/OkNteScriptEdit.vue | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/frontend/src/views/EditView/Script/OkNteScriptEdit.vue b/frontend/src/views/EditView/Script/OkNteScriptEdit.vue
index 7ee47a9ba..5ac7f7265 100644
--- a/frontend/src/views/EditView/Script/OkNteScriptEdit.vue
+++ b/frontend/src/views/EditView/Script/OkNteScriptEdit.vue
@@ -161,7 +161,7 @@
-
+
运行前强制切换账号
@@ -172,7 +172,7 @@
v-model:value="oknteConfig.Game.AccountSwitch"
size="large"
class="modern-input"
- :disabled="!oknteConfig.Game.Enabled"
+ :disabled="!oknteConfig.Game.Enabled || !oknteConfig.Game.LaunchBeforeTask"
@change="handleChange('Game', 'AccountSwitch', $event)"
>
是
From 6dfde6dc40e1a37113a7c7cf439159dd73b1b289 Mon Sep 17 00:00:00 2001
From: AthenaHibou <13084046220@163.com>
Date: Tue, 1 Sep 2026 20:39:41 +0800
Subject: [PATCH 06/11] =?UTF-8?q?fix(ok-nte):=20=E5=88=87=E5=8F=B7?=
=?UTF-8?q?=E9=97=A8=E6=8E=A7=E4=BE=9D=E8=B5=96=E3=80=8C=E4=BB=BB=E5=8A=A1?=
=?UTF-8?q?=E5=89=8D=E5=90=AF=E5=8A=A8=E3=80=8D=EF=BC=8C=E9=95=BF=E7=AD=89?=
=?UTF-8?q?=E5=BE=85=E7=AA=97=E5=8F=A3=E5=A4=B1=E6=95=88=E6=97=B6=E9=87=8D?=
=?UTF-8?q?=E5=AE=9A=E4=BD=8D=E6=96=B0=E7=AA=97=E5=8F=A3=E7=BB=AD=E7=AD=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/task/OkNte/AutoProxy.py | 5 ++--
app/task/OkNte/tools/account_switch.py | 33 +++++++++++++++++++-------
2 files changed, 28 insertions(+), 10 deletions(-)
diff --git a/app/task/OkNte/AutoProxy.py b/app/task/OkNte/AutoProxy.py
index 972705fc5..593805ca4 100644
--- a/app/task/OkNte/AutoProxy.py
+++ b/app/task/OkNte/AutoProxy.py
@@ -712,11 +712,12 @@ async def main_task(self):
continue
# 游戏启动成功后、下发脚本配置前,按「运行前强制切换账号」开关切号。
- # 开关在游戏配置区,依赖 Game.Enabled(未启用游戏配置则不生效);
- # 用户未填写手机号时跳过不切换。
+ # 开关在游戏配置区,依赖 Game.Enabled 且需开启「任务前启动游戏」(否则游戏非
+ # MAS 拉起、无窗口可切);用户未填写手机号时跳过不切换。
if (
self.script_config.get("Game", "AccountSwitch")
and self.script_config.get("Game", "Enabled")
+ and self.script_config.get("Game", "LaunchBeforeTask")
and self.game_manager is not None
):
account_id = (self.cur_user_config.get("Info", "Id") or "").strip()
diff --git a/app/task/OkNte/tools/account_switch.py b/app/task/OkNte/tools/account_switch.py
index f5d57b5f9..bc5998416 100644
--- a/app/task/OkNte/tools/account_switch.py
+++ b/app/task/OkNte/tools/account_switch.py
@@ -364,18 +364,28 @@ def _frame_signature(frame: np.ndarray) -> int:
return hash(small.tobytes())
+def _reacquire_game_hwnd(on_log: Callable[[str], None]) -> int:
+ """窗口句柄在等待期间失效(如游戏内更新触发客户端重启)时重新定位异环窗口。
+
+ 重定位成功返回新句柄;等待宽限期内仍找不到则抛出,交由调用方失败处理。
+ """
+ on_log("游戏窗口句柄已失效,重新定位异环游戏窗口...")
+ return _find_game_hwnd(wait=True)
+
+
def _wait_for_actionable_state(
hwnd: int, on_log: Callable[[str], None]
-) -> None:
- """等待进入可执行的切号态(标题界面或登录面板)。
+) -> int:
+ """等待进入可执行的切号态(标题界面或登录面板),返回当前有效的游戏窗口句柄。
游戏窗口刚出现时可能仍停在启动过渡帧(splash/加载),而异环更新频繁,点「开始
游戏」后游戏内可能出现体积大、耗时长更新的界面(此时标题界面与登录面板都不命中)。
若立即按标题界面退出图标分流,会落在不匹配的画面上。故采用「进展续延」语义等待:
- - 命中标题界面/登录面板 → 返回,由调用方按对应态执行;
+ - 命中标题界面/登录面板 → 返回有效句柄,由调用方按对应态执行;
- 界面仍在变化(有更新/加载进展)→ 持续顺延等待,硬上限 ``_IN_GAME_UPDATE_TIMEOUT``;
- 界面持续 ``_IN_GAME_STALL_SECONDS`` 无任何进展且仍非标题/登录 → 判卡死提前失败。
+ - 等待期间窗口句柄失效(客户端更新重启)→ 重新定位新窗口后继续等待。
进度日志按约 5 条/轮询节流,避免向调度台频繁刷屏。
"""
@@ -384,15 +394,22 @@ def _wait_for_actionable_state(
last_sig: int | None = None
iter_count = 0
while time.monotonic() < deadline:
- frame = _capture_window(hwnd, activate=False)
- items = ocr_image(frame)
+ try:
+ frame = _capture_window(hwnd, activate=False)
+ items = ocr_image(frame)
+ except RuntimeError:
+ # 窗口句柄失效:可能是游戏内更新触发客户端重启,重找新窗口继续而非中止。
+ hwnd = _reacquire_game_hwnd(on_log)
+ last_progress = time.monotonic()
+ last_sig = None
+ continue
if iter_count % _DIAGNOSTIC_DUMP_EVERY_POLLS == 0:
_dump_ocr_items(items)
if (
_find_text(items, _PANEL_TEXTS) is not None
or _find_text(items, _TITLE_TEXTS) is not None
):
- return
+ return hwnd
sig = _frame_signature(frame)
if sig != last_sig:
last_sig = sig
@@ -670,8 +687,8 @@ def _on_log(msg: str) -> None:
hwnd = _find_game_hwnd()
_activate_window(hwnd)
# 启动稳定化:等界面进入「标题界面」或「登录面板」之一,再按各自流程分流,
- # 避免在窗口已现但仍在加载过渡帧时立即误判失败。
- _wait_for_actionable_state(hwnd, _on_log)
+ # 避免在窗口已现但仍在加载过渡帧时立即误判失败;等待期间窗口重启会返回新句柄。
+ hwnd = _wait_for_actionable_state(hwnd, _on_log)
if _on_login_panel(hwnd):
on_log("登录面板已打开,直接选择账号")
else:
From db47758ca18c2b0d545bc7edc06034200739f41c Mon Sep 17 00:00:00 2001
From: AthenaHibou <13084046220@163.com>
Date: Tue, 1 Sep 2026 20:49:02 +0800
Subject: [PATCH 07/11] =?UTF-8?q?fix(ok-nte):=20=E4=BF=AE=E6=AD=A3?=
=?UTF-8?q?=E6=A0=87=E9=A2=98=E7=95=8C=E9=9D=A2=E9=80=80=E5=87=BA=E5=9B=BE?=
=?UTF-8?q?=E6=A0=87=E7=82=B9=E5=87=BB=E5=9D=90=E6=A0=87=EF=BC=88=E5=8E=9F?=
=?UTF-8?q?=20x=20=E5=81=8F=E5=8F=B3=E8=90=BD=E5=9C=A8=E5=9B=BE=E6=A0=87?=
=?UTF-8?q?=E5=8F=B3=E7=BC=98=E5=A4=96=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/task/OkNte/tools/account_switch.py | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/app/task/OkNte/tools/account_switch.py b/app/task/OkNte/tools/account_switch.py
index bc5998416..74cb5c33a 100644
--- a/app/task/OkNte/tools/account_switch.py
+++ b/app/task/OkNte/tools/account_switch.py
@@ -104,8 +104,9 @@ def _write_diagnostic(text: str) -> None:
_FRAME_WIDTH = 1920
_FRAME_HEIGHT = 1080
-# 标题界面右上角退出图标(无文本可 OCR,按 2048x1152 参考截图换算 1080p 坐标)
-_LOGOUT_ICON_POINT = (1865, 342)
+# 标题界面右上角退出图标(无文本可 OCR,按 2048x1152 参考截图换算 1080p 坐标;
+# 图标中心 x≈1962/2048→1839,y≈363/1152→340,原 1865 偏右落在图标右缘外导致点击不生效)
+_LOGOUT_ICON_POINT = (1839, 340)
# 账号列表滚动中心(HOTTA STUDIO 面板中部),目标账号超出可见范围时滚轮翻页
_LIST_SCROLL_POINT = (960, 600)
From 4ec04040ca486ab6ca5b2d086efa2ea3d3b51650 Mon Sep 17 00:00:00 2001
From: AthenaHibou <13084046220@163.com>
Date: Tue, 1 Sep 2026 21:00:49 +0800
Subject: [PATCH 08/11] =?UTF-8?q?fix(ok-nte):=20=E5=88=87=E5=8F=B7?=
=?UTF-8?q?=E5=B0=B1=E7=BB=AA=E5=88=A4=E5=AE=9A=E5=A2=9E=E5=8A=A0=E5=B7=A6?=
=?UTF-8?q?=E4=B8=8A=E8=A7=92=E9=80=82=E9=BE=84=E6=8F=90=E7=A4=BA=E5=BE=BD?=
=?UTF-8?q?=E6=A0=87=E4=BD=9C=E4=B8=BA=E9=99=84=E5=8A=A0=E4=BF=A1=E5=8F=B7?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/task/OkNte/tools/account_switch.py | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/app/task/OkNte/tools/account_switch.py b/app/task/OkNte/tools/account_switch.py
index 74cb5c33a..11171f908 100644
--- a/app/task/OkNte/tools/account_switch.py
+++ b/app/task/OkNte/tools/account_switch.py
@@ -114,6 +114,9 @@ def _write_diagnostic(text: str) -> None:
_TITLE_TEXTS = ("进入游戏",)
# 仅登录面板出现的文本,用于判定「登录面板已打开 / 登录成功后面板消失」
_PANEL_TEXTS = ("使用其他方式登录",)
+# 加载完成后常驻左上角的适龄提示徽标(16+/CADPA),标题界面与登录面板均有:
+# 作为就绪判定的附加信号,防止「进入游戏」按钮被特效遮挡或 OCR 瞬时失败漏判
+_LOADED_BADGE_TEXTS = ("适龄提示",)
# 掩码账号形如 130*****6220
_MASKED_ACCOUNT = re.compile(r"\d{3}\*+\d{4}")
@@ -383,7 +386,7 @@ def _wait_for_actionable_state(
游戏」后游戏内可能出现体积大、耗时长更新的界面(此时标题界面与登录面板都不命中)。
若立即按标题界面退出图标分流,会落在不匹配的画面上。故采用「进展续延」语义等待:
- - 命中标题界面/登录面板 → 返回有效句柄,由调用方按对应态执行;
+ - 命中标题界面/登录面板/左上角适龄提示徽标 → 返回有效句柄,由调用方按对应态执行;
- 界面仍在变化(有更新/加载进展)→ 持续顺延等待,硬上限 ``_IN_GAME_UPDATE_TIMEOUT``;
- 界面持续 ``_IN_GAME_STALL_SECONDS`` 无任何进展且仍非标题/登录 → 判卡死提前失败。
- 等待期间窗口句柄失效(客户端更新重启)→ 重新定位新窗口后继续等待。
@@ -409,6 +412,7 @@ def _wait_for_actionable_state(
if (
_find_text(items, _PANEL_TEXTS) is not None
or _find_text(items, _TITLE_TEXTS) is not None
+ or _find_text(items, _LOADED_BADGE_TEXTS) is not None
):
return hwnd
sig = _frame_signature(frame)
From 4662869bfbbea5568e032c6080a1a29f1e131fd7 Mon Sep 17 00:00:00 2001
From: AthenaHibou <13084046220@163.com>
Date: Tue, 1 Sep 2026 21:32:55 +0800
Subject: [PATCH 09/11] =?UTF-8?q?feat(ok-nte):=20=E5=90=AF=E5=8A=A8?=
=?UTF-8?q?=E5=99=A8=E8=87=AA=E6=9B=B4=E6=96=B0=E5=BC=B9=E7=AA=97=E5=A4=84?=
=?UTF-8?q?=E7=90=86=E4=B8=8E=E4=B8=8B=E8=BD=BD=E7=8A=B6=E6=80=81=E5=8A=A8?=
=?UTF-8?q?=E6=80=81=E7=BB=AD=E5=BB=B6=E7=AD=89=E5=BE=85=EF=BC=8C=E6=94=BE?=
=?UTF-8?q?=E5=AE=BD=E6=9B=B4=E6=96=B0=E6=97=B6=E9=99=90?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/task/OkNte/tools/account_switch.py | 4 +-
app/task/OkNte/tools/launcher_start.py | 84 +++++++++++++++++++++++++-
2 files changed, 85 insertions(+), 3 deletions(-)
diff --git a/app/task/OkNte/tools/account_switch.py b/app/task/OkNte/tools/account_switch.py
index 11171f908..206cae07c 100644
--- a/app/task/OkNte/tools/account_switch.py
+++ b/app/task/OkNte/tools/account_switch.py
@@ -91,7 +91,9 @@ def _write_diagnostic(text: str) -> None:
# 过渡帧)时,在硬上限内轮询等待「标题界面」或「登录面板」出现。异环更新频繁,点
# 「开始游戏」后游戏内更新可能耗时很长,故界面仍在变化(有进展)时持续顺延等待;
# 只有持续 ``_IN_GAME_STALL_SECONDS`` 无任何进展(界面静止且仍非标题/登录)才判失败。
-_IN_GAME_UPDATE_TIMEOUT = 1800.0
+# 异环更新频繁且包体大,慢网下 30 分钟不够,硬上限放宽到 2 小时;真挂起由无进展
+# 检测在 60s 内提前失败,不会傻等满上限。
+_IN_GAME_UPDATE_TIMEOUT = 7200.0
# 长时间无进展判定:界面静止达到该时长仍非标题/登录面板则视为卡死,提前失败。
_IN_GAME_STALL_SECONDS = 60.0
# 游戏内更新/加载等待的轮询间隔(比窗口等待更宽松,降低长等待期 OCR 负载)。
diff --git a/app/task/OkNte/tools/launcher_start.py b/app/task/OkNte/tools/launcher_start.py
index 62945e753..75346a68e 100644
--- a/app/task/OkNte/tools/launcher_start.py
+++ b/app/task/OkNte/tools/launcher_start.py
@@ -70,9 +70,26 @@
# 首次找到按钮的等待;点击「开始游戏」后等游戏起窗;点「更新」后等更新完成
_FIND_BUTTON_TIMEOUT = 120
_START_GAME_TIMEOUT = 300
-_UPDATE_TIMEOUT = 1800
+# 更新类等待上限:异环更新频繁且包体大,慢网下 30 分钟不够;对齐 ok-ww「MAS 下载
+# 不设总时限」的思路放宽到 2 小时。下载进行中时由下方下载状态检测持续动态续延,
+# 该值仅为点击「更新」后尚未出现下载 UI 的兜底等待。
+_UPDATE_TIMEOUT = 7200
# 启动器拉起后等其窗口创建的时限(对齐 ok-nte 上游 _wait_for_process 默认值)
_LAUNCHER_WINDOW_TIMEOUT = 120
+# 启动器点「确定」自重启后,等旧进程退出再找新窗口的缓冲
+_LAUNCHER_RESTART_QUIET_SECONDS = 3.0
+
+# ── 启动器下载状态动态识别(基于 OCR,样本为真实下载界面)────────────────
+# 下载进行中的判定文本:底部状态行「... 下载中 0% (x/x) 当前速度 xx MB/s」
+# 与右下角「暂停下载」按钮(该按钮仅在下载进行中存在)
+_DOWNLOAD_STATE_TEXTS = ("下载中", "暂停下载")
+# 下载进度相关文本特征:用于构造「下载进度签名」,只看下载相关行,
+# 避免首页横幅轮播等无关界面变化干扰卡死判定
+_DOWNLOAD_PROGRESS_TOKENS = ("%", "MB/s", "剩余时间")
+# 检测到下载态时每次顺延的等待宽限(下载 UI 持续存在就持续等,等效不设总时限)
+_UPDATE_ACTIVE_GRACE_SECONDS = 600.0
+# 下载进度签名持续无变化的时长上限:百分比/速度/剩余时间长时间不动视为下载卡死
+_DOWNLOAD_STALL_SECONDS = 300.0
@lru_cache(maxsize=1)
@@ -297,6 +314,14 @@ def start_game_via_launcher(
启动器按钮有概率是「更新」而非「开始游戏」(游戏有新版本时):点「更新」
后不再重复点击,等更新完成、按钮变回「开始游戏」后再点。
+ 启动器自身更新按弹窗分两级处理:「全新启动器现已推出」弹窗点「立即体验」
+ 升级;更新完成后「更新已完成,请重新启动游戏」弹窗点「确定」重启启动器,
+ 并重新等待启动器窗口后继续走「开始游戏」流程。
+
+ 游戏下载进行中(「下载中」状态行 /「暂停下载」按钮)基于下载状态动态续延等待,
+ 下载多久等多久;同时以百分比/速度/剩余时间构造下载进度签名,长时间无变化
+ 判定下载卡死提前失败。
+
Args:
launcher_path: 启动器 exe 路径(NTELauncher 下的启动器程序)。
on_log: 流程进度回调(供 MAS 推送调度台日志),默认仅写日志。
@@ -322,6 +347,9 @@ def start_game_via_launcher(
start_clicks = 0
update_clicked = False
+ launcher_upgrade_clicked = False
+ last_download_sig: int | None = None
+ last_download_progress = time.monotonic()
deadline = time.monotonic() + _FIND_BUTTON_TIMEOUT
while time.monotonic() < deadline:
if _find_game_hwnd() is not None:
@@ -337,9 +365,61 @@ def start_game_via_launcher(
continue
raise
+ now = time.monotonic()
+
+ # 弹窗一:全新启动器推送 → 点「立即体验」升级启动器
+ upgrade_box = _find_text(items, ("立即体验",))
+ if upgrade_box is not None and not launcher_upgrade_clicked:
+ on_log("检测到「全新启动器现已推出」弹窗,点击「立即体验」升级启动器...")
+ _click_box(hwnd, upgrade_box, after_sleep=3)
+ launcher_upgrade_clicked = True
+ deadline = max(deadline, now + _UPDATE_TIMEOUT)
+ time.sleep(2)
+ continue
+
+ # 弹窗二:启动器更新完成 → 点「确定」重启启动器,重新等窗口
+ if _find_text(items, ("更新已完成", "重新启动游戏")) is not None:
+ confirm_box = _find_text(items, ("确定",))
+ if confirm_box is not None:
+ on_log("启动器更新完成,点击「确定」重启启动器...")
+ _click_box(hwnd, confirm_box, after_sleep=3)
+ time.sleep(_LAUNCHER_RESTART_QUIET_SECONDS)
+ new_hwnd = _wait_launcher_hwnd(launcher_path)
+ if new_hwnd is None:
+ raise RuntimeError(
+ "启动器重启后未找到启动器窗口,请人工确认启动器状态"
+ )
+ hwnd = new_hwnd
+ _activate_window(hwnd)
+ start_clicks = 0
+ update_clicked = False
+ launcher_upgrade_clicked = False
+ time.sleep(2)
+ continue
+
+ # 游戏下载进行中:基于下载状态动态续延等待,并以下载进度签名检测卡死
+ if _find_text(items, _DOWNLOAD_STATE_TEXTS) is not None:
+ deadline = max(deadline, now + _UPDATE_ACTIVE_GRACE_SECONDS)
+ progress_sig = hash(
+ tuple(
+ text
+ for text, _ in items
+ if any(token in text for token in _DOWNLOAD_PROGRESS_TOKENS)
+ )
+ )
+ if progress_sig != last_download_sig:
+ last_download_sig = progress_sig
+ last_download_progress = now
+ elif now - last_download_progress >= _DOWNLOAD_STALL_SECONDS:
+ raise RuntimeError(
+ f"启动器下载长时间无进展({_DOWNLOAD_STALL_SECONDS:g}s 内"
+ "百分比/速度无变化,疑似卡住),请人工确认下载状态"
+ )
+ time.sleep(2)
+ continue
+
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)
From 6f046f128ac758128016db50544f3332680803ef Mon Sep 17 00:00:00 2001
From: AthenaHibou <13084046220@163.com>
Date: Tue, 1 Sep 2026 22:22:23 +0800
Subject: [PATCH 10/11] =?UTF-8?q?feat(ok-nte):=20=E6=96=B0=E5=A2=9E=20OK-N?=
=?UTF-8?q?TE=20=E9=97=AE=E9=A2=98=E5=8C=85=E4=B8=80=E9=94=AE=E5=AF=BC?=
=?UTF-8?q?=E5=87=BA?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/electron/main.ts | 7 ++
frontend/electron/preload.ts | 1 +
.../services/okNteIssueReportService.ts | 98 +++++++++++++++++++
.../src/composables/useOkNteIssueReport.ts | 11 +++
frontend/src/types/electron.d.ts | 6 ++
frontend/src/views/setting/TabAdvanced.vue | 18 ++++
res/version.json | 2 +-
7 files changed, 142 insertions(+), 1 deletion(-)
create mode 100644 frontend/electron/services/okNteIssueReportService.ts
create mode 100644 frontend/src/composables/useOkNteIssueReport.ts
diff --git a/frontend/electron/main.ts b/frontend/electron/main.ts
index ae21bfbe0..866100c03 100644
--- a/frontend/electron/main.ts
+++ b/frontend/electron/main.ts
@@ -36,6 +36,7 @@ import {
import { getLogger, initializeLogger } from './services/logger'
import { createMaaEndIssueReport } from './services/maaEndIssueReportService'
import { createOkwwIssueReport } from './services/okwwIssueReportService'
+import { createOkNteIssueReport } from './services/okNteIssueReportService'
import { configureMainSentry, recordMainStartup, setMainTelemetryEnabled } from './services/sentry'
import { applyInstanceIdentity, resolveStopAllTasksShortcut } from './services/instanceConfig'
import AdmZip = require('adm-zip')
@@ -1158,6 +1159,12 @@ registerIssueReportExporter(
'OK-WW-logs',
createOkwwIssueReport
)
+registerIssueReportExporter(
+ 'oknte:exportIssueReport',
+ '导出 OK-NTE 问题包',
+ 'OK-NTE-logs',
+ createOkNteIssueReport
+)
ipcMain.handle('data:backup', async () => {
let partialPath: string | undefined
diff --git a/frontend/electron/preload.ts b/frontend/electron/preload.ts
index be5177744..6fd486b65 100644
--- a/frontend/electron/preload.ts
+++ b/frontend/electron/preload.ts
@@ -127,6 +127,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
exportLogs: () => ipcRenderer.invoke('log:export'),
exportMaaEndIssueReport: () => ipcRenderer.invoke('maaend:exportIssueReport'),
exportOkwwIssueReport: () => ipcRenderer.invoke('okww:exportIssueReport'),
+ exportOkNteIssueReport: () => ipcRenderer.invoke('oknte:exportIssueReport'),
exportDataBackup: () => ipcRenderer.invoke('data:backup'),
getLogs: (lines?: number, fileName?: string) =>
ipcRenderer.invoke('log:getContent', lines, fileName),
diff --git a/frontend/electron/services/okNteIssueReportService.ts b/frontend/electron/services/okNteIssueReportService.ts
new file mode 100644
index 000000000..57a5e60b8
--- /dev/null
+++ b/frontend/electron/services/okNteIssueReportService.ts
@@ -0,0 +1,98 @@
+import * as fs from 'fs'
+import * as path from 'path'
+import AdmZip = require('adm-zip')
+
+import { getLogger } from './logger'
+import {
+ CollectorState,
+ Installation,
+ addDiagnosticFile,
+ addDirectory,
+ addLatestMasHistoryLog,
+ discoverInstallations,
+ resolveDataRoots,
+} from './issueReportCore'
+
+const logger = getLogger('OK-NTE问题包')
+
+// 与 app/task/OkNte/AutoProxy.py 的 script_log_path 默认值保持同步
+const OKNTE_REL_LOG_FILE = 'data/apps/ok-nte/working/logs/ok-script.log'
+
+function addLatestOkNteScriptLog(
+ state: CollectorState,
+ installations: Installation[]
+): void {
+ let latest: { sourcePath: string; archivePath: string; mtimeMs: number } | undefined
+
+ for (const installation of installations) {
+ const logPath = path.join(installation.rootPath, ...OKNTE_REL_LOG_FILE.split('/'))
+ try {
+ const mtimeMs = fs.statSync(logPath).mtimeMs
+ if (!latest || mtimeMs > latest.mtimeMs) {
+ latest = {
+ sourcePath: logPath,
+ archivePath: `oknte/${installation.label}/ok-script.log`,
+ mtimeMs,
+ }
+ }
+ } catch (error) {
+ logger.debug(`读取 ok-script.log 失败: ${logPath}, ${String(error)}`)
+ }
+ }
+
+ if (latest) {
+ addDiagnosticFile(state, latest.sourcePath, latest.archivePath)
+ }
+}
+
+export interface OkNteIssueReportResult {
+ success: boolean
+ message?: string
+ zipPath?: string
+ error?: string
+}
+
+export function createOkNteIssueReport(appRoot: string, zipPath: string): OkNteIssueReportResult {
+ const zip = new AdmZip()
+ const state: CollectorState = { zip, entries: [], archiveBytes: 0 }
+ const dataRoots = resolveDataRoots(appRoot)
+ const installations = discoverInstallations(dataRoots, {
+ configType: 'OkNteConfig',
+ pathField: 'RootPath',
+ labelPrefix: 'oknte',
+ })
+ addLatestMasHistoryLog(state, dataRoots)
+
+ dataRoots.forEach((dataRoot, index) => {
+ addDirectory(
+ state,
+ path.join(dataRoot, 'debug'),
+ index === 0 ? 'logs/auto-mas' : 'logs/auto-mas/backend'
+ )
+ })
+
+ const runtimeDebugDir = path.join(path.dirname(process.execPath), 'debug')
+ const knownDebugDirs = new Set(dataRoots.map(dataRoot => path.resolve(dataRoot, 'debug')))
+ if (!knownDebugDirs.has(path.resolve(runtimeDebugDir))) {
+ addDirectory(state, runtimeDebugDir, 'logs/frontend-runtime')
+ }
+
+ addLatestOkNteScriptLog(state, installations)
+
+ try {
+ fs.mkdirSync(path.dirname(zipPath), { recursive: true })
+ zip.writeZip(zipPath)
+ logger.info(`OK-NTE 问题包已导出: ${zipPath}`)
+ return {
+ success: true,
+ message: `OK-NTE 问题包导出成功,已收集 ${state.entries.filter(entry => entry.status !== 'skipped').length} 个文件`,
+ zipPath,
+ }
+ } catch (error) {
+ logger.error(`OK-NTE 问题包导出失败: ${String(error)}`)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : String(error),
+ }
+ }
+}
diff --git a/frontend/src/composables/useOkNteIssueReport.ts b/frontend/src/composables/useOkNteIssueReport.ts
new file mode 100644
index 000000000..9c26c0de4
--- /dev/null
+++ b/frontend/src/composables/useOkNteIssueReport.ts
@@ -0,0 +1,11 @@
+import { useIssueReport } from './useIssueReport'
+import type { ReportLogger } from './useIssueReport'
+
+export function useOkNteIssueReport(logger: ReportLogger) {
+ const { exporting, exportIssueReport } = useIssueReport(logger, {
+ label: 'OK-NTE',
+ fallbackName: 'OK-NTE-logs-*.zip',
+ exportFn: () => window.electronAPI?.exportOkNteIssueReport?.(),
+ })
+ return { exporting, exportOkNteIssueReport: exportIssueReport }
+}
diff --git a/frontend/src/types/electron.d.ts b/frontend/src/types/electron.d.ts
index d6c051c04..aec041497 100644
--- a/frontend/src/types/electron.d.ts
+++ b/frontend/src/types/electron.d.ts
@@ -160,6 +160,12 @@ export interface ElectronAPI {
zipPath?: string
error?: string
}>
+ exportOkNteIssueReport: () => Promise<{
+ success: boolean
+ message?: string
+ zipPath?: string
+ error?: string
+ }>
exportDataBackup: () => Promise<{
success: boolean
message?: string
diff --git a/frontend/src/views/setting/TabAdvanced.vue b/frontend/src/views/setting/TabAdvanced.vue
index b55ba7894..be7c3006e 100644
--- a/frontend/src/views/setting/TabAdvanced.vue
+++ b/frontend/src/views/setting/TabAdvanced.vue
@@ -6,6 +6,7 @@ import { ref } from 'vue'
import { useMaaEndIssueReport } from '@/composables/useMaaEndIssueReport'
import { useOkwwIssueReport } from '@/composables/useOkwwIssueReport'
+import { useOkNteIssueReport } from '@/composables/useOkNteIssueReport'
const { t } = useI18n()
@@ -18,6 +19,7 @@ const exportingLogs = ref(false)
const exportingDataBackup = ref(false)
const { exporting: exportingMaaEndLogs, exportMaaEndIssueReport } = useMaaEndIssueReport(logger)
const { exporting: exportingOkwwLogs, exportOkwwIssueReport } = useOkwwIssueReport(logger)
+const { exporting: exportingOkNteLogs, exportOkNteIssueReport } = useOkNteIssueReport(logger)
const exportLogsZip = async () => {
exportingLogs.value = true
@@ -142,6 +144,22 @@ const exportDataBackup = async () => {
+
+