diff --git a/app/models/config/script.py b/app/models/config/script.py index aa134852fd..2116860358 100644 --- a/app/models/config/script.py +++ b/app/models/config/script.py @@ -2670,12 +2670,23 @@ class UpdateFields(ConfigGroup): class M9AUserConfig(MaaFWUserConfig): - """M9A 账号配置:与 MaaFW 账号配置同形。 + """M9A 账号配置,含 B 服登录后的 UID 核对。 ``Info.Account`` 在这里不只是备注:脚本资源为官服且账号非空时,运行前会自动插入 「切换账号」任务(见 ``app/task/M9A/flavor.py``)。 """ + class InfoFields(MaaFWUserConfig.InfoFields): + GameUID: str = Field( + default_factory=lambda: "", + pattern=r"^[0-9]*$", + description="游戏内数字 UID,用于 MAS 登录 B 服后核对账号", + ) + + Info: InfoFields = Field( # pyright: ignore[reportIncompatibleVariableOverride] + default_factory=InfoFields + ) + class M9AConfig(MaaFWConfig): """M9A 托管配置:MaaFW 的特调类型。 @@ -2708,7 +2719,7 @@ class RunFields(MaaFWConfig.RunFields): default_factory=lambda: "Project", description="B 服登录方式。Project:由 M9A 项目的启动任务自己登录(上游「切换账号」仍只声明官服);" "Mas:由 MAS 完成 B 服登录——拉起 B 服客户端后处理隐私政策提示、在登录记录里选择已保存的账号," - "回到标题页后再交给 M9A 的启动任务进游戏并照常运行。Mas 需要 ADB controller 并在「游戏包名」里" + "进入游戏并核对账号配置中的 UID 后再交给 M9A 运行。Mas 需要 ADB controller 并在「游戏包名」里" "填写 B 服包名(com.shenlan.m.reverse1999.bilibili),账号填登录记录里显示的完整昵称," "且不再向 M9A 下发「切换账号」。自动同意隐私提示复用全局「同意哔哩哔哩用户协议」开关(默认关闭)," "未开启时登录会停下并提示手动处理。", diff --git a/app/task/M9A/AGENTS.md b/app/task/M9A/AGENTS.md index 22d2d3592b..7d5123bbb9 100644 --- a/app/task/M9A/AGENTS.md +++ b/app/task/M9A/AGENTS.md @@ -25,7 +25,7 @@ 任一命中即认领——导入完成后引擎据此把托管类型定成 `M9AConfig`(不命中的项目导进 M9A 脚本会变回 `MaaFWConfig`,类型由项目决定、双向自动、uid 不变)。 2. `decorate_selection(...)`:运行前先把勾选列表里三类受管任务**全部剔掉**(连同它们的选项),再按 - 固定顺序补:`StartUp` 放队首;脚本资源支持切号且有账号时 `SwitchAccount` 紧跟启动、账号填进它 + 固定顺序补:项目登录时 `StartUp` 放队首(MAS 登录 B 服时由登录钩子完成启动);脚本资源支持切号且有账号时 `SwitchAccount` 紧跟启动、账号填进它 第一个 input 选项;`Close1999` 放队尾。用户排的顺序不影响这三项。账号取 `Info.Account`,但队列里 恰好残留一个有效切号时取它(与启动整理的结果一致)。支持切号且有效切号 ≥ 2 时写账号日志并抛 `M9ASplitRequiredError`——引擎把它当成运行计划构建失败,该账号记「异常」、发错误通知、不跑,其它 @@ -67,15 +67,17 @@ - `login.py`:`Run.LoginMode == "Mas"` 时的 B 服原生登录(`ensure_logged_in` 钩子)。MAS 启动模拟器 (连带拉起游戏)之后、第一个任务之前,用 `adb` 截图 + `app.tools.common.ocr` 识别哔哩哔哩登录面板: 点「隐私政策提示」里唯一的「同意」,在「登录记录」里按 `Info.Account`(登录记录里显示的完整昵称) - 精确匹配、唯一命中后选中并点「登录」,等面板消失、连续两帧认出标题页后才返回;进游戏与跑任务交给 - M9A 的 `StartUp` 与用户队列。标题页的正向信号有四种:OCR 到「START」(或漏首字母的 TART)、 - 标题 logo 下的完整「REVERSE」或实机常读到的残词 `ERSE`、或登录成功后顶栏的「<昵称> 欢迎回来」横幅。 - 游戏已登录停在标题页(任一信号)时, + 含汉字的昵称提取汉字后完整匹配,无汉字的昵称按 OCR 原文精确匹配,唯一命中才选中并点「登录」。 + 登录面板退出后由 MAS 在标题页点 START,连续两帧识别到的 UID 与 `Info.GameUID` 一致才投递任务。 + 如果一开始就在主页,UID 一致直接继续,确认不符时经设置 → 账号 → 退出 → 返回登录,然后重新选号。 + 主页退出导航及 `login_assets/` 的两个模板来自 MAA1999/M9A(AGPL-3.0,原始提交 + [e03c1c6](https://github.com/MAA1999/M9A/tree/e03c1c60e51bff35c909aad96c58fde58a117946/resource/base/image/SwitchAccount))。 + 游戏已登录停在标题页时, MAS 不知道当前是哪个号,为保证切到目标账号,先点标题页右下角退出图标 → 唯一的「确认」→ 等「登录记录」 出现再选号。`adb` 子进程走 `app/utils/common/process/runner.py` 的受管入口:任务被停止时命令随作用域 回收,截图分支保留 PNG 原始 bytes(不能按文本解码)。 **边界**:自动同意只复用全局 `Config.setting.Function.IfAgreeBilibili`(默认关),关着时不点、 - 提示手动处理并停止;既认不出登录记录也认不出标题页(例如停在别的账号的主界面)时明确停止, + 提示手动处理并停止;既认不出登录记录也认不出标题页且无法识别主页 UID(例如加载中)时明确停止, 不把"没认出登录页"当成功;这段等待的预算取 `Game.WaitTime`(冷启动加载画面同样认不出,不能按 固定 60 秒截断慢启动的客户端),总时长仍受登录主循环 240 秒约束;所有循环先查总时限, `adb input tap` 失败直接抛错。 @@ -86,7 +88,7 @@ 没填时按这个包名拉起)。脚本资源不是「B 服」或实际要启动的包名不是 B 服包时直接报错, 不拿错客户端去走哔哩哔哩登录面板。`Run.LoginMode` 默认 `Project`(M9A 项目自己登录),只在 M9A 脚本有。 这是**临时补位**:上游 M9A 的 interface 已声明 B 服资源(任务运行可用),但「切换账号」仍只声明 - 官服、没有按 B 站昵称选号的入口;上游补齐等价入口后删除本模块的选号逻辑(立项时未单独向上游提 + 官服、没有按 B 站昵称选号的入口;上游补齐等价入口后移除这里的 B 服退出、选号及启动补位(立项时未单独向上游提 PR:本实现属于 MAS 侧运行前编排,上游入口补齐前无法复用)。 - `migration.py`:旧版 M9A 专项配置(`Run.IfPsychubeDailyOnce`、`Task.Queue`、`Data.LastPsychubeDate`…) → MaaFW 形状的一次性迁移,以及「通用 MaaFW 脚本指向 M9A 项目 → 换类型标签」。在 diff --git a/app/task/M9A/flavor.py b/app/task/M9A/flavor.py index 748edf8e6d..280bb0c389 100644 --- a/app/task/M9A/flavor.py +++ b/app/task/M9A/flavor.py @@ -244,14 +244,16 @@ def log(message: str) -> None: return ids, options added: list[str] = [] - startup = task_name_for_entry(interface_model, STARTUP_ENTRY) - if startup is None: - log( - f"[M9A] interface 里没有 entry 为 {STARTUP_ENTRY} 的任务,未自动加入启动游戏" - ) - else: - ids.insert(0, startup) - added.append(startup) + startup = None + if not self.manages_game_startup(script_config): + startup = task_name_for_entry(interface_model, STARTUP_ENTRY) + if startup is None: + log( + f"[M9A] interface 里没有 entry 为 {STARTUP_ENTRY} 的任务,未自动加入启动游戏" + ) + else: + ids.insert(0, startup) + added.append(startup) # 队列里恰好一个有效切号时以它为准,与启动整理的结果一致(整理会把它收进「账号」) account = ( @@ -361,6 +363,9 @@ async def ensure_logged_in( host_adb=host_adb, ) + def manages_game_startup(self, script_config: Any) -> bool: + return _login_mode(script_config) == LOGIN_MODE_MAS + async def ensure_game_updated( self, *, diff --git a/app/task/M9A/login.py b/app/task/M9A/login.py index b7cb24ce98..e43aa7265b 100644 --- a/app/task/M9A/login.py +++ b/app/task/M9A/login.py @@ -16,7 +16,7 @@ # You should have received a copy of the GNU Affero General Public License # along with AUTO-MAS. If not, see . -"""B 服(哔哩哔哩渠服)登录:MAS 原生实现,登录完成后交给 M9A 的启动任务进游戏。 +"""B 服(哔哩哔哩渠服)登录:MAS 原生实现,登录并在游戏主页核对 UID 后交给 M9A。 入口是 ``MaaFWFlavor`` 的可选钩子 ``ensure_logged_in``(契约见 ``app/task/MaaFW/tools/embedded/flavor.py`` 模块说明):MAS 启动模拟器(连带拉起游戏)之后、 @@ -30,14 +30,13 @@ 2. 循环截图 + OCR: - 「隐私政策提示」弹层:找到唯一的「同意」按钮(中心点限定在屏幕中下部)就点它; - - 「登录记录」面板:收起态先点右侧箭头展开列表,再按登录记录里显示的完整昵称精确匹配 - (中心点限定在列表区域),唯一命中后点击,复核「登录」按钮与仍可见的昵称,最后点「登录」; - - 已登录停在标题页(OCR 到「START」,或标题 logo 下的「REVERSE」副标题——实机常只读到 `ERSE`, - 或登录成功后顶栏的「<昵称> 欢迎回来」横幅;START 有时太淡读不到):MAS 无法知道当前登录的是哪个 + - 「登录记录」面板:收起态先点右侧箭头展开列表,再按登录记录里显示的完整昵称匹配 + (含汉字的昵称仅比对汉字,中心点限定在列表区域),唯一命中后点击, + 复核「登录」按钮与仍可见的昵称,最后点「登录」; + - 已登录停在标题页(OCR 到「START」,或标题 logo 下的「REVERSE」副标题——实机常只读到 `ERSE`):MAS 无法知道当前登录的是哪个 账号,为了保证切到目标账号,点标题页右下角退出图标 → 唯一的「确认」→ 等「登录记录」面板出现后按 上一条选号; -3. 登录后等哔哩哔哩面板消失(游戏回到标题页),把这之后的进游戏与任务执行交给 M9A 的 - ``StartUp`` 与用户任务队列。 +3. 登录后等面板消失,点击标题页 START 并等待主页 UID;核对通过后才运行 M9A 任务。 沿用的是哔哩哔哩 SDK 登录面板与标题页的 UI 锚点;布局改版后这里要跟着改。脚本能自己登录的 项目照旧走 ``Run.LoginMode == "Project"``,本模块只在 MAS 原生登录模式下生效。 @@ -46,12 +45,9 @@ - 自动同意只复用全局 ``Config.setting.Function.IfAgreeBilibili``(默认关闭):开关没开时 不点「同意」,提示手动处理并停止本次登录,不新增第二份协议开关; -- 既认不出登录记录、也认不出标题页(例如游戏停在别的账号的主界面)时,**明确停止**并让用户 - 把游戏放到标题页——不能把"没认出登录页"当成"目标账号已登录",否则任务会跑在错的账号上; - 等待时长服从脚本的 `Game.WaitTime`(加载画面同样认不出,不能按固定 60 秒截断慢启动的客户端), - 总时长仍受登录主循环的 240 秒约束; -- 登录后必须连续若干帧认出标题页才算完成;过渡黑屏 / 加载 / OCR 漏字只算未知画面,继续 - 有界等待,到点仍未确认就失败; +- 已在主页时连续两帧核对 UID;确认不匹配时经游戏设置退出,再回到登录面板选号。 + 无法识别画面时按脚本的 Game.WaitTime 等待,受登录总时限约束; +- 登录后等待面板退出,进游戏后核对 UID,未确认身份时不运行后续任务; - 所有循环都先检查总时限,``adb input tap`` 失败直接抛错,不让点击失败被静默吞掉; - 只在 `Mas` 模式、资源是「B 服」、且实际要启动的客户端是 B 服包名时干活:配错客户端要立刻 报错,不能拿官服客户端去走哔哩哔哩登录面板。 @@ -74,8 +70,11 @@ import re import subprocess import time +import unicodedata from collections.abc import Awaitable, Callable from contextlib import suppress +from functools import lru_cache +from pathlib import Path from typing import Any import cv2 @@ -110,8 +109,10 @@ #: 标题页可读的 OCR 文字:「START」按钮(允许 OCR 漏掉首字母)与标题 logo 下的「REVERSE」 #: 副标题(实机常只读到 ERSE)。登录面板与主界面都读不到这两处。 _TITLE_HINTS = ("START", "TART", "REVERSE", "ERSE") -#: 标题页右下角的退出图标位置(相对屏幕;实机 2560x1440 验证,点它弹「是否登出当前账号?」)。 +#: 标题页右下角退出图标的中心(2560×1440 实机验证)。 _EXIT_POINT = (0.956, 0.91) +#: 登录后的标题页「START」按钮中心(相对游戏画面宽高)。 +_START_POINT = (0.50, 0.745) #: ``dumpsys activity activities`` 里表示 Activity 在前台的几种写法(与模拟器应用启动同一口径)。 _FOREGROUND_MARKERS = ( @@ -131,8 +132,8 @@ #: 冷启动的加载页(只有版本号)也算认不出;上限由脚本的 ``Game.WaitTime`` 抬到用户愿意 #: 为启动等的时间(见 ``_unknown_screen_budget``),不能按这个固定值截断慢启动的客户端。 _UNKNOWN_SCREEN_SECONDS = 60.0 -#: 连续多少帧都认出标题页才判「已回到标题页」,避免过渡帧 / OCR 漏字误判。 -_TITLE_CONFIRM_FRAMES = 2 +#: 连续多少帧未见登录面板才允许 M9A 接手启动。 +_PANEL_CLOSED_FRAMES = 2 #: 连续多少帧都认成标题页才动手登出,避免偶发误识别点到别处。 _TITLE_SETTLE_POLLS = 3 #: 点退出图标后等「确认」弹窗的上限。 @@ -148,7 +149,7 @@ #: 登录记录里账号昵称中心点范围(列表区域)。 _ACCOUNT_X_RANGE = (0.40, 0.57) _ACCOUNT_Y_RANGE = (0.37, 0.70) -#: 收起态「切换其他账号」行右侧展开箭头的位置(相对屏幕)。 +#: 收起态账号卡片右侧的展开箭头(相对游戏画面宽高)。 _EXPAND_POINT = (0.608, 0.43) Box = tuple[int, int, int, int] @@ -163,6 +164,22 @@ def _mask(text: str) -> str: return f"{value[0]}{'*' * (len(value) - 1)}" +def _account_matches(text: str, account: str) -> bool: + """含汉字的昵称仅比较汉字,其余昵称精确匹配 OCR 原文。""" + + def han(value: str) -> str: + return "".join( + char + for char in value + if unicodedata.name(char, "").startswith( + ("CJK UNIFIED IDEOGRAPH-", "CJK COMPATIBILITY IDEOGRAPH-") + ) + ) + + account_han = han(account) + return han(text) == account_han if account_han else text == account + + def _center(box: Box) -> tuple[float, float]: x, y, width, height = box return x + width / 2, y + height / 2 @@ -207,16 +224,6 @@ def _privacy_prompt(items: list[tuple[str, Box]]) -> bool: return any(_PRIVACY_HINT in text for text, _ in items) -def _welcome_like(items: list[tuple[str, Box]]) -> bool: - """标题页的「<昵称> 欢迎回来」横幅。 - - 登录成功后标题页顶部会出现这条横幅,START 有时被它盖住或太淡 OCR 读不到;横幅本身 - 是「已登录并回到标题页」的正向信号。只在标题页出现,登录面板与主界面都读不到。 - """ - - return any(_WELCOME_HINT in text for text, _ in items) - - def _warning_like(items: list[tuple[str, Box]]) -> bool: """冷启动的健康警告页(「警告:请在游戏前阅读」):点一下继续。 @@ -227,10 +234,31 @@ def _warning_like(items: list[tuple[str, Box]]) -> bool: return any(_WARNING_HINT in text for text, _ in items) -def _title_like(items: list[tuple[str, Box]]) -> bool: - """标题页的样子:OCR 读到「START」这类按钮文字(登录面板与主界面都读不到)。""" +def _title_like(items: list[tuple[str, Box]], frame: np.ndarray) -> bool: + """识别标题文字,或用左上版本号和左下适龄标识共同确认标题页。""" - return any(text.strip().upper() in _TITLE_HINTS for text, _ in items) + if any( + _WELCOME_HINT in text or "".join(text.upper().split()) in _TITLE_HINTS + for text, _ in items + ): + return True + version = _pick( + items, + lambda text: bool( + re.fullmatch(r"[A-Z]?\d+\.\d+\.\d+(?:[-.]\d+)+", text.strip(), re.I) + ), + frame, + x_range=(0.02, 0.25), + y_range=(0.0, 0.10), + ) + rating = _pick( + items, + lambda text: text.strip().upper() in {"CADPA", "适龄提示"}, + frame, + x_range=(0.0, 0.12), + y_range=(0.80, 0.98), + ) + return bool(version and rating) def _is_bilibili_resource(resource_name: str | None) -> bool: @@ -499,7 +527,7 @@ async def _select_account( matches = _pick( items, - lambda text: text == account, + lambda text: _account_matches(text, account), frame, x_range=_ACCOUNT_X_RANGE, y_range=_ACCOUNT_Y_RANGE, @@ -523,7 +551,7 @@ async def _select_account( selected = _pick( items, - lambda text: text == account, + lambda text: _account_matches(text, account), frame, x_range=_ACCOUNT_X_RANGE, y_range=_ACCOUNT_Y_RANGE, @@ -541,38 +569,153 @@ async def _wait_panel_closed( device: _Device, *, progress: Progress | None, -) -> str: - """登录后等哔哩哔哩面板消失,并用标题页的正向信号确认已回到标题页。 - - 「一帧没有登录记录文字」不算完成:过渡黑屏、加载动画、OCR 漏字都可能满足; - 只有连续 ``_TITLE_CONFIRM_FRAMES`` 帧认出标题页才算成功,其余画面继续有界等待, - 等满总时限仍没确认就失败。标题页的正向信号有两种:OCR 读到「START」,或读到登录成功 - 后的「<昵称> 欢迎回来」横幅(横幅有时会盖住或淡化 START)。 - """ +) -> None: + """等待登录面板消失,遇到标题页先点击 START。""" deadline = time.monotonic() + _LOGIN_TIMEOUT - title_frames = 0 + closed_frames = 0 while True: if time.monotonic() > deadline: raise MaaFWLoginRequired( - "B 服登录失败:点击登录后未确认回到游戏标题页(等待超时)" + "B 服登录失败:点击登录后等待超时,登录面板未关闭或未识别到标题页、游戏主页" ) frame, items = await _ocr(device) if _privacy_prompt(items): await _agree_privacy(device, frame, items, progress=progress) - title_frames = 0 + closed_frames = 0 continue if _has(items, _LOGIN_RECORD, _SWITCH_OTHER): - title_frames = 0 - elif _title_like(items) or _welcome_like(items): - title_frames += 1 - if title_frames >= _TITLE_CONFIRM_FRAMES: - await asyncio.sleep(_TITLE_SETTLE_SECONDS) - return "B 服登录完成,已确认回到游戏标题页,交给 M9A 进游戏运行" + closed_frames = 0 else: - # 过渡帧(黑屏 / 加载 / OCR 漏字):不提前判成功,继续有界等待 - title_frames = 0 + closed_frames += 1 + if closed_frames == _PANEL_CLOSED_FRAMES: + await asyncio.sleep(_TITLE_SETTLE_SECONDS) + continue + if closed_frames > _PANEL_CLOSED_FRAMES: + if _title_like(items, frame): + height, width = frame.shape[:2] + await device.tap_point( + width * _START_POINT[0], height * _START_POINT[1] + ) + await _progress(progress, "B 服选号后已点击标题页 START") + return + if _homepage_uid(items, frame, require_label=True): + await _progress(progress, "B 服登录后已进入游戏主页") + return + await asyncio.sleep(_POLL_SECONDS) + + +def _homepage_uid( + items: list[tuple[str, Box]], + frame: np.ndarray, + *, + require_label: bool = False, +) -> str: + height, width = frame.shape[:2] + matches = [ + match[2] + for text, box in items + if ( + match := re.fullmatch( + r"(?:(UID|ID)\s*[::]?\s*)?([0-9]+)", text.strip(), re.I + ) + ) + and (not require_label or match[1]) + and 45 <= _center(box)[0] * 1280 / width <= 165 + and 113 <= _center(box)[1] * 720 / height <= 145 + ] + return matches[0] if len(matches) == 1 else "" + + +async def _verify_game_uid(device: _Device, expected_uid: str) -> str: + """选号后只允许连续两帧核对通过的账号进入任务队列。""" + + deadline = time.monotonic() + _LOGIN_PANEL_SECONDS + previous_uid = "" + while time.monotonic() < deadline: + frame, items = await _ocr(device) + actual_uid = ( + "" + if _privacy_prompt(items) or _has(items, _LOGIN_RECORD, _SWITCH_OTHER) + else _homepage_uid(items, frame) + ) + if actual_uid and actual_uid == previous_uid: + if actual_uid != expected_uid: + raise MaaFWLoginRequired( + "B 服登录失败:游戏内 UID 与配置不一致,已停止运行" + ) + return "B 服登录完成,已核对游戏内 UID,继续运行 M9A 任务" + previous_uid = actual_uid await asyncio.sleep(_POLL_SECONDS) + raise MaaFWLoginRequired("B 服登录失败:未能确认游戏内 UID,已停止运行") + + +@lru_cache(maxsize=2) +def _menu_template(name: str) -> np.ndarray: + data = (Path(__file__).with_name("login_assets") / name).read_bytes() + return cv2.imdecode(np.frombuffer(data, dtype=np.uint8), cv2.IMREAD_COLOR) + + +def _find_menu_button(frame: np.ndarray, name: str, roi: Box) -> Box | None: + height, width = frame.shape[:2] + x, y, w, h = roi + scaled = cv2.resize(frame, (1280, 720)) + template = _menu_template(name) + th, tw = template.shape[:2] + if th > h or tw > w: + return None + scores = cv2.matchTemplate( + scaled[y : y + h, x : x + w], template, cv2.TM_CCOEFF_NORMED + ) + _, score, _, (dx, dy) = cv2.minMaxLoc(scores) + if score < 0.8: + return None + return ( + round((x + dx) * width / 1280), + round((y + dy) * height / 720), + round(tw * width / 1280), + round(th * height / 720), + ) + + +async def _logout_from_homepage(device: _Device, *, progress: Progress | None) -> None: + """按 M9A 的退出导航回到标题页或 B 服登录记录。""" + + await _progress(progress, "游戏内 UID 与目标账号不同,正在退出当前账号") + deadline = time.monotonic() + _LOGIN_PANEL_SECONDS + # MAA1999/M9A resource/base/pipeline/switch_account.json (AGPL-3.0). + steps = ( + (("设置",), (33, 457, 515, 214), ("MenuRight.png", (450, 484, 119, 125))), + (("账号", "账户"), (22, 78, 177, 630), None), + (("退出",), (796, 500, 161, 53), None), + (("返回登录",), (739, 404, 126, 33), None), + ) + for index, (labels, (x, y, w, h), fallback) in enumerate(steps): + while time.monotonic() < deadline: + frame, items = await _ocr(device) + matches = _pick( + items, + lambda text: text.strip() in labels, + frame, + x_range=(x / 1280, (x + w) / 1280), + y_range=(y / 720, (y + h) / 720), + ) + if len(matches) == 1: + await device.tap(matches[0]) + break + if index == 0 and not matches: + button = _find_menu_button(frame, *fallback) if fallback else None + if button is None and _homepage_uid(items, frame): + button = _find_menu_button( + frame, "MainMenuEntry.png", (3, 460, 141, 151) + ) + if button is not None: + await device.tap(button) + await asyncio.sleep(_POLL_SECONDS) + else: + raise MaaFWLoginRequired( + f"B 服切号失败:游戏设置中未识别到「{'/'.join(labels)}」" + ) async def _logout_to_login_panel( @@ -649,6 +792,9 @@ async def ensure_logged_in( raise MaaFWLoginRequired( "B 服登录失败:账号为空,请在本账号的「账号」里填写登录记录中显示的完整昵称" ) + expected_uid = str(getattr(user_config.Info, "GameUID", "") or "").strip() + if not re.fullmatch(r"[0-9]+", expected_uid): + raise MaaFWLoginRequired("B 服登录失败:请在账号配置中填写游戏内数字 UID") package = str(package_name or "").strip() or BILIBILI_PACKAGE if package != BILIBILI_PACKAGE: raise MaaFWLoginRequired( @@ -673,6 +819,8 @@ async def ensure_logged_in( unknown_budget = _unknown_screen_budget(script_config) title_polls = 0 logout_tried = False + homepage_logout_tried = False + previous_uid = "" while True: if time.monotonic() > deadline: raise MaaFWLoginRequired( @@ -682,6 +830,7 @@ async def ensure_logged_in( if _privacy_prompt(items): await _agree_privacy(device, frame, items, progress=progress) unknown_since = None + title_polls = 0 continue if _warning_like(items): # 冷启动的健康警告页:点一下继续(M9A 的 StartUp 也会处理,但登录跑在它前面) @@ -690,25 +839,43 @@ async def ensure_logged_in( await device.tap_point(width * 0.5, height * 0.5) await asyncio.sleep(1.0) unknown_since = None + title_polls = 0 continue if _has(items, _LOGIN_RECORD): await _select_account(device, frame, items, account, progress=progress) - return await _wait_panel_closed(device, progress=progress) + await _wait_panel_closed(device, progress=progress) + return await _verify_game_uid(device, expected_uid) + + actual_uid = _homepage_uid(items, frame) + if actual_uid: + if actual_uid == previous_uid: + if actual_uid == expected_uid: + return "B 服登录完成,已核对游戏内 UID,继续运行 M9A 任务" + if homepage_logout_tried: + raise MaaFWLoginRequired( + "B 服切号失败:退出后仍停在其他账号的游戏主页" + ) + await _logout_from_homepage(device, progress=progress) + homepage_logout_tried = True + previous_uid = "" + unknown_since = None + title_polls = 0 + continue + unknown_since = None + previous_uid = actual_uid - # 标题页 = 已登录:MAS 不知道当前是哪个号,为了保证切到目标账号,登出后重选。 - # 「START」与「<昵称> 欢迎回来」横幅都算标题页已登录。 - if not logout_tried and (_title_like(items) or _welcome_like(items)): + if not logout_tried and _title_like(items, frame): title_polls += 1 if title_polls >= _TITLE_SETTLE_POLLS: - logout_tried = True await _logout_to_login_panel(device, frame, progress=progress) + logout_tried = True unknown_since = None title_polls = 0 continue else: title_polls = 0 - # 既不是登录界面、也认不出标题页(例如游戏停在别的账号的主界面): + # 既不是登录界面、也认不出标题页(例如加载中的画面): # 无法确认当前账号,明确停止,不能把「没认出来」当成「目标账号已登录」。 # 冷启动的加载画面同样落在这里,所以预算服从 Game.WaitTime,不能按固定 60 秒截断。 if unknown_since is None: diff --git a/app/task/M9A/login_assets/MainMenuEntry.png b/app/task/M9A/login_assets/MainMenuEntry.png new file mode 100644 index 0000000000..ec9502943d Binary files /dev/null and b/app/task/M9A/login_assets/MainMenuEntry.png differ diff --git a/app/task/M9A/login_assets/MenuRight.png b/app/task/M9A/login_assets/MenuRight.png new file mode 100644 index 0000000000..dce6201e67 Binary files /dev/null and b/app/task/M9A/login_assets/MenuRight.png differ diff --git a/app/task/MaaFW/AGENTS.md b/app/task/MaaFW/AGENTS.md index 353189dd31..c6f3e18857 100644 --- a/app/task/MaaFW/AGENTS.md +++ b/app/task/MaaFW/AGENTS.md @@ -823,6 +823,13 @@ MFW 不使用原生编辑器会话或 base 三态选择运行配置;已有配 登录/切号、按游戏语义组织的专属界面、对上游资源文件的动态读取等)可以立专项,MaaEnd 就是 这种情况。立专项时在专项目录写明它比通用 MaaFW 多控制了什么。 +## 运行前登录与启动 + +- 特调可声明 `manages_game_startup(script_config)`,具体契约见 `tools/embedded/flavor.py`。 + 声明生效时,特调的 `ensure_logged_in` 完成进入游戏及账号核对后才能下发任务。失败时下次尝试重新登录。 +- 计划装饰不加入启动任务。宿主按同一计划预建历史,单次 worker 加载资源和 agent、记录环境及任务结果。 + 首项按普通任务处理,显式声明的关键任务保护仍生效。 + ## 测试与排障 - 改 `tools/core/` 先跑 `tests/task/test_maafw_core.py`:核心最小回归,进仓库、长期保留,只收 diff --git a/app/task/MaaFW/tools/core/runner/models.py b/app/task/MaaFW/tools/core/runner/models.py index d6b6ada1d5..98cb827dd1 100644 --- a/app/task/MaaFW/tools/core/runner/models.py +++ b/app/task/MaaFW/tools/core/runner/models.py @@ -85,6 +85,8 @@ class MaaFWRunPlan(BaseModel): pretasks: list[MaaFWPretaskRunPlan] = Field(default_factory=list) piEnv: dict[str, str] = Field(default_factory=dict) tasks: list[MaaFWTaskRunPlan] = Field(default_factory=list) + # 特调在下发计划前已完成启动时,首项按普通任务处理。 + firstTaskStartupGuard: bool = True skippedTasks: list[MaaFWSkippedTaskPlan] = Field(default_factory=list) # 用户的全局选项表(已解密、叠过托管级键位),各任务覆盖里全局那一层的取值。任务的 # options 是给人看的合并结果,同一选项任务自己也有值时看不到这一层,宿主给日志打码时两处都收。 diff --git a/app/task/MaaFW/tools/core/runner/runner.py b/app/task/MaaFW/tools/core/runner/runner.py index f26c0e141e..89a77877df 100644 --- a/app/task/MaaFW/tools/core/runner/runner.py +++ b/app/task/MaaFW/tools/core/runner/runner.py @@ -1673,7 +1673,7 @@ def _handle_task_deadline( 返回 None 表示没超时;``"next"`` 让调用方跳到下一个任务;``"skip_rest"`` 让调用方 结束本轮(不计入失败)。 - 计划里的第一个任务超时直接按失败结束本轮:它往往是前置任务(M9A 特调的 + 启动保护启用时,计划里的第一个任务超时直接按失败结束本轮:它往往是前置任务(M9A 特调的 第一个任务是受管的「启动游戏」),前置都没做完,后面的任务没有意义。看的是 计划位置而不是「还没有任务完成」——第一个任务失败后第二个超时照样继续。 @@ -1729,7 +1729,7 @@ def _handle_task_deadline( level=LEVEL_WARNING, ) return "next" - if index == 0 or task.abortRoundMessage: + if (index == 0 and self.plan.firstTaskStartupGuard) or task.abortRoundMessage: reason = f"{TASK_TIMEOUT_MESSAGE}(限制 {limit_text})" user_reason = f"{task.abortRoundMessage or display_name}:{reason}" if task.abortRoundMessage: @@ -1833,7 +1833,7 @@ def _handle_loop_guard( ) -> Literal["next", "skip_rest"] | None: """本任务被原地打转检测停掉时收尾;返回值同 ``_handle_task_deadline``。 - 口径与 ``_handle_task_deadline`` 完全一致:计划里的第一个任务或特调声明的关键 + 口径与 ``_handle_task_deadline`` 完全一致:受启动保护的第一个任务或特调声明的关键 任务(``abortRoundMessage``)结束本轮,收尾任务(``nonFatalMessage``)放过(恰好 又被脚本侧强停时跳过剩余任务;排在中间且等满仍停不下来时结束本轮并计入失败), 其余记一条任务失败再继续。必须早于 @@ -1882,7 +1882,7 @@ def _handle_loop_guard( f"{reason},{stopped}: {display_name};{task.nonFatalMessage}" ) return "next" - if index == 0 or task.abortRoundMessage: + if (index == 0 and self.plan.firstTaskStartupGuard) or task.abortRoundMessage: user_reason = f"{task.abortRoundMessage or display_name}:{reason}" if task.abortRoundMessage: reason = f"{task.abortRoundMessage}:{reason}" diff --git a/app/task/MaaFW/tools/embedded/flavor.py b/app/task/MaaFW/tools/embedded/flavor.py index dfa2212b5a..0d917f5b7b 100644 --- a/app/task/MaaFW/tools/embedded/flavor.py +++ b/app/task/MaaFW/tools/embedded/flavor.py @@ -45,6 +45,15 @@ 走重试(不特殊处理退出条件)。抛其它异常同样按失败处理,原因会附在异常原文后面。 - **取消**:用户停止(``CancelledError``)必须照常向上抛,不能吞掉。 +可选钩子 ``manages_game_startup(self, script_config) -> bool`` 的契约: + +- 建运行计划时通过 ``manages_game_startup`` 解析器探测,缺省为 False。该声明必须与特调 + 的队列装饰规则一致,为 True 时不加入启动任务。 +- 声明为 True 的特调必须在 ``ensure_logged_in`` 成功返回之前完成进入游戏和必要的账号核对。 + 失败时按登录失败结算,保留未完成状态供下次尝试。此模式的首项是普通任务,单任务超时和 + 原地打转不触发首项启动保护,显式声明的关键任务规则仍生效。 +- 宿主和单次 worker 共用同一份任务计划,历史按该计划预建并记录任务事件。 + 可选钩子 ``sanitize_task_snapshot``(写账号任务快照前整理)的契约: - **可选**:同样不进协议,``resolve_snapshot_sanitizer`` 按 ``getattr`` 探测。 @@ -342,6 +351,13 @@ def resolve_login_hook(script_config: Any) -> LoginHook | None: return hook if callable(hook) else None +def manages_game_startup(flavor: MaaFWFlavor, script_config: Any) -> bool: + """特调是否在运行前登录阶段完成进入游戏。""" + + hook = getattr(flavor, "manages_game_startup", None) + return callable(hook) and bool(hook(script_config)) + + def resolve_game_update_hook(script_config: Any) -> GameUpdateHook | None: """脚本对应特调的游戏更新钩子;通用 MaaFW 或特调没实现时返回 None。""" @@ -538,6 +554,7 @@ def _is_empty_json_object(value: Any) -> bool: "resolve_flavor", "resolve_game_update_hook", "resolve_login_hook", + "manages_game_startup", "resolve_snapshot_sanitizer", "retype_notice_lines", "sanitize_user_task_update", diff --git a/app/task/MaaFW/tools/embedded/runner_task.py b/app/task/MaaFW/tools/embedded/runner_task.py index 6452afdead..0fa900a1b8 100644 --- a/app/task/MaaFW/tools/embedded/runner_task.py +++ b/app/task/MaaFW/tools/embedded/runner_task.py @@ -134,6 +134,7 @@ from .embedded_project import resolve_maafw_project_root from .flavor import ( MaaFWLoginRequired, + manages_game_startup, no_default_fallback_inputs, off_day_scope, resolve_flavor, @@ -1362,6 +1363,8 @@ def _build_run_plan( no_default_fallback_inputs=no_default_fallback_inputs(flavor), excluded_task_reasons=dict.fromkeys(off_day_extra, off_day_reason), ) + if manages_game_startup(flavor, self.script_config): + plan.firstTaskStartupGuard = False plan = _mark_nonfatal_tasks(_mark_abort_round_tasks(plan, flavor), flavor) return _with_skipped_tasks( _with_warnings(plan, stale_warnings), missing_skips @@ -1412,6 +1415,7 @@ async def _build_device_config( if plan.controllerType == "Adb": address, device_info = await self._resolve_adb_address() adb_path = await self._resolve_adb_path(address, device_info) + adb_path = await self._resolve_login_adb_path(address, adb_path) await self._ensure_game_updated(address, adb_path) await self._ensure_logged_in(address, adb_path) adb_profile = await self._build_adb_control_profile() @@ -1600,6 +1604,21 @@ async def _resolve_adb_path( ) return None + async def _resolve_login_adb_path( + self, address: str, adb_path: str | None + ) -> str | None: + """在宿主统一解析登录钩子和 worker 使用的 ADB 路径。""" + if adb_path is None and address and resolve_login_hook(self.script_config): + adb_path = await asyncio.to_thread( + self._resolve_adb_path_by_toolkit, address + ) + if adb_path: + self._append_log( + f"ADB 路径选择: MaaFW Toolkit 按地址发现; 路径={adb_path}" + ) + + return adb_path + def _resolve_adb_path_by_toolkit(self, address: str) -> str | None: """按设备地址问 MaaFW Toolkit 要 adb 路径(与 worker 的发现规则同一套)。 @@ -1679,17 +1698,6 @@ async def _ensure_logged_in(self, address: str, adb_path: str | None) -> None: if hook is None or self.run_plan is None: return - if adb_path is None and address: - # 登录钩子跑在 worker 之前,用不到 Runner 的按地址发现;宿主先把这条兜上, - # 否则钩子只能拿 PATH 里的 ``adb``(通用模拟器自带的多半叫 HD-Adb.exe)。 - adb_path = await asyncio.to_thread( - self._resolve_adb_path_by_toolkit, address - ) - if adb_path: - self._append_log( - f"ADB 路径选择: MaaFW Toolkit 按地址发现; 路径={adb_path}" - ) - async def report(text: str) -> None: self._append_log(text) @@ -2096,7 +2104,8 @@ async def _resolve_window_handle(self, controller: MaaFWController) -> int: return int(matches[0].hWnd) async def _run_maafw(self, device_config: MaaFWDeviceConfig) -> MaaFWRunResult: - if self.run_plan is None: + plan = self.run_plan + if plan is None: raise RuntimeError("MaaFW 运行计划尚未初始化") limit_minutes = self.script_config.Run.RunTimeLimit timeout = limit_minutes * 60 @@ -2136,7 +2145,8 @@ async def _run_maafw_worker( task_time_limit_overrides: dict[str, int] | None = None, loop_guard: bool = False, ) -> MaaFWRunResult: - if self.run_plan is None: + plan = self.run_plan + if plan is None: raise RuntimeError("MaaFW 运行计划尚未初始化") service = MaaFWRunnerService() @@ -2234,9 +2244,9 @@ def _prepare_environment_with_proxy() -> MaaFWRunnerEnvironment: event_server_failure: str | None = None try: self._check_avd_runtime_version(runner_environment.maafw_version) - runner_plan = self.run_plan + runner_plan = plan if runner_environment.maafw_version: - runner_plan = self.run_plan.model_copy(deep=True) + runner_plan = plan.model_copy(deep=True) runner_plan.piEnv["PI_CLIENT_MAAFW_VERSION"] = ( f"v{runner_environment.maafw_version.lstrip('v')}" ) diff --git a/changelog.d/fix-m9a-bili-nickname.fix.md b/changelog.d/fix-m9a-bili-nickname.fix.md new file mode 100644 index 0000000000..ba4f7d67c9 --- /dev/null +++ b/changelog.d/fix-m9a-bili-nickname.fix.md @@ -0,0 +1,4 @@ +project: m9a +beta-only: true + +修复 B 服特殊昵称选号并支持从主页切号,已有 MAS 登录账号需在账号配置中补填游戏 UID。 diff --git a/frontend/src/api/models/M9AUserConfig_Info.ts b/frontend/src/api/models/M9AUserConfig_Info.ts index c4877b3973..744731c360 100644 --- a/frontend/src/api/models/M9AUserConfig_Info.ts +++ b/frontend/src/api/models/M9AUserConfig_Info.ts @@ -63,5 +63,9 @@ export type M9AUserConfig_Info = { * 账号标签信息 */ readonly Tag?: (string | null); + /** + * 游戏内数字 UID,用于 MAS 登录 B 服后核对账号 + */ + GameUID?: (string | null); }; diff --git a/frontend/src/api/models/M9AUserConfig_InfoInput.ts b/frontend/src/api/models/M9AUserConfig_InfoInput.ts index 750671c95b..28dcdfed50 100644 --- a/frontend/src/api/models/M9AUserConfig_InfoInput.ts +++ b/frontend/src/api/models/M9AUserConfig_InfoInput.ts @@ -63,5 +63,9 @@ export type M9AUserConfig_InfoInput = { * 账号标签信息 */ readonly Tag?: (string | null); + /** + * 游戏内数字 UID,用于 MAS 登录 B 服后核对账号 + */ + GameUID?: (string | null); }; diff --git a/frontend/src/types/script.ts b/frontend/src/types/script.ts index 402afe8f13..884bcbb0c8 100644 --- a/frontend/src/types/script.ts +++ b/frontend/src/types/script.ts @@ -311,6 +311,8 @@ export interface MaaFWUserConfig { Tag?: string | null Account: string Password: string + /** M9A 游戏内 UID,供 MAS 登录 B 服后核对。 */ + GameUID?: string Resource?: string Base?: 'shared' | 'independent' | 'native' /** 快速配置:独立于配置来源的用户级开关 */ diff --git a/frontend/src/views/EditView/MaaFWFlavor/sectionContracts.ts b/frontend/src/views/EditView/MaaFWFlavor/sectionContracts.ts index 63ca0784b6..385fda7bbe 100644 --- a/frontend/src/views/EditView/MaaFWFlavor/sectionContracts.ts +++ b/frontend/src/views/EditView/MaaFWFlavor/sectionContracts.ts @@ -213,6 +213,7 @@ export interface MaaFWUserBasicInfoSectionProps { accountRecordTooltip: string /** 账号字段占位:特调类型可能把账号绑成切号任务,文案不再是「仅本地记录」 */ accountPlaceholder?: string + showGameUid?: boolean } export interface MaaFWUserBasicInfoSectionEmits { diff --git a/frontend/src/views/EditView/User/MaaFWUserEdit.vue b/frontend/src/views/EditView/User/MaaFWUserEdit.vue index 6605d2d6a3..eaa98f6ef3 100644 --- a/frontend/src/views/EditView/User/MaaFWUserEdit.vue +++ b/frontend/src/views/EditView/User/MaaFWUserEdit.vue @@ -36,6 +36,7 @@ :interface-dependent-disabled="interfaceDependentDisabled" :account-record-tooltip="accountRecordTooltip" :account-placeholder="t(flavor.userPage.text.accountPlaceholderKey)" + :show-game-uid="flavor.type === 'M9A'" @save="handleFieldSave" /> diff --git a/frontend/src/views/EditView/User/MaaFWUserEdit/BasicInfoSection.vue b/frontend/src/views/EditView/User/MaaFWUserEdit/BasicInfoSection.vue index de8188b6af..3a91cbf5da 100644 --- a/frontend/src/views/EditView/User/MaaFWUserEdit/BasicInfoSection.vue +++ b/frontend/src/views/EditView/User/MaaFWUserEdit/BasicInfoSection.vue @@ -47,6 +47,22 @@ /> + + + + + @@ -55,6 +71,7 @@